Compare commits

..
Author SHA1 Message Date
jschoubben af4e4e60a3 Carry mesh package versions in build asks and fingerprints
ADR 0573 §2 has every module build against the latest published version of
each mesh package it uses, chosen by the planner and written by the build,
never pinned by hand (issue 1585, S4). The ask, the fetch request and the
merge preview now carry the packages and their versions, and the new-form
fingerprint takes each version, so a package's trunk merge changes every
dependent's fingerprint. A module using no package keeps its fingerprint.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 01:57:26 +01:00
mesh-tools ff5e00f1f8 Merge pull request 'The contract of the node-engine's data items, so disk-load reads what the engine composed (ADR 0577)' (#151) from feat/data-items into main 2026-11-01 00:03:10 +00:00
jschoubben c28c645ca4 Name the node-engine's data items state, so mesh-host writes and disk-load reads one shape (ADR 0577)
mesh/delivery merged: source.main moved to novox/mesh-sdk@ff5e00f1f894
mesh/delivery-group group feat/data-items merged: every member is merged
mesh/check pass c28c645ca406 on 5ae69df8ccde | unit 4510 run, 4259 kept, 49 skipped | integration 4589 run, 4361 kept, 51 skipped | over bound: unit, …
The contract (state name, key form, value) is in delivery; buscompose grants the engine its own
node's keys of mesh-host_data and each node's disk-load a read of its own node's keys alone, the
same way the engine's other states are granted.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 00:05:38 +01:00
mesh-tools 5ae69df8cc Merge pull request 'A finding's reference is ref, not token, and a held seat carries its own events (issue 1952)' (#150) from fix/l4-quick into main 2026-10-31 21:33:56 +00:00
jschoubben cbbfbf3d2d Let observers' findings and a carried holder's seat events reach the bus (issue 1952)
mesh/delivery merged: source.main moved to novox/mesh-sdk@5ae69df8ccde
mesh/delivery-group group fix/l4-quick merged: every member is merged
mesh/check pass cbbfbf3d2d55 on fea48f19cea5 | unit 2729 run, 4570 kept, 16 skipped | integration 2975 run, 4493 kept, 25 skipped | over bound: unit, …
A finding's field `token` is named like a credential, so the tool runner
refused every finding that carried it; it is `ref` now, and a finding
written under the old name is still read. A module the tool runner
carries got no grant for its own held seat's events, so the self-check
could not say `checked`; a held plain seat's events are now in the
holder's seat traffic, its membership and its runtime's grants.

Quick vs thorough: quick, these fixes for L4's take-over now; the
thorough fix (research 032 / ADR 0240's health view extension) is
decided later.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-10-31 21:30:31 +01:00
mesh-tools fea48f19ce Merge pull request 'A contribution carries the machine port of its endpoint, so a provider serving from plans dials what the module binds (issue 774)' (#149) from fix/route-ports into main 2026-10-31 12:56:48 +00:00
jschoubben bb01a656d0 Carry an unassigned port as the declaration publishes it, refusing only a port a module does not listen on (issue 774, research 072 row 34)
mesh/delivery merged: source.main moved to novox/mesh-sdk@fea48f19cea5
mesh/delivery-group group fix/route-ports merged: every member is merged
mesh/check failed: integration: mesh-vault/cmd/mesh-vault.TestTheVaultWholeMintsOnTheBusWhoeverHoldsTheLease: never: the account minted under the cont…
influxdb and unifi publish bare ports that mesh-placement assigns none: the machine runs them at
the software's own number, so a plan refusing them held their whole plan for a route that works.
2026-10-31 13:21:37 +01:00
jschoubben b611022457 Carry the machine-assigned port in a plan's contribution, so a provider serving from plans dials where the module listens (issue 774, research 072 row 34)
route-proxy served every route at the software's own port (gitea's 3000, published at 20000;
grafana's 3000 on another machine, published at 20012) and public sites answered 502. The plan's
contribution now carries the port the placement assigned, the number ${port:N} gives the module,
and a composition for plans refuses a port none assigned by module, node and port.
2026-10-31 13:08:35 +01:00
mesh-tools fbbfae7629 Merge pull request 'Placement keyed per node and module, no ASSIGNMENTS stream, and plan entries carrying a consumer's contribution (issue 774)' (#148) from feat/072-moves into main 2026-10-31 10:30:57 +00:00
jschoubben acbf80ad91 Carry what a consumer contributes in its plan entry as one opaque payload, so a provider serving from the seat's plans reads it with its own contract and the provisioner knows no provision's fields (issue 774, research 072 row 34)
mesh/delivery merged: source.main moved to novox/mesh-sdk@fbbfae7629bf
mesh/delivery-group group feat/072-moves merged: every member is merged
mesh/check failed: shanks: the check of novox/mesh-controller#487 failed: go mod download in modules/route-proxy: go: git.novox.be/novox/mesh-controll…
2026-10-31 09:47:08 +01:00
jschoubben de8e82c06f Say what the controller's lease turns now that the vault mints whoever holds it: grants' consumers, the grant source, retirement and rotation (issue 774) 2026-10-31 09:34:27 +01:00
jschoubben 085f17e935 Let a node composing itself hand a module exactly when the vault judged its plan handed, through one rule (catalogue.AssignmentHands), so the vault and the node never judge postgres's secrets two ways again (issue 774) 2026-10-31 09:29:33 +01:00
jschoubben 2d1d8def75 Take the plan entry's Given and ConsumerAt back out: route-proxy's seat defines its own contract and the provisioner's plan stays generic (the operator, 2026-10-31; issue 774). process.Conn.Read stays. 2026-10-31 09:29:17 +01:00
jschoubben 98d5306d6b Say which machines are inside the private network from placement, for route-proxy, whose membership read goes with the assignments stream (issue 774, research 072 rows 33 and 34) 2026-10-31 09:28:15 +01:00
jschoubben a4e7629742 Carry each contribution, composed, and the consumer's address in its plan entry, and let a process read another module's state, so route-proxy serves routes from the seat's plans alone (issue 774, research 072 row 34) 2026-10-31 09:27:26 +01:00
jschoubben a320f8e2cd Keep placement.<node> in step after each commit, so a reader of the form before alone follows every write and the delivery's members need no order (issue 774, research 072 row 37) 2026-10-31 09:26:17 +01:00
jschoubben 7a9c8570cb Stop declaring and reading the ASSIGNMENTS stream: a runtime reads its membership from the grants part's state, and the stream is deleted at the lease write and must not be made again (issue 774, research 072 row 33) 2026-10-31 09:21:04 +01:00
jschoubben 4c54aec972 Keep the seat mesh-placement's state as one key per module and node, so a node watches its own keys and a change writes only the modules it moves (issue 774, research 072 row 37)
The node key <node>.node is a write's commit: it names each placed module with its value's digest, the
module keys carry the value they replace until it lands, so a reader never composes half a write. Every
reader goes through PlacementKeys/PlacementsOf, which read the legacy placement.<node> too while the
change is delivered.
2026-10-31 09:21:04 +01:00
jschoubben d352fe51b4 Let the bus module's tools delete the stream ASSIGNMENTS at the lease write, the only stream a bus account may delete (issue 774, research 072 row 33) 2026-10-31 09:12:31 +01:00
mesh-tools 712ec9481b Merge pull request 'A node-engine answers any verb of its own node, so a new engine verb needs no grants release (issue 774)' (#147) from fix/engine-seat-any-verb into main 2026-10-31 00:30:54 +00:00
mesh-tools 50ea6f748e Merge pull request 'A node says when it composes again, says what a long pass waits on, and composes on request (issue 774)' (#146) from fix/compose-retries-transient into main 2026-10-31 00:21:31 +00:00
jschoubben 748543c35f Grant a node's engine any verb and event of its node seat on its own node, so a verb added to the contract needs no grants release first (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@712ec9481b40
mesh/delivery-group group fix/engine-seat-any-verb merged: every member is merged
mesh/check checking 748543c35f7f on 712ec9481b40 | novox/mesh-sdk#147 in group fix/engine-seat-any-verb
The grants part composed the engine's subscriptions from catalogue.EngineVerbs at its own mesh-sdk pin, so mesh-host #184's optional verb composition was refused on every node and every engine reopened its link every four minutes. Quick fix: re-pin the grants part. Thorough fix (this): the seat's subjects on the engine's node, any verb, as a module answers mesh.mod.<module>.tool.>; callers stay granted per verb.
2026-10-31 01:05:57 +01:00
jschoubben dfd5d8295e Add the node-engine verb compose to the seat's contract: compose now, the recovery of a failed composition (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@50ea6f748e30
mesh/delivery-group group fix/compose-retries-transient merged: every member is merged
mesh/check being carried to dfd5d8295e0a on 50ea6f748e30 | from dec3bfe7a8cb | novox/mesh-sdk#146 in group fix/compose-retries-transient
2026-10-31 00:53:30 +01:00
mesh-tools dec3bfe7a8 Merge pull request 'A held module keeps what it contributes, a have stuck at waiting-yes clears, and the node-engine shows a composition (issue 774)' (#145) from fix/held-keeps-contributions into main 2026-10-30 23:52:51 +00:00
jschoubben 8ed3b872e0 Leave out a held module's contribution whose grant is not issued, so a provider never applies a stand-in (issue 774, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@dec3bfe7a8cb
mesh/delivery-group group fix/held-keeps-contributions merged: every member is merged
mesh/check checking 8ed3b872e0ba on dec3bfe7a8cb | novox/mesh-sdk#145 in group fix/held-keeps-contributions
A module held for its own secret keeps its contributions (leaveOwn). When its grant to the provider was not issued
yet, the provider's grants file carried "foreseen: made by the next send" for it, and the applying path passed over
stand-ins of a module already held. Such a stand-in now leaves that provision's contribution out of the held module.
2026-10-31 00:34:44 +01:00
jschoubben 6bce1fb0d2 Add the node-engine verb composition to the seat's contract, and the resources two compositions differ in (issue 774)
A provider waiting for a yes said why only in its have's words; nobody could read the composition that waits beside
what runs. The engine's read verb composition answers that, and ChangedResources lists what a move changes, a
received file naming the consumers that join or leave.
2026-10-31 00:23:42 +01:00
jschoubben 409c56685d Keep a held module's contributions when only its own secret is missing, and say a provider's move as consumers joining or leaving (issue 774)
A module held for an own secret never made (nextcloud's admin on novox, 2026-10-30) was composed as a bare stub, so
its contributions left postgres's grants and keycloak's clients, and their helpers would have retired its login.
The culprit now tries leaving out its own secrets alone before its whole offer.

JudgeMove said a changed received-, bound- or secret- file as "where its data is kept"; it now names the consumers
that join or leave. The judgement stays a move: its yes is what kept nextcloud's login.

Quick fix: leave out own secrets alone before the whole offer. Thorough fix (not here): carry a held module's last
contributions from what runs into its providers' received files whatever part of it fails to compose.
2026-10-31 00:21:33 +01:00
mesh-tools b9ef1e4fcb Merge pull request 'One rule for whether a plan hands its module, so no part reads a provisions-only plan as handed (issue 774)' (#144) from fix/plan-hands into main 2026-10-30 23:18:58 +00:00
jschoubben 17fc9b9b81 Say whether a plan hands its module in one rule, Plan.Hands, so no reader takes a plan naming provisions alone for a handed module (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@b9ef1e4fcb68
mesh/delivery-group group fix/plan-hands merged: every member is merged
mesh/check being carried to 17fc9b9b8115 on b9ef1e4fcb68 | from 125bb9177189 | novox/mesh-sdk#144 in group fix/plan-hands
2026-10-30 23:50:09 +01:00
mesh-tools 125bb91771 Merge pull request 'One rule for which provisions a module waits for, so the assigner, the plans and the provisioner agree and grafana gets its route (issue 774)' (#143) from fix/one-rule-for-provisions into main 2026-10-30 22:33:49 +00:00
jschoubben 6c5a8ef893 Test that the composition marks a need its provider's helper answers, so the consumer's plan keys it (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@125bb9177189
mesh/delivery-group group fix/one-rule-for-provisions merged: every member is merged
mesh/check being carried to 6c5a8ef893b4 on 125bb9177189 | from b74e23f973ab | novox/mesh-sdk#143 in group fix/one-rule-for-provisions
The review found answeredByHelper untested: forced false, every test passed. This row fails then.
2026-10-30 22:29:12 +01:00
jschoubben 91aa7b78fc Key provisions by one rule the plan, the seat and the assigner share (issue 774)
The assigner's rule 8 waited for a key for every `requires`, while the plans named only the needs of handed modules:
every new build of grafana, dnsmasq, mesh-knowledge, mesh-mail and the builders waited for a key nobody would make,
and grafana, not handed, had no plan, so no route key and no route on route-proxy.

catalogue.Keyed is the rule (a need a module answers is keyed when its consumer is handed or the provider's helper
answers it), catalogue.Awaited the same rule from manifests for the assigner, and a module not handed now has a plan
for what a helper answers of it. Handed modules' plans are unchanged.

Quick fix: drop the missing-key check from rule 8. Thorough fix (chosen): one rule in mesh-sdk all three parts use,
since the kind (three parts deciding the same set apart) already drifted once and hid failures as waits.
2026-10-30 22:29:12 +01:00
mesh-tools b74e23f973 Merge pull request 'A module held for a missing secret gets no runtime of its own, so it never refuses its node's whole composition (issue 774)' (#142) from fix/held-module-and-assigner-contract into main 2026-10-30 21:17:38 +00:00
jschoubben eff81f1220 Never refuse a node for a held module's stand-in by its own runtime's rules (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@b74e23f973ab
mesh/delivery-group group fix/held-module-and-assigner-contract merged: every member is merged
mesh/check checking eff81f1220e2 on b74e23f973ab | novox/mesh-sdk#142 in group fix/held-module-and-assigner-contract
On 2026-10-30 at 21:04 mesh-self-check (ace) and mesh-conditions, mesh-healers (novox) were placed before
their bus accounts were issued. Each was held for "needs a secret called broker and none was made"; its bare
stub then lost its own secrets and was refused by ownRuntimes ("runs as its own account and declares no own
secret broker"), a refusal no culprit takes, so 74 modules on ace and 79 on novox failed. A held runtime's stub
met the same kind earlier today ("its build produced 0 bundle(s)").

Quick fix: let the bare stub keep its broker own secret, for this one rule.
Thorough fix (chosen, the fourth of the kind today): a held module's stand-in (Stubbed) has no runtime
composed at all, so no rule about its runtime, bundle or broker can refuse it: ownRuntimes passes it over and
runtimeProcess composes none for a held runtime (what runs of either is carried, as before). A module of its
own account left without its runtime by a held runtime is refused with a typed NoRuntimeToRunError, which makes
the held runtime the culprit, so those modules are held after it and the node composes the rest.
2026-10-30 21:18:06 +01:00
mesh-tools 6a71d29b33 Merge pull request 'A module's bus account not issued yet is foreseen in a node's answer, so a new module can be assigned; a refusal leads with each held module's first reason (issue 774)' (#141) from fix/kept-read-and-foreseen-account into main 2026-10-30 19:49:12 +00:00
jschoubben 4e9621edd6 Lead a refusal of a held module's stand-in with why the module was held, so its first reason is never hidden (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@6a71d29b3331
mesh/delivery-group group fix/kept-read-and-foreseen-account merged: every member is merged
mesh/check pass 4e9621edd664 on 14de8dadb4ce | unit 2719 run, 602 kept, 36 skipped | integration 2731 run, 645 kept, 29 skipped | over bound: unit, in…
The proposal on ace said only "mesh-self-check runs as its own account and declares no own secret broker": the words
of its bare stub, held for "has no bus credential", which no one saw. The second hidden first reason of the day, after
"0 bundle(s)".

Quick fix: log the held map in composeEach. Thorough fix: firstReason leads a refusal no culprit is found for with the
first reason of every held module it names ("<m> is held: <why>; its stand-in is then refused: <err>"), for Compose and
the node's Answer alike; the hold's own why already keeps its first words. Chose thorough.
2026-10-30 20:33:54 +01:00
jschoubben 0cdbdca139 Foresee a bus account not yet issued in a composition asked ahead of the Approve, so a module with its own account can be placed (issue 774)
On 2026-10-30 the placement proposal p-20261030t192428-77dabc was refused on ace and novox: "has no bus credential:
nothing was issued for novox.mesh-conditions" (and mesh-self-check, mesh-healers). The vault mints a bus account from
the grants part's wanted list once the placement is written on the operator's Approve (ADR 0561 §1: "From the lease
write, the vault mints every new bus account and the controller mints none"; ADR 0567 §5: "A placement is written on
the operator's Approve"), so a shadow composition can never hold it, and no new module with its own account could be
placed.

Quick fix: skip the check for the three modules. Thorough fix: the Foreseeing mode foresees an unissued bus account like
any own secret the next step makes; the issue-203 refusal (busCredentialIssued), the controller's send's, is gone, and
the Reading mode still leaves out what was never issued. Chose thorough: it holds for every module with its own account.
2026-10-30 20:33:05 +01:00
jschoubben 14de8dadb4 Merge pull request 'A build not done yet holds its module, never every module of a node, and a refusal names what was held (issue 774)' (#140) from fix/build-not-done-holds-one-module into main
Reviewed-on: #140
2026-10-30 18:53:04 +00:00
jschoubben 9685990a15 Compose a module at the build its want names and hold a running one whose build is not done, so the runtime never loses its bundle (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@14de8dadb4ce
mesh/delivery-group group fix/build-not-done-holds-one-module merged: every member is merged
mesh/check checking 9685990a15ed on 14de8dadb4ce | novox/mesh-sdk#140 in group fix/build-not-done-holds-one-module
At 19:30 on 2026-10-30 node-tools' trunk fingerprint moved to a build not yet done; fromBuilds then composed it from
the indexer's manifest, which names no bundle, and ace, g14 and shanks refused every module ("its build produced 0
bundle(s)") until another input moved after the build landed.

Quick fix: skip the runtime's check while its build is not done. Thorough fix: Compose reads each placed module at the
build its want names (as Scope.KeptAt), and a module that runs whose build is not done is held, keeping what runs (a
held runtime is composed as what runs of it, its running bundle), named with why; a module nothing runs of still
composes from the indexer's manifest as before. Input, PlansHeld and Answer read the trunk's builds as before. Chose
thorough: no running module is ever composed from a source manifest that names no build.
2026-10-30 19:45:32 +01:00
jschoubben d539d7e4eb Take the runtime as a culprit only where its stub composes, so node-tools is never held for an error that names it (issue 774)
The culprit rule held a module whose stub made the composition fail otherwise. The runtime's stub carries no bundle,
so it always fails otherwise: any refusal naming node-tools (or a module whose name holds it) held the runtime, and
the node was then refused whole for the runtime's "0 bundle(s)".

Quick fix: never take node-tools as a culprit. Thorough fix: blames, the rule as one function, takes the runtime only
where its stub composes. Chose thorough: a runtime whose stub composes may still be held alone.
2026-10-30 19:44:08 +01:00
jschoubben c1bc21c35f Say what a node held on the way when it is refused whole, so a held module's own refusal is never hidden (issue 774)
At 19:30 on 2026-10-30 ace, g14 and shanks refused every module with "node-tools … its build produced 0 bundle(s)":
the refusal of node-tools' stand-in, while why node-tools could not be composed was dropped with the hold.

Quick fix: log held in mesh-host before marking. Thorough fix: Compose's node-wide error carries "(held before:
<module>: <why>; …)", so the engine's journal, every have's detail and any caller see the cause. Chose thorough.
2026-10-30 19:43:46 +01:00
jschoubben 16932c93d6 Merge pull request 'A manifest the mesh refuses holds only its own module, never every node's composition (issue 774)' (#139) from fix/one-bad-item-holds-one-module into main
Reviewed-on: #139
2026-10-30 18:30:53 +00:00
jschoubben 754edfae69 Hold only the module whose manifest the mesh refuses, so one bad item no longer fails every node's composition (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@16932c93d672
mesh/delivery-group group fix/one-bad-item-holds-one-module merged: every member is merged
mesh/check checking 754edfae6924 on 16932c93d672 | novox/mesh-sdk#139 in group fix/one-bad-item-holds-one-module
On 2026-10-30 at 19:13 power's bar item (mesh-catalog ca8d6f33) was refused by ParseManifest, and frombus's input
returned that refusal for the whole read: every node-engine marked all its modules failed and no update could land.
The third of a kind that day, after the bus user list and the provisioner's plans (PlansHeld).

Quick fix: skip a manifest that does not parse in input's catalogue loop. Thorough fix: input follows the rule a build
that cannot be read already follows there (refuse + stub), so Compose, Answer and PlansHeld all hold the one module:
held with why on a node it is placed on, read for its offer alone (its unjudged stub, without items) elsewhere. A
manifest the bus does not answer for still ends the read (transient, waited out by the caller), and Input (refuse
unset, the check's whole composition) still refuses. Chose thorough: one place serves all three callers.
2026-10-30 19:24:15 +01:00
jschoubben 4c1384faef Merge pull request 'One bad module no longer drops its machine, too-large values and bus blackouts fail loud or are waited out, watches are never forgotten (issue 774)' (#138) from fix/sweep-2026-10-30 into main
Reviewed-on: #138
2026-10-30 18:12:47 +00:00
jschoubben a37181261c Never ask a call again that its callee answered, whatever its words (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@4c1384faefa0
mesh/delivery-group group fix/sweep-2026-10-30 merged: every member is merged
mesh/check checking a37181261c24 on 4c1384faefa0 | novox/mesh-sdk#138 in group fix/sweep-2026-10-30
The review found that busretry could ask a delivered call again: Conn.request turned a callee's error answer into
errors.New(words), and Undelivered matched the words "no responders" and "jetstream not enabled", so a callee that
answered so during a blackout ran its work twice. A callee's answer is now *busretry.Answered, which Undelivered
never counts: only the transport's own no responders makes a call undelivered. The other busretry sites read or
write through JetStream's API (State, Ask) or the runtime's state verbs, where the words are the transport's.
2026-10-30 18:41:37 +01:00
jschoubben 206f794eb3 Grant a bar's holder the state a contributor's item shows, at its own machine's key (issue 774)
i3status-rust on g14 and shanks was issued nothing to read ("was issued: none", 148 times): ReadsGranted derived a
holder's reads from contributions' data alone, and design 57 L3 moved power's draw block to an item
(mesh-catalog ca8d6f33: shows: state, state: power.draw). ReadsGranted now reads a module's items of a kind the
holder's seat receives too, their attributes in a contribution's data shape, always at the machine's own key
(ADR 0260), never another's. Both callers, the controller's busrecords and mesh-grants' records.go, get it
through ReadsGranted with no change of their own.
2026-10-30 18:35:54 +01:00
jschoubben ef1ec1c935 Name mesh-config among the controller's lease readers (issue 774)
mesh-config reads mesh-controller.lease to take the config over only once the lease is handed away (ADR 0561 §3,
its takeover.go leaseAway), and was refused it as a reader the composer does not name.
2026-10-30 18:26:23 +01:00
jschoubben 942f906222 Judge only the verbs of a mesh seat a module invokes, not its holder (issue 774)
The seat-invoke rule refused mesh-config, mesh-healers and messenger calling the controller's verbs, and
mesh-self-check calling the watchman's seat, over a catalogue that holds no manifest of their holders: the
controller answers its seat itself, and the watchman lives in mesh-delivery. mesh-control's
TestModuleCheckPassesTheCatalogue (the captured mesh-catalog) failed on it. A mesh seat is held by the mesh or by a
module of any repository, so one repository's catalogue cannot say it has no holder: for a mesh seat only the verb
is judged; a seat a module declares is still judged for a claimant. Over the captured catalogue, the live
mesh-catalog alone, and mesh-catalog, mesh-control, mesh-delivery and mesh-host together: no refusal.
2026-10-30 18:25:59 +01:00
jschoubben 61b67d61e2 Export the accounts block's user entry, so a kept user is written in ComposeAccounts' own lines (issue 774)
mesh-grants' refused.go keptEntry wrote a user of the accounts block from grants it keeps by copying
ComposeAccounts' lines; a change to one would leave the other writing a list the bus reads differently.
buscompose.AccountsEntry(user, passwordHash, perms) is now the one writer, and ComposeAccounts uses it. No change
to what is written.
2026-10-30 18:19:20 +01:00
jschoubben 44dbb22ea7 Wait out a bus that answers nothing for a few seconds instead of losing the write (issue 774)
At each mesh-grants restart and user-list reload (16:37:24, 16:55:15) nats-server 2.11's reloadAuthorization left
the mesh's account without its imports, $JS.API.> among them, for a few seconds: requests had no responders,
JetStream calls said it was not enabled, and a build-test result on ace was lost.

Quick fix, chosen as asked; the thorough fix is a reload that keeps the account's imports, in the bus's holder.
The package busretry asks a request again, backing off within about 30 s, only while its error says it reached no
one (no responders, JetStream not enabled, no response from the stream, or either in words): nothing was done, so
even a create or an update is safe to ask again. A timeout or any answer is never asked again. After the bound it
is said on standard error and returned. Used by process.State's requests, process.Conn's calls (asked again only
while $JS.API.INFO says the bus itself answers nothing, so a callee that is not running still fails at once) and
asks, and the bundle's state requests through the runtime.
2026-10-30 18:18:32 +01:00
jschoubben 1dc5d8cb3d Run a watch's handler beside the runtime, and give a refused watch up loudly (issue 774)
The provisioner's watches of gitea.bound, mesh-vault.bound and postgres.bound failed every minute for an hour on
the control-node ("the current values ... did not arrive in 10s") and 1257 changes were refused as "names no
watch of this bundle". Both sides were at fault. The SDK answered a change only once its handler returned, so a
slow handler (Rejudge waits on the lock its composition holds) counted against the runtime's 10 s bound on the
current values; and when the runtime gave up, the SDK forgot the id at once. The runtime (mesh-tools node-tools
internal/bus/state.go StateWatch) stops its watcher on that bound but still drains and delivers the values already
buffered, to an id nobody holds.

Quick fix: a longer runtime bound. Thorough fix here: each state and provision watch has a follower that takes a
change in order and answers at once, its handler on its own goroutine; Watch still returns only once the current
values were handled, and a handler's error is said on standard error. A watch the runtime refuses is said given up
and kept by id, so a change still handed to it is refused saying so. KeptState.Follow asks again with backoff until
taken, the loop four modules wrote for themselves. A log watch keeps its answer after the handler: that is its
back-pressure.
2026-10-30 18:15:51 +01:00
jschoubben 7115b7bf64 Refuse a catalogue where a module invokes a seat verb nothing answers (issue 774)
messenger invoked seat:node-engine.root-free while nothing answered it, and Telegram held every ask that offers an
approval, silently. Nothing judged a seat:<seat>.<verb> invoke beyond its spelling: not that the seat exists, that
it promises the verb, nor that anything holds it.

Quick fix: messenger asks the controller's root-free (mesh-catalog bc271896) and the engine announces its seat as
discovery reads it (mesh-host 7ecf7ba). Thorough fix, chosen because an invoke nothing answers fails only at the
call, silently for whoever waits on it: CatalogueProblems refuses every seat verb a module or one of its processes
invokes whose seat no module declares and the mesh does not define, whose seat does not promise the verb, or whose
seat no module claims (a seat every node's engine holds, ADR 0563 §1, is held). Over some manifests alone a seat
whose holder was not given is a note (IsUndeclaredSeat). Over every manifest of mesh-catalog, mesh-control and
mesh-delivery today it refuses nothing.
2026-10-30 18:11:19 +01:00
jschoubben e703e9b0e2 Judge every read for its reader where the bus's users are composed (issue 774)
Since 13:25 the vault, the assigner, the watchman, the grants part and the artifact store's tools were refused
mesh-controller.lease, and the vault mesh-grants.accounts, though the composer names them those states' readers
(ADR 0547 clause 9): the controller splits reads with its own copy of the reader-less ReadsAnnounced, which knows
no named readers, and Users only moved reads from granted to refused, never back.

Quick fix: the controller calls ReadsAnnouncedBy with the module. Thorough fix, chosen because the kind was met
on four owners and the judgement had two copies: Users judges every read again for its reader against the
announcements in Records (withAnnouncedReads), whatever split the caller made, and the reader-less
ReadsAnnounced is removed so no caller can judge without the reader. A rule over manifests was not added:
ADR 0495 clauses 1 and 5 keep readability out of the manifest and replaced the merge gate's reads check by each
module's announcement test. gitea.bound and disk-load.readings stay refused: their owners announce them not
readable, which their own code fixes.
2026-10-30 18:08:02 +01:00
jschoubben 7b02f63daf Refuse a key-value write too large for its bucket before it is put (issue 774)
mesh-grants' sealed grants and the controller's merge record each met "message size exceeds maximum" (JetStream
10054) only after the put, and a writer that retries looped on a value that can never land; an update over the
limit was even answered as a revision mismatch, which every compare-and-set writer retries.

Quick fix: cap the two values in their modules. Thorough fix, chosen because the kind was met twice and every
module writes through the SDK: the package kvlimit checks a value against its bucket's largest message (read from
its stream and kept) before the put, naming the bucket, the key, the size and the limit, and turns the bus's own
refusal into the same *TooLarge (errors.Is kvlimit.ErrTooLarge), so no writer retries it. The SDK's own writers
use it: process.State (Put, Create, CreateTTL, Update), process.AwaitedBucket, the runtime state of a bundle
(stdio KeptState, against the module state's 256 KiB) and mesh-hand-compose. A value that can outgrow its bucket
is split over keys or kept in the object store with its name in the key.
2026-10-30 18:02:18 +01:00
jschoubben 59311407e2 One bad module must not drop its node: plans are composed module by module, a foreseen own secret fills a file, a built bus manifest keeps its bundle (issue 774)
PlansHeld holds a module whose build cannot be taken in or whose plan refuses, by composeEach's rule, and composes
every other module's plan; sealedFor gives a file the stand-in its own secret's file has (baserow on ace); shipsABundle
reads a built manifest's Bundles (nats on novox).
2026-10-30 17:50:56 +01:00
jschoubben 1d9de5dbb6 Merge pull request 'Grant the provisioner client every machine's plans, as it watches them (issue 774)' (#137) from fix/provisioner-reads-every-plan into main
Reviewed-on: #137
2026-10-30 15:53:30 +00:00
jschoubben af66ccb833 Grant the provisioner client every machine's plans, as it watches them, so g14 and shanks read their plans again (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@1d9de5dbb63b
mesh/delivery-group group fix/provisioner-reads-every-plan merged: every member is merged
mesh/check being carried to af66ccb8335a on 1d9de5dbb63b | from 42988a734b3d | novox/mesh-sdk#137 in group fix/provisioner-reads-every-plan
The client V3 watches the seat's plans whole to bind a provider's consumers (ADR 0373); the grant gave each machine
only its own keys, so its watch was refused and no files were placed. Quick fix, chosen in a recovery: plans join
the buckets a client reads whole (references, never secret values). Thorough fix, queued: the client fills consumers
from the seat's provision keys (ADR 0570 clause 3) and plans go back to its own machine's keys.
2026-10-30 16:40:46 +01:00
jschoubben 42988a734b Merge pull request 'The tool runner never carries the node-engine's state, so the bus user list composes again (issue 774)' (#136) from fix/runner-carries-no-engine-state into main
Reviewed-on: #136
2026-10-30 15:35:51 +00:00
jschoubben 6e78203959 Compose no placed user from the node-engine's manifest, so the tool runner never becomes a second writer of the engine's state (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@42988a734b3d
mesh/delivery-group group fix/runner-carries-no-engine-state merged: every member is merged
mesh/check checking 6e7820395952 on 42988a734b3d | novox/mesh-sdk#136 in group fix/runner-carries-no-engine-state
Second meeting of the engine-state-in-runner kind (mesh-host_acts this
morning, mesh-host_health now); thorough: the runner never carries the
engine module. One predicate, Composed, keeps the engine out of the
placed loop (no user, no process, nothing carried); CarriedByRunner is
the one test of what the runner carries. The engine's grants stay the
node account's, its state created through Records.Engine.
2026-10-30 16:30:27 +01:00
jschoubben d0625eea3d Merge pull request 'One list of credential forms, so no part keeps its own copy (issue 774)' (#135) from fix/credential-forms-one-list into main
Reviewed-on: #135
2026-10-30 15:10:19 +00:00
jschoubben 9379e9a96a Keep one list of credential forms in the catalogue, so no reader of a plan refuses a form another writes (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d0625eea3d11
mesh/delivery-group group fix/credential-forms-one-list merged: every member is merged
mesh/check checking 9379e9a96ad4 on d0625eea3d11 | novox/mesh-sdk#135 in group fix/credential-forms-one-list
Second meeting of the credential-form list kind: the provisioner client's
own copy lacked "none" and refused every plan naming it, as the
controller's had before. Thorough: one list in mesh-sdk,
catalogue.CredentialForms and IsCredentialForm; provisions' forms are now
the catalogue's constants.
2026-10-30 16:03:14 +01:00
jschoubben 0c99e847a2 Merge pull request 'One codec for the grants part's memberships state, so its writer and readers cannot drift (issue 774)' (#134) from fix/memberships-shape into main
Reviewed-on: #134
2026-10-30 14:42:04 +00:00
jschoubben 83cf97b690 Refuse an old wrapper of a held-back membership, so a reader falls back to the assignments stream
mesh/delivery merged: source.main moved to novox/mesh-sdk@0c99e847a2ae
mesh/delivery-group group fix/memberships-shape merged: every member is merged
mesh/check checking 83cf97b690f5 on 0c99e847a2ae | novox/mesh-sdk#134 in group fix/memberships-shape
The grants part's old build wrote its wrapper for every membership, a held-back one too, with the unpublished
composition inside; the new part never rewrites such a key, so the decoder took the held-back membership as given.
It now reads a wrapper only when its published says published or unchanged. The codec's rows join the round-trip
test (mesh-sdk is over its unit budget). Review round 1 of issue 774's fix.
2026-10-30 15:36:23 +01:00
jschoubben 46d4b4da5e One codec for the grants part's memberships, so its writer and the tool runner cannot drift
The grants part wrote its own wrapper into mesh-grants_memberships while the tool runner read the value as a bare
membership: novox's tool runner served no tool. EncodeGrantsMembership writes the bare membership;
DecodeGrantsMembership and BareGrantsMembership read it, still read the old wrapper for the transition, and refuse a
value naming no node or module instead of answering an empty membership.

Issue 774 (the bus login list, L3). Quick fix: write the bare membership in the grants part alone. Thorough, chosen:
one codec in mesh-sdk both sides use, because a hand-built test fixture agreed with the mistake and hid it.
2026-10-30 15:23:17 +01:00
mesh-tools 2b1e148bf5 Merge pull request 'Design 47's last milestones as one release: the contracts for health, control records, provisioning and history leaving the controller (issues 774, 1126, 1952, 1953, 1954, 1955, 1289)' (#133) from feat/774-l3-reland into main 2026-10-30 11:01:40 +00:00
jschoubben 2da0de75c3 Merge main's parse-only new words into the release, which supersedes them with its full implementation (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@2b1e148bf58e
mesh/delivery-group group feat/774-l3-reland merged: every member is merged
mesh/check being carried to 2da0de75c3f0 on 044ead7db83e | from mesh-catalog 12c2574c9d54 | novox/mesh-sdk#133 in group feat/774-l3-reland
2026-10-30 01:11:16 +01:00
jschoubben 6e4a3014cb Keep the provisioner client's needs grant for one release, removed after the lease write, so the client before V3 still writes its needs while its delivery reaches each node (issue 1954) 2026-10-30 00:14:53 +01:00
jschoubben 007b6fd45f Drop stdio.Discover, the self-check's discovery and its read of the withheld hearings, which no probe uses now that the operator dropped D-root, D3 and D-heard (issue 1952) 2026-10-30 00:02:07 +01:00
jschoubben 8fe3467753 Let a provider not ready yet wait and keep serving what it served, so a restarted gitea no longer stops serving its consumers until its keys load (issue 1954) 2026-10-30 00:02:07 +01:00
jschoubben fa5a6aeff5 Let a bundle ask the runtime who answers a verb, and the self-check's own account ask the bus's discovery, so D-root and D3 find holders without the controller (issue 1955) 2026-10-29 23:58:21 +01:00
jschoubben 31e90aaa0f Keep when each data item was first seen and when and why it was retired in its reading, so the self-check's D13 judges a new or retired item from disk-load's state (issue 1955, ADR 0571 clause 3) 2026-10-29 23:56:06 +01:00
jschoubben 7bd0f6c2bd Name the artifact sweep's seat mesh-artifact-sweep for its scope, grant the provisioner client its machine's plans in place of the needs, and keep the grants part's withheld hearings in one layout, so the seat names follow ADR 0121 and the client and the self-check read the bus (issue 1955, ADR 0571 clause 2, ADR 0570 clause 2) 2026-10-29 23:53:51 +01:00
jschoubben f3677eb6f3 Carry the engine's verdict on each account declared never to become root, and its contract, in the node's health report, so the self-check's DA and D-root judge on the machine's own word (issue 1952, ADR 0266)
NodeHealth.Root: each such account's verdict (healthy, unhealthy with its way to root, unknown while the setuid search
runs), the module and resource declaring it. NodeHealth.Contract: the engine's statement contract, so a reader tells an
engine older than the judging. Test: a row in TestTheControlRecordsReadTheirWritersValues, read back byte-equal.
2026-10-29 23:48:08 +01:00
jschoubben 992ea0ab66 Let restic's bundle, not its night's process, write each item's last good night, since the night runs as the operator's account with no bus account (issue 1955, ADR 0436 §1-2) 2026-10-29 23:41:16 +01:00
jschoubben 85f5fd0b22 Fail the grants table test for any row whose user is not composed, so a row can never pass for a missing user (issue 1955) 2026-10-29 23:38:59 +01:00
jschoubben 9f0bb1cfa4 Move restic's last good night per item into the sdk's health layouts, so restic's night process writes it and the self-check reads one layout (issue 1955, ADR 0571 clause 3) 2026-10-29 23:37:37 +01:00
jschoubben 08fc70783a Read the bus through a module's declared states in frombus, with the runtime's adapter in fromruntime, so mesh-provisioner and disk-load compose from one reader instead of two copies (issue 1955) 2026-10-29 23:31:34 +01:00
jschoubben bb993dd606 Keep the builder's history as its own log that readers declare and follow, the memberships as the grants part's state, and refuse a module's own stream, so every module's data is in the bus's predefined forms and readable by its tools (issue 1955, ADR 0571 clauses 1, 4, 5, 7) 2026-10-29 23:28:21 +01:00
jschoubben 196c5b6af2 Compose a node's placed data items from the bus and move the readings layout into the sdk, so disk-load measures without a composed file and restic, mounts, nfs-server and the self-check read one layout (issue 1955, ADR 0571 clause 3) 2026-10-29 23:16:56 +01:00
jschoubben dc78524e38 Let the artifact store's seat holder read the controller's lease, so its sweep starts only once the lease names no controller (issue 1955, ADR 0571 clause 2) 2026-10-29 23:10:46 +01:00
jschoubben f9a496d3e1 Carry each build's repository, directory and commit in the builder's log, so the indexer, the outside-built registrations and the assigner read where a build came from there (issue 1955) 2026-10-29 23:09:47 +01:00
jschoubben 7fdbadc655 Add the seat artifact-store, the seat-holders state with its reader, and the builder's log layout, so the store's sweep, who holds which seat and build history leave the controller (issue 1955, ADR 0571 clauses 1, 2, 4) 2026-10-29 23:09:25 +01:00
jschoubben 6eae0765f4 Name the machines a failing network part points at, so the watchman holds it there as the controller does without any machine's address (issue 774)
NetworkPart.Targets: the hub's machine for "hub", the machine whose tunnel address is each resolver's; empty where the
part is the machine's own. Filled by the node-engine from its tunnel's peers. Test: a row in
TestTheControlRecordsReadTheirWritersValues, read back byte-equal.
2026-10-29 23:02:54 +01:00
jschoubben 94d0de5c19 Move the node's health report layout into the sdk and let each node write only its own key while the watchman reads them, so the six machine alerts leave the controller (issue 774) 2026-10-29 22:58:05 +01:00
jschoubben 58a83249c8 Give a provision the vault itself provides no credential beside it, since its offer is the value (issue 1954, ADR 0570 clause 6) 2026-10-29 22:53:15 +01:00
jschoubben 6b8c694381 Make every offer say its credential's form, pair or none where it names no own secret or issuer, and read no default, so the vault never mints a login for a provider that takes none (issue 1954, ADR 0570) 2026-10-29 22:47:32 +01:00
jschoubben dedd988fc9 Export a bus in memory and a mesh whose plan hands a module with a provision, so mesh-provisioner's whole test composes a real plan (issue 1954 H5) 2026-10-29 22:44:49 +01:00
jschoubben f701db0516 Key a provision's credential as the vault's own provision secret-<provision> and say what a provision depends on, so a provider waits for its credential and its own requirements (issue 1954, ADR 0570 clauses 3, 6) 2026-10-29 22:43:56 +01:00
jschoubben 161fba56ae Hold the provisioner's records to one writer in the credential table: only mesh-provisioner writes provisions and plans, readers only read, and it writes none of the grants part's states (issue 1954 L6-P5, ADR 0570 clauses 1, 8) 2026-10-29 22:42:21 +01:00
jschoubben 692dbf9a17 Bind every provider by mesh-provisioner's plans and provision keys instead of the controller's assignments, act once a provision's dependencies are provided, say the outcome in its entry, and retire a consumer only on the operator's yes (issue 1954 L6-P3, P4, ADR 0570 clauses 2, 3, 5) 2026-10-29 22:41:50 +01:00
jschoubben 66ff33dbd3 Name a provision's credential as the vault's own names it, read each node's provisions by its engine, and give mesh-config's held values their layout in the sdk, so the vault, the engine and the composition checks read one form (issue 1954, issue 1953 D7) 2026-10-29 22:39:29 +01:00
jschoubben 6e39ed1852 Refuse a want's rule past the eighth in the refusals table, now that the eighth rule exists (issue 1954 D1) 2026-10-29 22:37:41 +01:00
jschoubben 56514aa4a2 Add the assigner's eighth rule, every provision the build requires provided, so a want waits on its provisions as ADR 0570 §4 decides (issue 1954 D1) 2026-10-29 22:37:11 +01:00
jschoubben 4a967933b3 Lay out a provision as a key of mesh-provisioner holding a reference and never a value, and a plan as a key of its own, so the holder, the vault, the clients and the providers read one record (issue 1954 L6-P2, ADR 0570 clauses 1, 2) 2026-10-29 22:32:15 +01:00
jschoubben 233b6be5dd Compose each node's provisioning plans from the bus alone, in a Planning mode that reads no secret, so mesh-provisioner composes the plans the controller did (issue 1954 L6-P1, ADR 0570 clause 2) 2026-10-29 22:31:02 +01:00
jschoubben 4204afd240 Name mesh-config's compare, take-over and hand-back on its seat, so callers reach the config switch ADR 0565 decides by the seat (issue 1953) 2026-10-29 22:28:45 +01:00
jschoubben 2d2aa7ba9b Carry the catalogue's predefined list on the bridge's merged message, so the indexer keeps it without the controller's mirror (issue 1953 L5-D1, ADR 0565 §2) 2026-10-29 22:21:12 +01:00
jschoubben 2e5cc4f166 Let a module's log keep its entries for an age, so the hand-act log holds each act 400 days as ADR 0565 decides instead of refusing when full (issue 1953 S-age) 2026-10-29 22:17:50 +01:00
jschoubben bc6c8a6d24 Let a bundle write a key with its own time to live and hear its expiry, so pulses, settles and bounds expire on the bus through the runtime (issue 1952) 2026-10-29 22:14:42 +01:00
jschoubben 20cac82ab7 Give the control records their layouts, a person's policy and its one derivation, and the engine's own packages key, so the assigner, the indexer and the nodes keep them without the controller (issue 1953 L5-S2, S3, S4) 2026-10-29 22:14:01 +01:00
jschoubben fbd00c8943 Refuse a manifest that lists its claimed seat's event among its own emits, so a holder never says a seat event where no consumer hears it (issue 1952, second of a kind) 2026-10-29 22:10:44 +01:00
jschoubben 7df90445ad Add the seats mesh-wants and mesh-hand-acts, the engine's packages verb, the act layout and a recorder that falls back to the controller's log while the seat has no holder, so the control records have homes the lease does not hold (issue 1953 L5-S1, S2, S4) 2026-10-29 22:09:16 +01:00
jschoubben 32f5ce3712 Refuse a tools account secret under a parent the runtime cannot pass, so the rule covers the module's 0700 mesh place too (issue 1952 S5, ADR 0564 Limits 4) 2026-10-29 22:00:43 +01:00
jschoubben 8e3d7a4c3c Give a tools bundle's bus-account secret to the runtime's account and refuse it where that account cannot reach, so the bus tools can read their credential (issue 1952 S5, ADR 0564 Limits 4) 2026-10-29 21:59:11 +01:00
jschoubben b6ef01d6c6 Let the watchman hear the bus module's slow consumers and closed connections and read the provisioner reports, and never the bus's API audit, so S9 and S21 have their facts without the vault's hashes (issue 1952 S5) 2026-10-29 21:57:50 +01:00
jschoubben 7000bd5049 Let a tools bundle declare its own bus account and what it may remove, so the bus tools remove the controller's dead-letter consumer and the node-engine's acts on a declared account with no idle service (issue 1952 S5, ADR 0564 Limits 4) 2026-10-29 21:52:52 +01:00
jschoubben 69fcb6c1c1 Fit the watchman's grant to what L4 does: hear its watchdogs' words and read a source's age and message, never deliver again or drop, so it holds no more than its job (issue 1952 S5) 2026-10-29 21:44:53 +01:00
jschoubben 2a04bf0ac5 Let the watchman read and delete a given-up message only on the streams dead letters come from, each by name and never a bucket's, so keeping dead letters cannot read every bucket (issue 1952 S5) 2026-10-29 21:43:15 +01:00
jschoubben 0b12b62a01 A failure among the change's own tests ends the check ask's level there, so the rest of the suite is not paid for (issue 1289)
Quick fix chosen (in askrun.go, the one place every build module's check ask runs): own-test packages already ran first, but the rest ran after their failure (issue 1921). 1921 still holds past them.
2026-10-29 21:41:19 +01:00
jschoubben 5a47192b57 Grant the health holders what ADR 0564 gives them and nothing more: named reads of the lease and the controller's conditions and hand acts, the watchman's own dead-letter consumer, and the bus tools' removal account (issue 1952 S5) 2026-10-29 21:40:49 +01:00
jschoubben 83d928fe50 Refuse mesh-watcher and mesh-self-check on one node, so the watcher of the self-check never falls silent with it (issue 1952 S8, ADR 0564 clause 6, Limits 5) 2026-10-29 21:37:59 +01:00
jschoubben 544720b00a Read whether a module is being delivered on a node in one place, so the view and the condition keeper judge a delivery alike (issue 1952 S7, ADR 0564 clause 13) 2026-10-29 21:37:59 +01:00
jschoubben 575a05e49e Let a state create a key with its own time to live, so a pulse, a settle or a hold bound expires on the bus with no timer (issue 1952 S6, ADR 0564 clauses 4, 7, 13) 2026-10-29 21:36:11 +01:00
jschoubben 4fff214eeb Give the condition keeper the controller's condition bodies, the findings layout and the take-over key, so readers change only the seat they hear (issue 1952 S2-S4, ADR 0564 clauses 2, 3, 7-9) 2026-10-29 21:35:16 +01:00
jschoubben 187fb63439 Add the four health seats, mesh-conditions, mesh-watchdogs, mesh-self-check and mesh-healers, so the controller's health has homes its lease does not hold (issue 1952 S1, ADR 0564 clause 1) 2026-10-29 21:35:16 +01:00
jschoubben 22c9ddd32b A runtime that already dropped a carried module's tools serves them again, read from what runs and its build (issue 1126, review)
rev-held2: ToolsRunning took a carried module's bundles from the runtime's MESH_TOOL_MODULES alone, and shanks had
already applied the drop of systemd-resolved, so its runs stayed empty and the drop permanent. Now every bundle
archive that runs at its module's BundlePath is a running bundle; one the runtime loads nothing of takes its loads and
words from the module's build (its built bundles, else its build's artifacts, by catalogue.BundleLoads, the rule a
build's resolution keeps, now one function). The runtime process and each archive are matched by their owning module
too, not only by id and path. A row added: the runtime already dropped it.
2026-10-29 21:03:16 +01:00
jschoubben 94cee5d199 A data flag that differs alone moves no data: the judgement reads the data items first, and says the flag loudly (issue 1126)
novox's mongodb and mssql asked a yes for moving their data on a toolchain move: the running side, read back from the
kept declaration, carries no data flag, and judge.go returned moves-data on the flag before it compared the data
directories and files, so a flag mismatch both raised a false ask and hid a real move. Now moves-data only when a
data item (a directory or file within the data places, or a process of a module with data) changes; a flag-only
difference is neither, with a WARNING in its words. One row changed and one added in TestJudgeMove.
2026-10-29 21:03:16 +01:00
jschoubben 52056010fe A module composed as what runs of it serves its tools as they run, so the runtime no longer drops it (issue 1126)
After the take-over of placement on 2026-10-29, shanks's runtime stopped serving the folded systemd-resolved (the
node-resolver seat's verbs answer "nothing serves") and novox's the folded nats. Neither has a want: each runs inside
its node's own composition at a build of the old form (Scope.InOwn), and is composed as Carried of the indexer's
manifest. That manifest is the catalogue's, which no build resolved, so it has no bundles (catalogue/build.go: Bundles
are set only by a build's resolution), and the runtime's MESH_TOOL_MODULES and MESH_TOOL_ENV are made from bundles
(catalogue/runtime.go). The controller composed them from the resolved manifest its store kept.

The kind: a module composed as what runs of it (Stub, Carried: held, or folded at an old build) contributes to the
others from its manifest, not from what runs. For a held module with a new build it is the other way round: its new
bundles are served, which were never delivered.

- The quick fix: read the old-form build's resolved manifest for a folded module. One instance; a held module still
  hands its new bundles.
- The thorough fix, chosen: compose.ToolsRunning reads what the node's runtime serves as it runs (its process's
  MESH_TOOL_MODULES and MESH_TOOL_ENV, each bundle's archive), frombus gives it as Node.RunsTools, and every stubbed
  module of the machine's runtime (held or carried) contributes those bundles, whatever its manifest says.

Tests: two rows (folded, held) added to TestCompositionsAreTheDeclarationSplitByModule; no new test function.
2026-10-29 21:03:16 +01:00
157 changed files with 10611 additions and 1348 deletions
+30 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"path"
"slices"
"sort"
@@ -37,24 +38,35 @@ type AskSteps struct {
// Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
Has func(need string) bool
// Narrate says an event on the module's own stream (stdio.Emit) and Module is the module's name: a failed test's whole
// output is said on the module's log stream while the ask runs, and the step's result names where (narrate). Nil
// says nothing, and the whole output is dropped from the result all the same.
Narrate func(event string, body any) error
Module string
}
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
if ask.Build != nil {
return steps.build(ctx, *ask.Build)
job := *ask.Build
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.build(ctx, job)
}
if ask.Check != nil {
return steps.check(ctx, *ask.Check)
job := *ask.Check
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.check(ctx, job)
}
return nil, Refuse("the ask %s holds no job", ask.ID)
}
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
// fetches nothing, is left as it is.
func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any {
if len(packages) == 0 || fetch == nil {
return fetch
}
out := maps.Clone(fetch)
out["packages"] = maps.Clone(packages)
return out
}
func raw(v any) (json.RawMessage, error) {
if r, ok := v.(json.RawMessage); ok {
return r, nil
@@ -226,7 +238,6 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
if err != nil {
return false, err
}
r = s.stepLog(ctx).narrate(r, s.Logf)
la.Tests = append(la.Tests, r)
var t struct {
Passed bool `json:"passed"`
@@ -234,8 +245,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
_ = json.Unmarshal(r, &t)
return t.Passed, nil
}
// Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
// level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
// The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level
// there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every
// test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of
// the level and not only the first (issue 1921); the level passes only when each of its steps did.
all := true
if s.Digest == nil {
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
@@ -250,6 +263,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
}
all = all && passed
}
if !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
}
return all, nil
}
@@ -285,7 +301,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
if err != nil {
return false, fmt.Errorf("the %s level: %w", level, err)
}
all = all && passed // the packages left still run (issue 1921)
all = all && passed // past the change's own packages the others still run (issue 1921)
if priority && !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
}
return all, nil
}
+64 -99
View File
@@ -5,12 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -76,15 +74,21 @@ func TestRunAskBuilds(t *testing.T) {
steps := r.steps()
var released []string
steps.Release = func(ws string) error { released = append(released, ws); return nil }
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
// Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were.
a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
out, err := RunAsk(context.Background(), steps, a)
if err != nil {
t.Fatal(err)
}
if _, put := a.Build.Fetch["packages"]; put {
t.Error("the ask's fetch arguments were changed")
}
got := strings.Join(r.calls, "\n")
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"}
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if got != want {
@@ -93,9 +97,9 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if len(released) != 1 || released[0] != "/w/1" {
t.Errorf("released %v, want the one workspace", released)
}
a := out.(delivery.BuildAnswer)
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
t.Fatalf("answer %+v", a)
answer := out.(delivery.BuildAnswer)
if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" {
t.Fatalf("answer %+v", answer)
}
}
@@ -115,7 +119,7 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
}
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921), and runs no
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no
// level after one that did not pass (ADR 0484 clause 13).
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
r := &recorder{
@@ -131,8 +135,7 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
got := strings.Join(r.calls, "\n")
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
digest {"level":"unit","workspace":"/w"}
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
}
@@ -141,7 +144,7 @@ test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
}
u := a.Levels[0]
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
t.Fatalf("the unit level %+v", u)
}
@@ -256,102 +259,64 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
}
}
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
// check runs every module directory after one fails too.
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
}
for i, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
t.Errorf("result %d %s, want a failure", i, raw)
// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the
// change touched are two test results of the one level. A Node check does the same with its module directories.
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) {
for _, row := range []struct {
name string
changed []string
tests map[string]bool
want []string // the packages tested, in order
}{
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
{"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}},
{"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}},
} {
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests,
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}}
if _, ok := row.tests["x/pkg/c"]; ok {
r.digests["x/pkg/c"] = "d-c"
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
var got []string
for _, c := range r.calls[1:] {
if strings.HasPrefix(c, "test ") {
var a struct{ Packages []string }
_ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a)
got = append(got, strings.Join(a.Packages, " "))
}
}
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
}
a := out.(delivery.CheckAnswer)
failed := false
for _, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
_ = json.Unmarshal(raw, &res)
failed = failed || !res.Passed
}
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
}
}
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": false}}
s := r.steps()
s.Digest = nil
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
}
// The operator, 2026-10-30: a failure's whole output is said on the module's log stream under the run and the step,
// the result names where and holds no whole output, and the log reads back whole, in pages, its lines in order.
var lines []string
for i := range 3000 {
lines = append(lines, fmt.Sprintf("line %04d %s", i, strings.Repeat("x", 40)))
}
var said []stdio.StreamEntry
s = r.steps()
s.Digest, s.Module = nil, "mesh-build-node"
s.Narrate = func(event string, body any) error {
b, _ := json.Marshal(body)
said = append(said, stdio.StreamEntry{Sequence: uint64(len(said) + 1), Event: event, Body: b})
return nil
}
s.Test = func(ctx context.Context, args map[string]any) (any, error) {
return map[string]any{"passed": false, "failed": []Failure{{Package: "b", Test: "t", Cause: "boom", Output: "boom", Full: strings.Join(lines, "\n")}}}, nil
}
r.fetch["modules"] = []string{"b"}
ctx := context.WithValue(context.Background(), phaseKey{}, &phases{step: delivery.Step{ID: "s1", Node: "ace"}, run: "r1"})
out, err = RunAsk(ctx, s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
var res struct{ Failed []Failure }
_ = json.Unmarshal(out.(delivery.CheckAnswer).Levels[0].Tests[0], &res)
if f := res.Failed[0]; f.Full != "" || f.Step != "s1" || f.Module != "mesh-build-node" || f.Machine != "ace" || f.Run != "r1" || len(said) < 2 || said[0].Event != "log.r1.s1" {
t.Fatalf("failure %+v, %d events (first %+v): want its address, no whole output, and its log in several events", f, len(said), said[0].Event)
}
read := func(events string, from uint64, limit int) (*stdio.StreamPage, error) {
p := &stdio.StreamPage{Last: uint64(len(said))}
for _, e := range said {
if e.Sequence >= max(from, 1) && e.Event == events && len(p.Entries) < limit {
p.Entries = append(p.Entries, e)
}
}
if n := len(p.Entries); n > 0 && p.Entries[n-1].Sequence < p.Last {
p.Next = p.Entries[n-1].Sequence + 1
}
return p, nil
}
var got []string
pages := 0
for from := uint64(0); ; pages++ {
page, err := ReadStepLog(read, "r1", "s1", from)
if err != nil || page.Gap || page.Bytes > LogPageBytes {
t.Fatalf("page from %d: %+v, %v", from, page.Next, err)
}
got = append(got, page.Lines...)
if from = page.Next; from == 0 {
break
}
}
// Each page opens with the failure it reads: "=== <package> <test>".
own := slices.DeleteFunc(slices.Clone(got), func(l string) bool { return l == "=== b t" })
if pages < 2 || got[0] != "=== b t" || strings.Join(own, "\n") != strings.Join(lines, "\n") {
t.Errorf("%d pages, %d lines read back, want %d in several pages, headed by the failure", pages+1, len(own), len(lines))
}
if _, err := ReadStepLog(read, "r1", "s1.>", 0); err == nil {
t.Error("a step id that is no token was read")
if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 {
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels)
}
}
+1 -24
View File
@@ -29,16 +29,6 @@ type Failure struct {
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
// only its first line.
Output string `json:"output,omitempty"`
// Full is the failing test's whole output, its last FullBytes, passing lines kept: it crosses from a shut-off level
// process to the step, which says it on the build module's own log stream and empties it, so it never reaches the
// step's result (narrate).
Full string `json:"full_output,omitempty"`
// Step, Module, Machine and Run say which build step said the whole output on its log, of which build module on which
// machine, under which run (delivery.StepLogEvent); set when it was said, absent when it was not.
Step string `json:"step,omitempty"`
Module string `json:"module,omitempty"`
Machine string `json:"machine,omitempty"`
Run string `json:"run,omitempty"`
}
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
@@ -119,19 +109,6 @@ func Clip(lines []string, cause string) string {
return strings.Join(kept, "\n")
}
// FullOf joins a failure's whole output, its last FullBytes when longer, starting on a whole line.
func FullOf(lines []string) string {
s := strings.Join(lines, "\n")
if len(s) <= FullBytes {
return s
}
tail := s[len(s)-FullBytes:]
if i := strings.IndexByte(tail, '\n'); i >= 0 {
tail = tail[i+1:]
}
return fmt.Sprintf("... (%d earlier bytes)\n", len(s)-len(tail)) + strings.ToValidUTF8(tail, "")
}
// CutLine cuts a line to LineBytes, on a character boundary.
func CutLine(l string) string {
if len(l) <= LineBytes {
@@ -166,5 +143,5 @@ func ConsoleFailure(pkg, test, out string) Failure {
cause = test + " failed and wrote nothing that says why"
}
cause = CutLine(cause)
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause), Full: FullOf(lines)}
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
}
+3 -3
View File
@@ -211,7 +211,7 @@ func (r *goReader) subtestTakesTheCause(pkg, test string) bool {
took := false
for i, f := range r.res.Failed {
if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") && f.Cause == noMessage {
r.res.Failed[i].Cause, r.res.Failed[i].Output, r.res.Failed[i].Full = cause, Clip(r.testOut[key], cause), FullOf(r.testOut[key])
r.res.Failed[i].Cause, r.res.Failed[i].Output = cause, Clip(r.testOut[key], cause)
took = true
}
}
@@ -231,7 +231,7 @@ func (r *goReader) testFailure(pkg, test string) Failure {
if cause == "" {
cause = noMessage
}
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause, Full: FullOf(r.testOut[key])}
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause}
}
// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone,
@@ -380,7 +380,7 @@ func (r *goReader) packageFailure(name string) Failure {
if cause == "" {
cause = "the package failed with no test to name and no panic, signal or build error in its output"
}
return Failure{Package: name, Test: goTestNames(running), Output: Clip(lines, cause), Cause: cause, Full: FullOf(lines)}
return Failure{Package: name, Test: goTestNames(running), Output: Clip(lines, cause), Cause: cause}
}
var goFileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
-3
View File
@@ -34,9 +34,6 @@ type phases struct {
ended bool
logf func(format string, args ...any)
prefix string
// run is the ask's run (delivery.StepAsk.Run), and log the step's log (steplog.go), made at the first line said.
run string
log *stepLog
}
func (r *phases) enter(phase string) {
+1 -1
View File
@@ -619,7 +619,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
}
}
}()
ph := &phases{put: p.Put, key: key, step: step, run: ask.Run, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
ph := &phases{put: p.Put, key: key, step: step, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
result, err := protect(ctx, func(ctx context.Context) (any, error) {
return p.Run(context.WithValue(ctx, phaseKey{}, ph), ask)
})
+48
View File
@@ -2,9 +2,12 @@ package buildstep
import (
"fmt"
"maps"
"path"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
var (
@@ -27,6 +30,10 @@ type FetchRequest struct {
Base string `json:"base,omitempty"`
Dir string `json:"dir,omitempty"`
Paths []string `json:"paths,omitempty"`
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
// the repository names, checksum-verified, and names them in its log.
Packages delivery.PackageVersions `json:"packages,omitempty"`
}
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
@@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any {
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
}
}
@@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any {
if len(r.Paths) > 0 {
args["paths"] = append([]string(nil), r.Paths...)
}
if len(r.Packages) > 0 {
args["packages"] = map[string]any{}
for p, v := range r.Packages {
args["packages"].(map[string]any)[p] = v
}
}
return args
}
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
default:
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
}
packages, err := PackagesArg(args["packages"])
if err != nil {
return FetchRequest{}, err
}
r.Packages = packages
return r, r.Check()
}
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
// nil for none.
func PackagesArg(v any) (delivery.PackageVersions, error) {
out := delivery.PackageVersions{}
switch v := v.(type) {
case nil:
return nil, nil
case delivery.PackageVersions:
maps.Copy(out, v)
case map[string]string:
maps.Copy(out, v)
case map[string]any:
for p, version := range v {
s, ok := version.(string)
if !ok {
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
out[p] = s
}
default:
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
if len(out) == 0 {
return nil, nil
}
return out, out.Check()
}
// Check is what ParseFetchRequest refuses.
func (r FetchRequest) Check() error {
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
+9 -2
View File
@@ -10,11 +10,15 @@ import (
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
// clause 1). The request survives the trip through a tool call's arguments.
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
Dir: ".", Paths: []string{"package.json", "src"}}
Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
args := req.Args()
// A tool call's arguments arrive as JSON: lists of any.
// A tool call's arguments arrive as JSON: lists of any, objects of any.
args["paths"] = []any{"package.json", "src"}
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
}
got, err := ParseFetchRequest(args)
if err != nil {
t.Fatal(err)
@@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
} {
args := map[string]any{}
for k, v := range ok {
+1 -1
View File
@@ -86,7 +86,7 @@ func finish(put Put, key string, step delivery.Step, result any, err error, logf
if merr != nil {
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
} else {
step.Result = (*stepLog)(nil).narrate(raw, nil) // a step run by hand keeps no whole output in its result
step.Result = raw
}
}
case errors.As(err, &ref):
-16
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"time"
@@ -87,21 +86,6 @@ func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
result string
}{
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
// A step run by hand says no log: a failure's whole output never reaches its result.
{"no whole output", func(context.Context) (any, error) {
return map[string]any{"failed": []map[string]string{{"package": "p", "full_output": "all of it"}}}, nil
}, delivery.StepPassed, "", `{"failed":[{"package":"p"}]}`},
// A log the bus does not take is tried once in a step, then left: the step passes, its failures name no step.
{"a dead log", func(context.Context) (any, error) {
tries := 0
l := &stepLog{say: func(string, any) error { tries++; return errors.New("timeout") }, id: "s1", run: "r1"}
raw := l.narrate(json.RawMessage(`{"failed":[{"package":"p","full_output":"a"},{"package":"q","full_output":"b"}]}`), nil)
raw = l.narrate(raw, nil)
if tries != 1 {
return nil, fmt.Errorf("%d publishes, want one", tries)
}
return raw, nil
}, delivery.StepPassed, "", `{"failed":[{"package":"p"},{"package":"q"}]}`},
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
{"refusal", func(context.Context) (any, error) {
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
-225
View File
@@ -1,225 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"fmt"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A build step's log on its build module's own stream (the operator, 2026-10-30: a verdict cut a failure to its last four
// kilobytes and named no place that held the rest, so a check's cause was guessed; novox/hq ADR 0157 for the shape: a
// build says what it does on the bus, under its id, in sequenced lines, read back from the stream). The step's result
// keeps each failure's Output clipped to OutputBytes, the summary; the failure's whole output, up to FullBytes, is said
// as delivery.StepLog events on delivery.StepLogEvent(run, step), at most LogEventBytes each, on the stream the module
// declares for `log.**` (max age and size in its manifest, dropping its oldest). ReadStepLog reads it back in pages.
const (
// FullBytes is the most of one failure's output that is said whole (its last bytes).
FullBytes = 1 << 20
// LogEventBytes is the most lines one log event holds, and LogPageBytes the most one page of a read answers.
LogEventBytes = 64 << 10
LogPageBytes = 64 << 10
)
// stepLog says one step's log: its node, id and run, the build module, how to say an event, and the next line's number.
type stepLog struct {
say func(event string, body any) error
module, node string
id, run string
seq int
// dead says a publish failed: the step says nothing more on its log, so a bus that does not take its lines costs the
// step at most one publish's timeout, and never fails it (the 2026-10-30 review).
dead bool
}
// stepLog is the log of the step whose ask runs under ctx, or nil when no ask runs (a tool called by hand) or the module
// says no log.
func (s AskSteps) stepLog(ctx context.Context) *stepLog {
r, ok := ctx.Value(phaseKey{}).(*phases)
if !ok || r == nil || s.Narrate == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if r.log == nil {
r.log = &stepLog{say: s.Narrate, module: s.Module, node: r.step.Node, id: r.step.ID, run: r.run}
}
return r.log
}
// narrate says every failure's Full of a step answer's "failed" on the step's log and empties it, setting the failure's
// Step, Module, Machine and Run; a failure whose lines could not all be said keeps no address, that is said once through
// logf, and the step says nothing more on its log (dead). With l nil, Full is only emptied: the step's result never holds it.
func (l *stepLog) narrate(raw json.RawMessage, logf func(string, ...any)) json.RawMessage {
if !strings.Contains(string(raw), `"full_output"`) {
return raw
}
var answer map[string]json.RawMessage
if err := json.Unmarshal(raw, &answer); err != nil || answer["failed"] == nil {
return raw
}
var failed []map[string]json.RawMessage
if err := json.Unmarshal(answer["failed"], &failed); err != nil {
return raw
}
for i, f := range failed {
var full string
_ = json.Unmarshal(f["full_output"], &full)
delete(failed[i], "full_output")
if full == "" || l == nil || l.dead {
continue
}
var name struct {
Package string `json:"package"`
Test string `json:"test"`
}
b, _ := json.Marshal(f)
_ = json.Unmarshal(b, &name)
if err := l.say1(name.Package, name.Test, strings.Split(full, "\n")); err != nil {
l.dead = true
if logf != nil {
logf("the step %s says no more on its log, from the whole output of %s %s on: %v", l.id, name.Package, name.Test, err)
}
continue
}
for field, v := range map[string]string{"step": l.id, "module": l.module, "machine": l.node, "run": l.run} {
failed[i][field], _ = json.Marshal(v)
}
}
answer["failed"], _ = json.Marshal(failed)
out, err := json.Marshal(answer)
if err != nil {
return raw
}
return out
}
// say1 says one failure's lines, in events of at most LogEventBytes, numbered on from the step's last line.
func (l *stepLog) say1(pkg, test string, lines []string) error {
event := delivery.StepLogEvent(l.run, l.id)
for len(lines) > 0 {
n, size := 0, 0
for n < len(lines) && (n == 0 || size+len(lines[n])+1 <= LogEventBytes) {
if len(lines[n]) > LogEventBytes {
lines[n] = strings.ToValidUTF8(lines[n][:LogEventBytes], "") // one line longer than an event is cut
}
size += len(lines[n]) + 1
n++
}
if err := l.say(event, delivery.StepLog{Seq: l.seq + 1, Package: pkg, Test: test, Lines: lines[:n]}); err != nil {
return err
}
l.seq += n
lines = lines[n:]
}
return nil
}
// StreamRead reads a module's own stream: stdio.Stream(name).Read's shape.
type StreamRead func(events string, from uint64, limit int) (*stdio.StreamPage, error)
// StepLogPage is one page of a step's log: the lines from the stream sequence From on, headed "=== <package> <test>"
// where a failure's lines start on the page, Next the sequence to read on from (0 when nothing follows yet; a step still running may
// say more), Last the stream's last sequence, and Gap when a line number was skipped (the stream dropped it with age).
type StepLogPage struct {
Run string `json:"run"`
Step string `json:"step"`
Event string `json:"event"`
From uint64 `json:"from"`
Lines []string `json:"lines"`
Bytes int `json:"bytes"`
Next uint64 `json:"next"`
Last uint64 `json:"last"`
Gap bool `json:"gap,omitempty"`
}
// ReadStepLog reads one page of about LogPageBytes of the log of the step id under run, from the stream sequence from on.
func ReadStepLog(read StreamRead, run, id string, from uint64) (StepLogPage, error) {
if !stepToken(run) || !stepToken(id) {
return StepLogPage{}, fmt.Errorf("a step's log is named by its run and its step id, each one token of letters, digits and -: %q, %q", run, id)
}
event := delivery.StepLogEvent(run, id)
page := StepLogPage{Run: run, Step: id, Event: event, From: from, Lines: []string{}}
seq, test := 0, "\x00"
for {
got, err := read(event, from, 16)
if err != nil {
return page, fmt.Errorf("the log %s cannot be read: %w", event, err)
}
page.Last = got.Last
for _, e := range got.Entries {
var l delivery.StepLog
if err := json.Unmarshal(e.Body, &l); err != nil {
return page, fmt.Errorf("the log %s holds at %d what is no log event: %v", event, e.Sequence, err)
}
size := 0
for _, line := range l.Lines {
size += len(line) + 1
}
if page.Bytes > 0 && page.Bytes+size > LogPageBytes {
page.Next = e.Sequence
return page, nil
}
if seq > 0 && l.Seq != seq {
page.Gap = true
}
if name := l.Package + " " + l.Test; name != test {
page.Lines = append(page.Lines, "=== "+strings.TrimSpace(name))
test = name
}
page.Lines = append(page.Lines, l.Lines...)
page.Bytes += size
seq = l.Seq + len(l.Lines)
}
if got.Next == 0 {
return page, nil
}
from = got.Next
}
}
// stepToken is one subject token of a run or a step id.
func stepToken(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
return false
}
}
return true
}
// LogStreamOf is the local name of a build module's own log stream, <language>-build-logs (a stream's bus name is
// mesh-wide): LogStreamOf names it.
func LogStreamOf(language string) string { return language + "-build-logs" }
// LogTool is a build module's tool that reads one step's log from its own stream, {run, step, from}: a failed test's
// whole output, which the step's result and the checker's verdict keep cut. The stream is the mesh's, so any machine's
// instance answers for a step of any machine.
func LogTool(name string, read StreamRead) stdio.Tool {
return stdio.Tool{Name: name,
Description: "The full build log of one build step: the whole output of every test that failed in it, which a check's " +
"verdict and the step's result keep cut to their last lines (\"... (N earlier lines)\"). One page of about 64 KB; " +
"read on with from = the answer's next until it is 0 (a step still running may say more: read again from next). " +
"run is the check (its heads hash) or the delivery plan the step ran for, \"none\" when the ask named none; step " +
"is the step id; both are in a failed verdict (checker_failure, checker_pull). Kept 14 days. Read-only.",
Input: map[string]any{"type": "object", "required": []string{"run", "step"}, "properties": map[string]any{
"run": map[string]any{"type": "string", "description": "the check or delivery plan the step ran for, or none"},
"step": map[string]any{"type": "string", "description": "the step id (the ask's id)"},
"from": map[string]any{"type": "integer", "description": "the stream sequence to read on from: the last answer's next; 0 is the start"}}},
Run: func(args map[string]any) (any, error) {
run, _ := args["run"].(string)
step, _ := args["step"].(string)
from, _ := args["from"].(float64)
if from < 0 {
return nil, fmt.Errorf("from is a stream sequence, 0 or more")
}
return ReadStepLog(read, run, step, uint64(from))
}}
}
+1 -1
View File
@@ -107,7 +107,7 @@ func ReadTAP(pkg, script, dir, report, console string) []Failure {
if dir != "" {
file = strings.TrimPrefix(file, strings.TrimSuffix(dir, "/")+"/")
}
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause), Full: FullOf(block)}
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause)}
if fields["failureType"] == "cancelledByParent" {
cancelled = append(cancelled, f)
continue
+5
View File
@@ -25,6 +25,8 @@ type ModuleTree struct {
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"`
// Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2).
Packages []string `json:"packages,omitempty"`
}
var lockFiles = []string{"go.sum", "package-lock.json"}
@@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
mt.Locks = map[string]string{}
}
mt.Locks[lock] = sha256Hex(raw)
if lock == "go.sum" {
mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2)
}
}
}
trees[mdir] = mt
+5 -3
View File
@@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
writeTreeFile(t, root, "module.json", `{"root":1}`)
writeTreeFile(t, root, "go.sum", "rootsum")
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
writeTreeFile(t, root, "modules/a/go.sum", "asum")
asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n"
writeTreeFile(t, root, "modules/a/go.sum", asum)
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
merge := strings.Repeat("c", 40)
@@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
t.Fatal(err)
}
want := map[string]ModuleTree{
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf(asum), "package-lock.json": hexOf("alock")},
Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
}
if !reflect.DeepEqual(got, want) {
+17 -12
View File
@@ -187,12 +187,17 @@ func HeldCredential(w WantedAccount, password string) ([]byte, error) {
Claims: w.Claims})
}
// **One issuer at every moment: minting turns on at the lease write** (novox/hq ADR 0561 §3, §6). The controller's lease
// is the one key `holder` of its bucket (LeaseBucket). The operator's credential hands it away by writing a value that
// names its new holder in `handed-to`; while the key holds that, the vault mints and the grants part creates the durable
// consumers an account needs. A controller's value, a key deleted or expired, or one that cannot be read or parsed, turn
// both off: when in doubt nothing is minted, so the controller and the vault never both mint one account. No clock and
// no setting turns it.
// **The lease write** (novox/hq ADR 0561 §3, §6). The controller's lease is the one key `holder` of its bucket
// (LeaseBucket). The operator's credential hands it away by writing a value that names its new holder in `handed-to`;
// while the key holds that, the grants part creates the durable consumers an account needs, the assigner reads the grants
// part's grants (delivery.GrantSource), and the vault retires and rotates accounts. A controller's value, a key deleted or
// expired, or one that cannot be read or parsed, turn those off. No clock and no setting turns it.
//
// **Minting is no longer on the lease** (issue 774, research 072's gap "vault mints after the lease"): the vault is the
// one issuer of every account the grants part wants, whoever holds the lease, and the controller mints none once a vault
// is installed (it forwards `module issue` to the vault's issue-account). One issuer at every moment holds by who mints,
// not by the lease: an account whose value the vault holds is never minted anew, and a value the controller hands or
// moves never replaces one the vault minted.
// ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it
// (NamedReaders), the controller's announcement marking it readable by nobody.
@@ -221,24 +226,24 @@ type LeaseHolder struct {
func LeaseHandedAway(raw []byte, found bool, readErr error) (bool, string) {
switch {
case readErr != nil:
return false, "the controller's lease cannot be read (" + readErr.Error() + "), so nothing is minted"
return false, "the controller's lease cannot be read (" + readErr.Error() + "), so it is not handed away"
case !found || len(raw) == 0 || string(raw) == "null":
return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so nothing is minted"
return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so it is not handed away"
}
var h LeaseHolder
if err := json.Unmarshal(raw, &h); err != nil {
return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so nothing is minted"
return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so it is not handed away"
}
if strings.TrimSpace(h.HandedTo) == "" {
who := h.Instance
if who == "" {
who = "an instance that names itself not"
}
return false, "the controller holds the lease (" + who + "): it mints, so the vault mints nothing (ADR 0561 §3)"
return false, "the controller holds the lease (" + who + "), so it is not handed away (ADR 0561 §3)"
}
if h.Instance != "" {
return false, "the lease names both a controller instance (" + h.Instance + ") and a holder it is handed to (" +
h.HandedTo + "), so nothing is minted"
h.HandedTo + "), so it is not handed away"
}
return true, "the lease is handed to " + h.HandedTo + ": the vault mints every new account (ADR 0561 §1, §3)"
return true, "the lease is handed to " + h.HandedTo + " (ADR 0561 §3)"
}
+6 -10
View File
@@ -42,16 +42,12 @@ func ControllerAnnouncement(node string) announce.States {
return s
}
// ReadsAnnounced splits a reader's `reads` (`<owner>.<name>`) by what each owner announced: granted when the owner
// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did
// not announce that state readable.
func ReadsAnnounced(reads []string, announced map[string]announce.States) (granted, refused []string) {
return ReadsAnnouncedBy("", reads, announced)
}
// ReadsAnnouncedBy is ReadsAnnounced for one reader module, which also honours the readers an owner named for a state
// (announce.State.Readers) and the composer's named readers (NamedReaders, ADR 0547 clause 9): a read named for other
// modules is refused. An empty reader is judged by the readable mark alone.
// ReadsAnnouncedBy splits one reader module's `reads` (`<owner>.<name>`) by what each owner announced: granted when the
// owner announced the state readable (by this reader, where it named its readers: announce.State.Readers), when the
// composer names the reader among the state's readers (NamedReaders, ADR 0547 clause 9), or when the owner announced
// nothing yet (the transition); refused otherwise, and a read named for other modules is refused. There is no judgement
// without the reader (issue 774): one made without it refused every named reader, and Users judges again for the
// reader whatever split it is given (withAnnouncedReads). An empty reader is judged by the readable mark alone.
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
for _, r := range reads {
owner, name, ok := strings.Cut(r, ".")
+414 -2
View File
@@ -13,7 +13,10 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/nats-io/nats.go/jetstream"
)
@@ -33,8 +36,17 @@ var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATE
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
// (issue 1790) is the other row beyond, matched by createdAdvisory.
//
// And the watchman's dead letters and watchdogs (novox/hq ADR 0564 clauses 4, 5, issue 1952 S5): its own consumer on
// DEAD_LETTER_NOTICES, a read of DEAD_LETTERS, and the words its watchdogs hear, the controller's jobs until the lease
// write.
func beyondTheControllers(user string) map[string]bool {
out := map[string]bool{}
if strings.HasSuffix(user, "."+WatchmanModule) {
for _, s := range append(WatchmanDeadLetterGrants(nil), WatchmanHears()...) {
out[s] = true
}
}
node, ok := strings.CutPrefix(user, "node.")
if !ok {
return out
@@ -48,6 +60,22 @@ func beyondTheControllers(user string) map[string]bool {
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
func mayBeyondTheControllers(user string) map[string]bool {
// A runtime's read of its membership from the grants part's state (novox/hq ADR 0571 clause 7): its own key, or the
// tool runner's node's.
if parts := strings.Split(user, "."); len(parts) == 2 && parts[0] != "node" {
key := user
if parts[1] == RuntimeModule {
key = parts[0] + ".*"
}
out := map[string]bool{}
if membership.GrantsBucket != GrantsMembershipsBucket || membership.GrantsKey("a", "b") != MembershipKey("a", "b") {
panic("the membership package's grants bucket does not agree with buscompose's") // FollowMembership reads it
}
for _, s := range readGrants(GrantsMembershipsBucket, key) {
out[s] = true
}
return out
}
if _, ok := strings.CutPrefix(user, "node."); !ok {
return nil
}
@@ -65,7 +93,15 @@ func engineBeyond(user, g string) bool {
}
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".")
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 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).
strings.Contains(g, "KV_"+provisions.ProvisionsBucket) ||
// And its node's placed modules' data items, its own node's keys (novox/hq ADR 0577 clause 1).
strings.Contains(g, "KV_"+EngineDataBucket) || strings.HasPrefix(g, "$KV."+EngineDataBucket+"."+node+".")
}
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
@@ -121,11 +157,16 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
}
intended := beyondTheControllers(user)
for g := range was {
if !now[g] {
// 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]+".") {
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
}
}
for g := range now {
if strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".") && !strings.HasSuffix(user, "."+GrantsModule) {
t.Errorf("%s: the copy grants %s, a read of the assignments stream, which goes at the lease write", user, g)
}
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) &&
!createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) {
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
@@ -212,6 +253,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil {
t.Fatal(err)
}
heldEventsBeyond(gotM, wasM)
if !reflect.DeepEqual(gotM, wasM) {
t.Errorf("the memberships differ from the controller's:\n%s", raw)
}
@@ -290,6 +332,21 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
!MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") {
t.Errorf("node one hears %v", perms.Subscribe)
}
// A verb or an event the seat's contract gains later is its engine's on its own node without a release of
// the grants part (2026-10-31: mesh-host #184's optional verb composition was refused on every node, the
// grants part's mesh-sdk pin predating it): the seat's subjects on its node, any verb, any event.
for _, verb := range []string{"composition", "compose", "a-verb-added-later"} {
if !MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".one") ||
!MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".one.messenger~one") ||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".two") ||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".two.messenger~one") {
t.Errorf("node one does not answer %s on its own node alone: %v", verb, perms.Subscribe)
}
}
if !MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.one") ||
MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.two") {
t.Errorf("node one does not say a later event on its own node alone: %v", perms.Publish)
}
case u.Kind == KindNodeTools || u.Module == "messenger":
if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") {
t.Errorf("%s cannot call one's root-free", u.Username())
@@ -522,6 +579,34 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Errorf("%s reads the lease: %v, want %v", reader, granted, want)
}
}
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: the
// controller's own reader-less copy refused the vault the lease and the grants part's accounts it is named for.
announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}}
announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}}
judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": {
{Module: VaultSeat, ReadsRefused: []string{ControllerLease, GrantsAccounts}},
{Module: "mesh-config", ReadsRefused: []string{ControllerLease}},
{Module: "restic", ReadsRefused: []string{"disk-load.readings"}},
{Module: "messenger", Reads: []string{ControllerLease, GrantsAccounts}}}}})
if err != nil {
t.Fatal(err)
}
reads := map[string]string{}
for _, u := range judged {
if u.Kind == KindModule {
reads[u.Module] = strings.Join(u.Reads, ",")
}
}
if reads[VaultSeat] != ControllerLease+","+GrantsAccounts || reads["mesh-config"] != ControllerLease || reads["restic"] != "" || reads["messenger"] != "" {
t.Errorf("the reads granted for their readers: %v", reads)
}
// AccountsEntry is the very lines ComposeAccounts writes for a user, so a writer of kept users repeats none.
filled, _ := WithPasswords(judged, map[string]string{judged[0].Username(): "$2a$11$h"})
list, err := ComposeAccounts(filled[:1])
perms, perr := PermissionsFor(filled[0])
if err != nil || perr != nil || !strings.Contains(list, " users = [\n"+AccountsEntry(filled[0].Username(), "$2a$11$h", perms)+" ]\n") {
t.Errorf("the accounts entry is not ComposeAccounts' own: %v %v\n%s", err, perr, list)
}
c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}})
cfg := ConsumerConfig(c)
if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second ||
@@ -548,6 +633,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
}
}
// 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"},
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
@@ -555,3 +642,328 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}
})
}
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
func healthChecks(t *testing.T) {
t.Helper()
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"),
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
for _, c := range []struct {
reader, read string
may bool
}{
{"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true},
{"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, {ArtifactStoreTools, ControllerLease, true},
{"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false},
{"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false},
// ADR 0565 clauses 10, 11 (issue 1953 L5-S4): mesh-hand-acts reads the controller's log beside its own.
{"mesh-hand-acts", ControllerHandActs, true},
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
// ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read.
{"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false},
} {
granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced)
if (len(granted) == 1) != c.may {
t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may)
}
}
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts", ControllerConditions}},
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}},
// Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them.
{Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}},
{Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}},
{Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}},
{Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}},
// ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider
// read them.
{Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"),
bucket("mesh-provisioner", "registry")}},
{Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
{Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
// The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3).
{Module: "mesh-provisioner-client", ProvisionsNode: true},
// ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders.
{Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}},
Reads: []string{delivery.SeatHoldersRead}},
{Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}},
// ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them.
// ADR 0577 clause 1: and it reads its own node's keys of the node-engine's data items, which restic may not.
{Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}, Reads: []string{delivery.EngineDataRead}},
{Module: "restic", Reads: []string{"disk-load.readings", delivery.EngineDataRead}, State: []Bucket{bucket("restic", "good")}},
// ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks
// the builder's tool.
{Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}},
{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}},
{Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}},
{Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}},
Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}},
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools",
Removes: []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs, catalogue.RemovesAssignmentsStream}}, {Process: "other"}}},
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
}}})
if err != nil {
t.Fatal(err)
}
may := map[string][]string{
"a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"},
"a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
"a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database",
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"},
"a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"},
"a.disk-load": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg",
"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.a.*"},
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak",
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak",
// Every machine's plans, as the client V3 watches them (issue 774, 2026-10-30).
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.>",
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak",
// The needs, for the client before V3, until the lease write (review round 3).
"$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"},
// ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it.
"a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"},
"a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"},
"a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data",
// ADR 0571 clause 7: its own membership from the grants part's state.
"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"},
"a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta",
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1",
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"},
"a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"},
"a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"},
WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule,
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"},
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER",
"$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"},
"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"},
}
mayNot := map[string][]string{
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
"a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"},
"a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data",
"$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools",
"$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"},
// Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570
// clauses 1, 8).
"a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"},
"a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak",
"mesh.seat.mesh-artifact-sweep.event.artifacts-gone"},
// Each writes its own state alone, and these users are composed.
"a.disk-load": {"$KV.restic_good.a.pg.data", "$KV.mesh-host_data.a.pg",
"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.b.pg", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.b.*"},
"a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"},
// It reads every machine's plans (a provider's consumers are bound there, ADR 0373) but writes none, nor another
// machine's needs, which it keeps for the client before V3 until the lease write.
"provisioner.a": {"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"},
// Only the assigner writes seat-holders.
"a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"},
// Only disk-load writes its readings.
"a.restic": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg"},
"a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
// Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10).
"a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch",
"$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"},
// The watchman never deletes or changes the controller's consumer (clause 5).
// Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's
// (the lead, 2026-10-29).
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x",
"$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER",
// In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29).
"$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x",
"mesh.again.a_m.x"},
WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule},
// The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts
// remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them
// 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"},
"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"},
}
// ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes.
if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}},
Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes ||
m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead {
t.Errorf("the indexer's membership issues the logs %+v", m.Logs)
}
// ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the
// assigner writes its policies.
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
continue
}
// ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none.
if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") ||
MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) {
t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish)
}
// Issue 774: a node's engine writes its own health report's key and no other node's.
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) {
t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish)
}
if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") ||
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")) {
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())
}
}
// 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).
if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete {
t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge)
}
// The tools account's secret is the module's own broker-<binary>, which its manifest's own-secrets names and the
// vault keeps by that name (AccountSecret).
if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools",
URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" {
t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid())
}
// Issue 1952: a module that holds a seat says its own seat's events, also when the tool runner carries it, so the
// self-check's `checked`, the keeper's condition events and the healers' `healer-acted` reach their hearers. Its
// membership lists them, the runtime is granted them; a user of the seat is not, nor a machine's event on a node seat
// (granted per machine, to the holder's own account only).
selfCheck := Declared{Module: "mesh-self-check", Holds: []Seat{{Name: "mesh-self-check", Scope: "mesh", Emits: []string{"checked"}}}}
for _, c := range []struct {
name string
d Declared
subject string
may bool
}{
{"the self-check says checked", selfCheck, "mesh.seat.mesh-self-check.event.checked", true},
{"the keeper says a condition raised", Declared{Module: "mesh-conditions", Holds: []Seat{{Name: "mesh-conditions", Scope: "mesh",
Emits: []string{"condition-raised", "condition-cleared"}}}}, "mesh.seat.mesh-conditions.event.condition-cleared", true},
{"a user does not say the seat's events", Declared{Module: "mesh-delivery-view", Uses: []Seat{{Name: "mesh-self-check",
Scope: "mesh", Emits: []string{"checked"}}}}, "mesh.seat.mesh-self-check.event.checked", false},
{"the holder says no other seat's", selfCheck, "mesh.seat.mesh-conditions.event.condition-raised", false},
{"a machine's event on a node seat is not composed here", Declared{Module: "pause", Holds: []Seat{{Name: "node-power",
Scope: "node", Emits: []string{"paused.*"}}}}, "mesh.seat.node-power.event.paused.b", false},
} {
m := MembershipFor("a", c.d, Placements{})
listed := m.SeatTraffic != nil && slices.ContainsFunc(m.SeatTraffic.Publish, func(p string) bool { return p == c.subject })
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Carries: []Declared{c.d}})
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if listed != c.may || MayPublish(perms, c.subject) != c.may {
t.Errorf("%s: the membership lists %s %v and the runtime may publish it %v, want %v", c.name, c.subject, listed,
MayPublish(perms, c.subject), c.may)
}
}
seen := map[string]bool{}
for _, u := range users {
name := u.Username()
if u.Module == WatchmanModule && u.Kind == KindModule {
name = WatchmanModule
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if name == WatchmanModule {
// 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"} {
if !MaySubscribe(perms, subject) {
t.Errorf("the watchman may not hear %s", subject)
}
}
// Never the bus's API audit, which carries every API answer in the account (the lead, 2026-10-29).
if MaySubscribe(perms, "$JS.EVENT.ADVISORY.API") || MaySubscribe(perms, "$JS.EVENT.ADVISORY.API.x") {
t.Error("the watchman hears the bus's API audit")
}
if !MayPublish(perms, "$JS.API.DIRECT.GET.PROV_APPLIED.prov.applied.g14.keycloak") {
t.Error("the watchman may not read the newest provisioner report")
}
}
subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...)
denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...)
if len(may[name+" sources"]) > 0 {
seen[name+" sources"] = true
}
for _, subject := range subjects {
seen[name] = true
if !MayPublish(perms, subject) {
t.Errorf("%s may not publish %s", name, subject)
}
}
for _, subject := range denied {
seen[name] = true
if MayPublish(perms, subject) {
t.Errorf("%s may publish %s", name, subject)
}
}
}
// Every row names a user that was composed, in either table: a row for a user absent would pass for nothing.
for _, table := range []map[string][]string{may, mayNot} {
for name := range table {
if !seen[name] {
t.Errorf("no user %s was composed", name)
}
}
}
}
// heldEventsBeyond takes out of the copy's memberships the one row they list beyond the controller's on purpose (issue
// 1952): a held seat's own events in its holder's seat traffic, so a module the tool runner carries may say them. The
// controller's composer is frozen; any other difference is still a fault.
func heldEventsBeyond(got, was any) {
gotAll, _ := got.(map[string]any)
wasAll, _ := was.(map[string]any)
for key, m := range gotAll {
gm, _ := m.(map[string]any)
traffic, _ := gm["seat-traffic"].(map[string]any)
if traffic == nil {
continue
}
listedBefore := map[string]bool{}
if wm, _ := wasAll[key].(map[string]any); wm != nil {
if wt, _ := wm["seat-traffic"].(map[string]any); wt != nil {
for _, p := range asStrings(wt["publish"]) {
listedBefore[p] = true
}
}
}
var kept []any
for _, p := range asStrings(traffic["publish"]) {
if parts := strings.Split(p, "."); !listedBefore[p] && len(parts) == 5 && parts[0] == "mesh" && parts[1] == "seat" && parts[3] == "event" {
continue
}
kept = append(kept, p)
}
if kept == nil {
delete(traffic, "publish")
} else {
traffic["publish"] = kept
}
if len(traffic) == 0 {
delete(gm, "seat-traffic")
}
}
}
func asStrings(v any) []string {
list, _ := v.([]any)
out := make([]string, 0, len(list))
for _, x := range list {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
-11
View File
@@ -1,7 +1,6 @@
package buscompose
import (
"strings"
"testing"
"time"
@@ -14,9 +13,6 @@ func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
WorkQueue: true, Duplicates: 1})
logs := ModuleStream{Module: "mesh-build-go", Name: "GO_BUILD_LOGS", Events: []string{"log.>"}, MaxAgeDays: 14, MaxMiB: 2048}
lcfg := ModuleStreamConfig(logs)
grants := strings.Join(ownStreamGrants("mesh-build-go", []ModuleStream{logs, {Module: "b", Name: "ASKS", Events: []string{"ask"}, WorkQueue: true}}), " ")
for _, row := range []struct {
what string
got, want any
@@ -41,13 +37,6 @@ func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
{"queue cap", queue.MaxBytes, int64(32 << 20)},
{"queue discards new", queue.Discard, jetstream.DiscardNew},
{"queue duplicates", queue.Duplicates, time.Hour},
{"queue is read by its consumer, never directly", queue.AllowDirect, false},
{"a module's stream is read back directly", lcfg.AllowDirect, true},
{"a module's stream drops its oldest", lcfg.Discard, jetstream.DiscardOld},
{"a module's stream cap", lcfg.MaxBytes, int64(2048 << 20)},
{"a module's stream age", lcfg.MaxAge, 14 * 24 * time.Hour},
{"its owner publishes and reads it, and no other's", grants,
"mesh.stream.mesh-build-go.event.log.> $JS.API.STREAM.INFO.GO_BUILD_LOGS $JS.API.DIRECT.GET.GO_BUILD_LOGS"},
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
} {
if row.got != row.want {
+14
View File
@@ -22,6 +22,10 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
{Module: RuntimeModule},
// The node-engine's manifest as a placement names it (issue 774, met twice on 2026-10-30): no account of its
// own, so the runner would carry it, and its state is the node's to write alone.
{Module: EngineModule, NoAccount: true, Consumes: []string{"restic.backed-up"},
State: []Bucket{{Module: EngineModule, Name: "health"}, {Module: EngineModule, Name: EngineActsState}}},
}},
// The node-engine's own manifest, placed on every node though no node is assigned it (novox/hq ADR 0563 §2, §3).
Engine: &Declared{Module: EngineModule, State: []Bucket{{Module: EngineModule, Name: EngineActsState}}},
@@ -32,6 +36,9 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Module == EngineModule {
t.Errorf("the node-engine's manifest composed a user of its own: %s", u.Username())
}
if u.Kind == KindNode || u.Kind == KindNodeTools {
p, err := PermissionsFor(u)
if err != nil {
@@ -64,6 +71,8 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
// Research 072 row 33: a runtime reads its membership from the grants part's state, never the assignments stream.
{"mesh-delivery-builder", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.novox.mesh-delivery-builder", false},
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
// The node-engine's acts: created by the grants part, written by each node's engine under its own keys alone.
@@ -75,6 +84,11 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.ask.hand-over-1", true},
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.ask.hand-over-1", false},
{"novox.node-tools", "$KV.mesh-host_acts.novox.act.hand-over-1", false},
// The runner never carries the engine (issue 774): its health report is the node's own key alone.
{"novox.node-tools", "$KV.mesh-host_health.novox", false},
{"novox.node-tools", "$KV.mesh-host_acts.novox.ask.x", false},
{"node.novox", "$KV.mesh-host_health.novox", true},
{"node.novox", "$KV.mesh-host_health.ace", false},
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
+43 -6
View File
@@ -3,6 +3,8 @@ package buscompose
import (
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module
@@ -22,18 +24,34 @@ const (
// EngineActsBucket is that state's bucket.
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the
// node's name, written by that node's engine alone.
var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState)
// EngineDataBucket is the engine's state of its node's placed modules' data items (novox/hq ADR 0577 clause 1): one
// key `<node>.<module>` per module, written by that node's engine alone.
var EngineDataBucket = delivery.EngineDataBucket
// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone,
// and its read.
func EnginePackagesGrants(node string) []string {
return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...)
}
// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it
// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the
// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR
// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any
// user of that seat is granted.
//
// Its verbs and events are granted as any verb and any event of the seat on its own node, not as the contract's list:
// the list is compiled into whichever mesh-sdk the grants part pins, so a verb the engine gained before the grants part
// was released was refused on every node and the engine reopened its link every few minutes (2026-10-31, the optional
// verb composition of mesh-host #184). As a module answers `mesh.mod.<module>.tool.>`, nothing is given away: no other
// principal may subscribe this seat's subjects of this node, and a caller is still granted each verb by name.
func engineGrants(p Principal) (pub, sub []string) {
for _, v := range catalogue.EngineVerbs() {
sub = append(sub, "mesh.seat."+EngineSeat+".tool."+v.Name+"."+p.Node)
}
for _, e := range catalogue.EngineEvents {
pub = append(pub, "mesh.seat."+EngineSeat+".event."+e+"."+p.Node)
}
sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node)
pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node)
sub = append(sub, announcing(EngineSeat)...)
pub = append(pub, discovering()...)
tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil))
@@ -41,5 +59,24 @@ func engineGrants(p Principal) (pub, sub []string) {
if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil {
pub = append(pub, own...)
}
// And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the
// consuming engine checks the vault's name and generation of what it is handed).
if safeSubject.MatchString(p.Node) {
pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...)
}
// And its node's own health report, its key `<node>` alone (issue 774, the operator's alert table): what the watchman
// reads for the machine's alerts.
if safeSubject.MatchString(p.Node) {
pub = append(pub, append([]string{"$KV." + delivery.NodeHealthBucket + "." + p.Node},
readGrants(delivery.NodeHealthBucket, p.Node)...)...)
}
// And its node's placed modules' data items, its own node's keys alone (ADR 0577 clause 1).
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).
if safeSubject.MatchString(p.Node) {
pub = append(pub, EnginePackagesGrants(p.Node)...)
}
return append(pub, tp...), append(sub, ts...)
}
+182
View File
@@ -0,0 +1,182 @@
package buscompose
import (
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
)
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5).
//
// - The condition keeper reads the controller's open conditions until the lease write, for what clauses 8 and 9 carry
// from them, and mesh-healers the controller's hand-act log, for S15 (Limits 2): both buckets the controller
// announces readable by nobody, so each is read by its named reader alone. The keeper, mesh-healers and the
// watchman read the controller's lease (Limits 8). Nobody reads mesh-healers' `would` (clause 8): what the healers
// would have done reaches no condition. Every observer's `findings`, the healers' `attempts`, the wants and the
// haves are read through `reads` as any announced state is; and no module writes another's state, so an observer
// can never write the keeper's buckets (clause 2).
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). It
// lists and shows DEAD_LETTERS, which is all it does before the lease write.
// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes`
// (catalogue.BusAccounts), which may remove only the consumer `controller` on DEAD_LETTER_NOTICES and keys of the
// node-engine's acts bucket (Limits 4), never the backup's account `broker`, which only reads (ADR 0235).
// The states the composer names its readers for, beside the lease (NamedReaders).
var (
// ControllerConditions is the controller's open conditions, read by the keeper alone (ADR 0564 clauses 8, 9).
ControllerConditions = ControllerSeat + ".conditions"
// ControllerHandActs is the controller's hand-act log, read by mesh-healers alone of the four health holders (ADR
// 0564 Limits 2).
ControllerHandActs = ControllerSeat + ".hand-acts"
// HealersWould is what mesh-healers would have done while the lease names a controller: read by nobody (clause 8).
HealersWould = health.HealersModule + "." + health.WouldState
)
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, issue 1952
// notes 6 and 7), each removal its grant: the controller's consumer on DEAD_LETTER_NOTICES read and deleted (the lease
// write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; the ASSIGNMENTS stream read and deleted whole (the lease
// write, research 072 row 33). A
// removal outside the set is refused, as the catalogue refuses it (catalogue.BusRemovals).
func BusRemovalGrants(removes []string) ([]string, error) {
var out []string
for _, r := range removes {
switch r {
case catalogue.RemovesControllerDeadLetterConsumer:
out = append(out, "$JS.API.CONSUMER.INFO."+DeadLetterNoticesStream+"."+ControllerName,
"$JS.API.CONSUMER.DELETE."+DeadLetterNoticesStream+"."+ControllerName)
case catalogue.RemovesEngineActs:
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
case catalogue.RemovesAssignmentsStream:
out = append(out, "$JS.API.STREAM.INFO."+AssignmentsStream, "$JS.API.STREAM.DELETE."+AssignmentsStream)
default:
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r,
strings.Join(catalogue.BusRemovals, ", "))
}
}
return out, nil
}
// removesAssignmentsStream is an account declared to remove the stream ASSIGNMENTS: the writers table's one exemption from
// stream definitions being the controller's, for deleting that one stream at the lease write (research 072 row 33).
func removesAssignmentsStream(p Principal, pattern string) bool {
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesAssignmentsStream) &&
pattern == "$JS.API.STREAM.DELETE."+AssignmentsStream
}
// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one
// exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4).
func removesEngineActs(p Principal, pattern string) bool {
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>"
}
// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no
// controller and `controller` is gone (ADR 0564 clause 5).
const WatchmanDeadLetterConsumer = WatchmanModule
// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least
// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller`
// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept
// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the
// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name.
// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read
// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may
// neither delete a message nor publish `mesh.again.>`.
func WatchmanDeadLetterGrants(sources []string) []string {
own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer
out := []string{
"$JS.API.STREAM.INFO." + DeadLetterNoticesStream,
"$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName,
"$JS.API.CONSUMER.CREATE." + own,
"$JS.API.CONSUMER.INFO." + own,
"$JS.API.CONSUMER.MSG.NEXT." + own,
"$JS.ACK." + own + ".>",
"$JS.API.STREAM.INFO." + DeadLettersStream,
"$JS.API.STREAM.MSG.GET." + DeadLettersStream,
deadLetterPrefix + ">",
// And the provisioner clients' reports, the newest per subject read at its start (S21, ADR 0362 §3).
"$JS.API.STREAM.INFO." + ProvAppliedStream,
"$JS.API.STREAM.MSG.GET." + ProvAppliedStream,
"$JS.API.DIRECT.GET." + ProvAppliedStream + ".>",
}
for _, s := range DeadLetterSources(sources) {
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s)
}
return out
}
// 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
// (`$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 {
return []string{
"mesh.mod.power.event.*",
"mesh.mod.*.event.provisioner.failing",
"mesh.mod.*.event.provisioner.recovered",
"mesh.control.*.tools-alive",
"mesh.seat.mesh-self-check.event." + health.EventChecked,
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
"mesh.mod.nats.event.bus.slow-consumer",
"mesh.mod.nats.event.bus.connection-closed",
ProvAppliedSubjects,
deadLetterPrefix + ">",
}
}
// DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a
// bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern.
func DeadLetterSources(names []string) []string {
seen := map[string]bool{}
var out []string
for _, n := range names {
switch {
case seen[n], !safeSubject.MatchString(n), strings.HasPrefix(n, "KV_"), strings.HasPrefix(n, "OBJ_"),
n == DeadLettersStream, n == DeadLetterNoticesStream:
continue
}
seen[n] = true
out = append(out, n)
}
sort.Strings(out)
return out
}
// deadLetterSourcesOf are the streams the records' consumers give up on: EVENTS, the work queue of every seat that
// accepts work, and every module's and process's own work queue.
func deadLetterSourcesOf(r Records) []string {
out := []string{EventsStream}
for _, mods := range r.Assigned {
for _, d := range mods {
for _, s := range append(append([]Seat(nil), d.Holds...), d.Uses...) {
if len(s.Accepts) > 0 {
out = append(out, seatStreamName(s.Name))
}
}
for _, q := range d.Queues {
out = append(out, q.Stream)
}
for _, pr := range d.Processes {
for _, q := range pr.Queues {
out = append(out, q.Stream)
}
}
}
}
return DeadLetterSources(out)
}
// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which
// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the
// controller's in L4.
func keepsDeadLetters(p Principal, pattern string) bool {
return isController(p, pattern) ||
(p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">")
}
+62 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"sort"
"strings"
"time"
"github.com/nats-io/nats.go/jetstream"
)
@@ -14,8 +15,10 @@ import (
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
// its stream and subjects, the controller creates it from the catalogue on every raise and from
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
// is gone is reported, as a bucket is. Unlike a bucket, a log is its owner's alone: no other module
// reads it, so there is no read of a log to grant or issue.
// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's
// `reads` names it, `<module>.<log>` (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): by direct gets, and through an
// ordered consumer of its own it creates and lets go of to read from a time or the newest per key and then follow new
// entries; never a durable consumer, never a write.
//
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
// is exactly what is sent; jetstream.go is the part that asks a server.
@@ -37,6 +40,8 @@ type Log struct {
Name string
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
MaxMiB int
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
MaxAgeDays int
}
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
@@ -74,7 +79,7 @@ func (l Log) Why() string {
}
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
// headers at most, that refuses a new entry when full rather than
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
// is how the runtime reads it.
@@ -85,7 +90,7 @@ func (l Log) Config() jetstream.StreamConfig {
Subjects: []string{l.Subject() + ".>"},
Storage: jetstream.FileStorage,
Retention: jetstream.LimitsPolicy,
MaxAge: 0,
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
MaxMsgs: -1,
MaxMsgsPerSubject: -1,
MaxBytes: l.MaxBytes(),
@@ -135,6 +140,59 @@ func logGrants(module string, names []string) []string {
return out
}
// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `<module>.<log>`.
func DeclaredLogs(r Records) map[string]bool {
out := map[string]bool{}
for _, ds := range r.Assigned {
for _, d := range ds {
for _, l := range d.Logs {
out[d.Module+"."+l.Name] = true
}
}
}
return out
}
// withReadLogs is d with ReadLogs set: of its reads, those naming a log of logs.
func withReadLogs(d Declared, logs map[string]bool) Declared {
d.ReadLogs = nil
for _, r := range d.Reads {
if logs[strings.TrimSpace(r)] {
d.ReadLogs = append(d.ReadLogs, strings.TrimSpace(r))
}
}
return d
}
// readLogGrants is what a reader of other modules' logs publishes, for each `<module>.<log>` read: the stream's info,
// direct gets (an entry by sequence, the last of a key), and an ordered consumer of its own (created with any name and
// filter, pulled, read and deleted). No append, no purge, no delete of an entry: the log's stream refuses those anyway.
func readLogGrants(reads []string) []string {
var out []string
for _, r := range reads {
module, name, ok := strings.Cut(r, ".")
if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
continue
}
stream := LogStreamName(module, name)
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream, "$JS.API.DIRECT.GET."+stream+".>",
"$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.MSG.NEXT."+stream+".*",
"$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*")
}
return out
}
// readLogsIssuedFor is every other module's log a module's code reads, as its membership lists them: by the read's
// name, never written.
func readLogsIssuedFor(d Declared) []LogIssued {
var out []LogIssued
for _, r := range d.ReadLogs {
module, name, _ := strings.Cut(r, ".")
out = append(out, LogIssued{Name: r, Stream: LogStreamName(module, name), Subject: LogSubject(module, name)})
}
return out
}
// logNames is the local names of a module's logs.
func logNames(logs []Log) []string {
out := make([]string, 0, len(logs))
+5 -2
View File
@@ -24,6 +24,9 @@ type Placements struct {
Interchangeable map[string]bool
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
Kinds []KindHeld
// Logs is every log a module placed anywhere declares, `<module>.<log>` (DeclaredLogs): a read naming one is issued
// as a log read (novox/hq ADR 0571 clause 1).
Logs map[string]bool
}
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
@@ -58,7 +61,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
}
}
m.State = stateIssuedFor(d, node)
m.Logs = logsIssuedFor(d)
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
m.Streams = streamsIssuedFor(d)
m.Hears = HeardIssuedFor(node, d)
m.Waits = waitsIssuedFor(d)
@@ -103,7 +106,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)}
for node, declared := range r.Assigned {
for _, d := range declared {
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
+1 -8
View File
@@ -89,8 +89,6 @@ func (s ModuleStream) Stream() Stream {
Why: why,
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
Restricted: s.Restricted,
// Its owner reads it back by direct gets, with no consumer (its runtime's stream.read, as a log's read).
Direct: !s.WorkQueue,
}
if s.WorkQueue {
out.Retention = RetentionWorkQueue
@@ -250,9 +248,7 @@ func heardGrants(p Principal, hears []Heard) []string {
return out
}
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's, and the reading of
// each back by direct gets (its runtime's stream.read: the stream's info and the batch direct get, with no consumer), as
// a log is read. A work-queue stream is its one consumer's to read, never by direct get.
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
func ownStreamGrants(module string, streams []ModuleStream) []string {
var out []string
for _, s := range streams {
@@ -260,9 +256,6 @@ func ownStreamGrants(module string, streams []ModuleStream) []string {
continue
}
out = append(out, s.Subjects()...)
if !s.WorkQueue && safeSubject.MatchString(s.Name) {
out = append(out, "$JS.API.STREAM.INFO."+s.Name, "$JS.API.DIRECT.GET."+s.Name)
}
}
return out
}
+69 -17
View File
@@ -181,6 +181,8 @@ type Principal struct {
KeyedReads []KeyedRead
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
Logs []string
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
ReadLogs []string
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
// subjects it was granted (novox/hq ADR 0363).
Streams []ModuleStream
@@ -196,6 +198,12 @@ type Principal struct {
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
// else — not its declarations, which the node's tool runtime serves for it.
SnapshotsTheBus bool
// 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
// 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
// WitnessesController is a machine principal whose node-engine witnesses the controller's upgrades:
// the machine runs the controller (novox/hq ADR 0236, lease/witness.go). It may read the lease's one
@@ -655,6 +663,19 @@ func PermissionsFor(p Principal) (Permissions, error) {
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
}
if p.Kind == KindProcess && len(p.Removes) > 0 {
pub, err := BusRemovalGrants(p.Removes)
if err != nil {
return Permissions{}, err
}
sort.Strings(pub)
if err := CheckWriters(p, pub); err != nil {
return Permissions{}, err
}
// Its own inbox for the answers, and nothing else (Limits 4).
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
}
var pub, sub []string
switch p.Kind {
case KindController:
@@ -988,6 +1009,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
if p.Module == WatchmanModule {
sub = append(sub, BusConnectSubject, BusDisconnectSubject)
pub = append(pub, BusConnzSubject)
// And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5).
pub = append(pub, WatchmanDeadLetterGrants(p.DeadLetterSources)...)
// And the words its watchdogs hear (ADR 0564 clause 4, Limits 3, 11).
sub = append(sub, WatchmanHears()...)
}
// The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5;
// the operator, 2026-10-25 22:31): one per subject, `mesh.assignment.<node>.<module>`, reading the last one of each
@@ -1036,10 +1061,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
sub = append(sub, announcing(announced...)...)
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
// directly from the stream and followed live. Nothing else's.
// 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))
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+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).
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
// grant a person gets and derived the same way, so "what may this module ask" is
@@ -1157,6 +1183,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
sub = append(sub, stateCreatedHeard(kept)...)
// And its logs (novox/hq ADR 0297): appended to and read directly, its own alone.
pub = append(pub, logGrants(p.Module, p.Logs)...)
pub = append(pub, readLogGrants(p.ReadLogs)...)
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
// (novox/hq ADR 0259 §3).
@@ -1199,11 +1226,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
}
// Every assigned module's membership on this node (ADR 0160): one per module, read
// directly from the stream and followed live. This node's and no other's — the one token
// 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+".*")
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+p.Node+".*")
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.
pub = append(pub, "mesh.control."+p.Node+".tools-alive")
@@ -1260,6 +1287,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
// runtime's to keep, from the logs each membership lists.
for _, d := range p.Carries {
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
pub = append(pub, readLogGrants(d.ReadLogs)...)
}
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
@@ -1649,17 +1677,7 @@ func ComposeAccounts(principals []Principal) (string, error) {
if p.PasswordHash == "" {
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
}
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
if len(perms.PublishDeny) > 0 {
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
} else {
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
}
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
if perms.AllowResponses {
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
}
b.WriteString(" } }\n")
b.WriteString(AccountsEntry(p.Username(), p.PasswordHash, perms))
}
b.WriteString(" ]\n }\n")
b.WriteString(systemAccount)
@@ -1667,6 +1685,25 @@ func ComposeAccounts(principals []Principal) (string, error) {
return b.String(), nil
}
// AccountsEntry is one user of the accounts block, as ComposeAccounts writes each: its name, its password hash and its
// permissions, in the lines the bus reads. Exported so a writer that keeps a user from grants it holds rather than
// composes (mesh-grants' refused users) writes the same lines, never a copy of them (issue 774).
func AccountsEntry(user, passwordHash string, perms Permissions) string {
var b strings.Builder
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", user, passwordHash)
if len(perms.PublishDeny) > 0 {
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
} else {
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
}
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
if perms.AllowResponses {
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
}
b.WriteString(" } }\n")
return b.String()
}
// WatchmanModule is the one module whose principal hears the bus's connect and disconnect events and asks its list of
// connections (novox/hq ADR 0431 §2, issue 1147): the watchman of the repository mesh-delivery, which writes presence
// from them. No other principal is granted the subjects below, and nothing is a wildcard over them.
@@ -1705,6 +1742,21 @@ func CreateConsumerGrants(consumers []ModuleConsumer) []string {
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
const GrantsModule = "mesh-grants"
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime
// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through
// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a
// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader
// served nothing (issue 774). The decoder still reads that wrapper, for the transition.
const (
GrantsMembershipsState = "memberships"
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
GrantsMembershipsRead = GrantsModule + "." + GrantsMembershipsState
)
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
func MembershipKey(node, module string) string { return node + "." + module }
// The bus's own events, imported into MESH from the default system account `$SYS` under subjects of MESH's own
// (novox/hq ADR 0431 §1, issue 1147): the server says an account's connections opening and closing only to the system
// account, and the list of an account's connections only to a request made there. `$SYS` holds no user, so nobody
+17 -6
View File
@@ -3,9 +3,11 @@ package buscompose
import (
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/nats-io/nats.go"
)
@@ -34,15 +36,19 @@ var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `<node>.<module>` (novox/hq ADR
// 0570 clause 2). **Named one by one**, as the controller's asks are: a domain that moves to the
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
var ProvisionerReads = []string{ConfigResolvedBucket}
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
// and plans, never a secret's value; the client writes none of it.
var ProvisionerReadsWhole = []string{AssignmentsBucket}
// and plans, never a secret's value; the client writes none of it. **The seat's plans are read whole for the same
// reason** (2026-10-30 recovery, issue 774): the client V3 watches every machine's plans to bind a provider's
// consumers, and granted only its own machine's keys it read none. ADR 0570 clause 3 has providers act on the seat's
// provision keys instead; once the client does, plans go back to its own machine's keys.
var ProvisionerReadsWhole = []string{AssignmentsBucket, provisions.PlansBucket}
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
@@ -68,8 +74,13 @@ var (
)
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
// client cannot write another machine's needs, or say another machine applied a file.
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A client
// cannot write another machine's needs, or say another machine applied a file.
//
// **The needs stay granted for one release, and are removed after the lease write** (review round 3 of design 47's
// release): the client's V3 reads its machine's plans from the seat mesh-provisioner's `plans` (novox/hq ADR 0570 clause
// 2) and records no needs, but the client before it still runs on a node until its delivery reaches it, and it writes
// them.
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
+14
View File
@@ -58,6 +58,20 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
var t SeatTraffic
for _, s := range holds {
if !s.isNewTraffic() {
// **A holder says its own seat's events** (novox/hq ADR 0564 clause 6, issue 1952): its own account is
// granted them by the held seats' rule (PermissionsFor, step 3), and a module the tool runner carries had
// no way to, so the self-check's `checked` was never said. Listed here, the membership names them for the
// runtime and the runtime is granted them. A machine's event on a node seat carries the machine, which
// only the holder's own account is composed with, so it is left to that rule.
if servedOnlyByTheController(s) {
continue
}
for _, e := range s.Emits {
if s.Scope == "node" && perMachineEvents[e] {
continue
}
t.Publish = append(t.Publish, seatSubject(s, "event", e))
}
continue
}
kind := ""
+11
View File
@@ -156,6 +156,12 @@ func stateGrants(a stateAccess) []string {
}
for _, r := range a.Reads {
if bucket, ok := bucketOfRead(r); ok {
if NodeKeyedReads[r] {
if machineKey != "" {
read(bucket, machineKey+".>")
}
continue
}
read(bucket, "")
}
}
@@ -167,6 +173,11 @@ func stateGrants(a stateAccess) []string {
return out
}
// NodeKeyedReads are the states keyed `<node>.>` whose reader reads its own machine's keys alone, whatever its `reads`
// names: the node-engine's data items, each node's disk-load reading its own node's (novox/hq ADR 0577 clause 1). A
// machine with no usable name reaches none of them.
var NodeKeyedReads = map[string]bool{delivery.EngineDataRead: true}
// StreamCreatedAdvisory is the subject the bus says a stream was created on (the server's own advisory), here for a
// bucket's stream KV_<bucket>.
func StreamCreatedAdvisory(bucket string) string {
+3 -8
View File
@@ -171,14 +171,9 @@ func MeshStreams() []Stream {
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
"n-1 by construction (issue 107)",
},
{
Name: AssignmentsStream,
Subjects: []string{"mesh.assignment.*.*"},
Retention: RetentionLastPerSubject,
Direct: true,
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
},
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
// (research 072 row 33), so it is not declared here, which would make it again.
{
Name: EventsStream,
// A seat's own events ride here too: they are 1:many like any event, and the
+67 -1
View File
@@ -3,8 +3,12 @@ package buscompose
import (
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
)
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes
@@ -77,12 +81,30 @@ func grantsOwnObjects(p Principal, pattern string) bool {
// readers too (announce.State.Readers); both are honoured.
const VaultBusAccounts = "mesh-vault.bus-accounts"
// ArtifactStoreTools is the module holding the seat mesh-artifact-sweep (novox/hq ADR 0571 clause 2).
const ArtifactStoreTools = "artifact-store-tools"
// ConfigModule is the module that holds the seat mesh-config, a reader of the controller's lease (NamedReaders).
const ConfigModule = "mesh-config"
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`: the grants part's wanted accounts
// are read by the vault alone (novox/hq ADR 0561 §2).
// The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which
// reads the catalogue's placement alone from then (ADR 0549 §5, issue 774).
//
// The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the
// watchman read the lease; the keeper alone the controller's conditions; mesh-healers and mesh-hand-acts its hand-act log
// (ADR 0565 clauses 10, 11: every reader of acts reads both logs); and nobody the healers' `would` (health_grants.go).
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}}
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule,
// ADR 0571 clause 2: the artifact store's seat sweeps only once the lease names no controller.
ArtifactStoreTools,
// ADR 0561 §3: mesh-config takes over the config only once the lease is handed away (its takeover.go leaseAway).
ConfigModule},
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}}
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
// that names none.
@@ -103,6 +125,23 @@ func readsNamed(module string, reads []string) (granted, refused []string) {
return granted, refused
}
// withAnnouncedReads is d with its reads judged for d's module (ReadsAnnouncedBy) against what the owners announced:
// every read its manifest names, granted or refused by the caller, judged again here, so a caller that split them
// without the reader (the controller's own copy of a reader-less judgement, issue 774: the vault, the assigner, the
// grants part and the artifact store's tools refused the lease they are named for) cannot refuse a read the reader is
// named for, nor grant one named for another. With no announcements read it is withNamedReaders.
func withAnnouncedReads(d Declared, announced map[string]announce.States) Declared {
if announced == nil {
return withNamedReaders(d)
}
all := append(append([]string(nil), d.Reads...), d.ReadsRefused...)
if len(all) == 0 {
return d
}
d.Reads, d.ReadsRefused = ReadsAnnouncedBy(d.Module, all, announced)
return d
}
// withNamedReaders is d with the reads named for other modules moved to its refused reads.
func withNamedReaders(d Declared) Declared {
granted, refused := readsNamed(d.Module, d.Reads)
@@ -113,3 +152,30 @@ func withNamedReaders(d Declared) Declared {
d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...)
return d
}
// **The hearings the grants part withholds, on the bus** (issue 1955 L7, mesh-delivery e564eaf): a hearing of a restricted
// stream a module asks in the tool runner, withheld because only an account of its own may hear it (ADR 0376 §2), kept
// in the grants part's state `withheld`, one key `<module>.<stream, lower case>.<machine>` (the controller's condition id
// of it), written on change only and deleted once heard; the grants part's tools read it (D-heard was dropped on
// 2026-10-30, so no other module reads it).
const (
GrantsWithheldState = "withheld"
GrantsWithheldBucket = GrantsModule + "_" + GrantsWithheldState
GrantsWithheldRead = GrantsModule + "." + GrantsWithheldState
WithheldVersion = 1
)
// WithheldHearing is one withheld hearing: the machine, the module, the restricted stream and its emitter, the plain
// words of it and the remedy (Withheld.Said), and when the grants part first wrote it as it stands.
type WithheldHearing struct {
V int `json:"v"`
Node string `json:"node"`
Module string `json:"module"`
Stream string `json:"stream"`
Emitter string `json:"emitter"`
Said string `json:"said"`
At time.Time `json:"at"`
}
// WithheldKey is a withheld hearing's key: `<module>.<stream, lower case>.<machine>`.
func WithheldKey(w Withheld) string { return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node }
+34 -7
View File
@@ -64,6 +64,9 @@ type Declared struct {
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
ReadsRefused []string
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
ReadLogs []string
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
KeyedReads []KeyedRead
@@ -105,6 +108,9 @@ type ProcessPrincipal struct {
Emits []string
// Queues are the module's own work-queue streams this process is the one consumer of.
Queues []QueueOwner
// Removes are what its account may remove from the bus, as its bus declares them (catalogue.BusRemovals; novox/hq
// ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants.
Removes []string
// State is the module's own state, by local name, this process reads and writes.
State []string
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
@@ -154,6 +160,7 @@ func Users(r Records) ([]Principal, error) {
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
served := servedReadsOf(r)
logs := DeclaredLogs(r)
grants := -1
for _, node := range sortedCopy(r.Nodes) {
witness := false
@@ -193,9 +200,14 @@ func Users(r Records) ([]Principal, error) {
}
}
for _, d := range r.Assigned[node] {
// A state with named readers is read by them alone (ADR 0547 clause 9): a read named for another module is
// granted nothing, here and in the runtime carrying the module.
d = withNamedReaders(d)
// **The node-engine's manifest is no placed module's** (novox/hq ADR 0563 §2, §3; issue 774): where a placement
// names it, it adds no user, no process and nothing the runner carries; its grants are the node's account's.
if !Composed(d) {
continue
}
// A state with named readers is read by them alone (ADR 0547 clause 9), and a read is granted what its owner
// announced (ADR 0495 clause 3): judged here, for this reader, whatever split the caller made (issue 774).
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
// **A process account is its own, delivered in its own secret** (broker-<process id>, novox/hq
// ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module
// itself has an account, and is never carried by the machine's runtime, which runs as the
@@ -204,7 +216,7 @@ func Users(r Records) ([]Principal, error) {
out = append(out, Principal{
Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process,
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
Invokes: pr.Invokes,
Invokes: pr.Invokes, Removes: pr.Removes,
})
}
if runtimeHere && d.Module == RuntimeModule {
@@ -223,12 +235,15 @@ func Users(r Records) ([]Principal, error) {
Kind: KindModule, Node: node, Module: d.Module,
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
})
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
if d.Module == WatchmanModule {
out[len(out)-1].DeadLetterSources = deadLetterSourcesOf(r)
}
if d.Module == GrantsModule {
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
grants = len(out) - 1
@@ -243,8 +258,8 @@ func Users(r Records) ([]Principal, error) {
if runtimeHere {
var carried []Declared
for _, d := range r.Assigned[node] {
if d.RunsAs == "" && runnerServes(d) {
d = withNamedReaders(d)
if CarriedByRunner(d) {
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
d.Hears = ComposedHears(d)
carried = append(carried, d)
@@ -440,6 +455,18 @@ func boundBucketsOf(r Records) []string {
return out
}
// Composed says a placed module is composed as one: its own user, its processes and what the runner carries of it.
// The node-engine's manifest (EngineModule) is not, though a placement may name it: each node's engine speaks as the
// node's account, which alone writes the engine's state under its own key (engineGrants, novox/hq ADR 0563 §2, §3), and
// Records.Engine creates that state. Granted to another account it is a second writer, and the writers table refuses
// the whole list (issue 774: met for mesh-host_acts and then mesh-host_health, 2026-10-30).
func Composed(d Declared) bool { return d.Module != EngineModule }
// CarriedByRunner says the machine's tool runner carries this placed module: a module composed at all (Composed) that
// runs as no account of its own and has something for the runner to serve (runnerServes). The one test of what the
// runner's grants are composed from.
func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) }
// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1,
// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on,
// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and
+23 -3
View File
@@ -43,6 +43,9 @@ func createsDeclared(p Principal, pattern string) bool {
if isController(p, pattern) {
return true
}
if removesAssignmentsStream(p, pattern) {
return true
}
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
if ok && p.Kind == KindModule && p.Module == GrantsModule && name == GrantsUserListObjectStream {
return true
@@ -185,7 +188,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)"},
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)"},
{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"},
@@ -220,7 +223,8 @@ var WritersTable = []WriterRow{
// and delivered again or dropped only through its verb, with why.
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
Others: "read, delivered again or dropped through the controller's dead-letters verb",
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters,
Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"},
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
// anything else write them is refused at composition. Built so far: the assignments and each registered
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
@@ -247,9 +251,25 @@ var WritersTable = []WriterRow{
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
// The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the
// node-engine's declared state, created by the grants part, written by each node's engine under its own keys.
// A node's own health report (issue 774): its engine's own key, read by the watchman.
{State: "a machine's health report", Writer: "the node-engine of the machine, its key `<node>` alone",
KeptIn: "key-value " + delivery.NodeHealthBucket, Others: "read by the watchman through `reads`",
Subjects: kvOf(delivery.NodeHealthBucket), Writes: ownNodeKeys},
// A node's declared packages (novox/hq ADR 0565 clause 6): its engine's own key, read by pacman's verbs.
{State: "a machine's declared packages", Writer: "the node-engine of the machine, its key `<node>` alone",
KeptIn: "key-value " + EnginePackagesBucket, Others: "read by pacman through `reads`",
Subjects: kvOf(EnginePackagesBucket), Writes: ownNodeKeys},
// A node's placed modules' data items (novox/hq ADR 0577 clause 1): its engine's own keys, read by that node's disk-load.
{State: "a machine's data items", Writer: "the node-engine of the machine, under its own keys `<node>.>`",
KeptIn: "key-value " + EngineDataBucket, Others: "read by the node's disk-load through `reads`, its own node's keys alone",
Subjects: kvOf(EngineDataBucket), Writes: ownNodeKeys},
{State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `<node>.>`, compare-and-set",
KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts",
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), Writes: ownNodeKeys},
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket),
Writes: func(p Principal, pattern string) bool {
return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern)
},
Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"},
// The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each
// object under the digest of its content; a node-engine reads one by its name, with no list and no write.
{State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +
+76
View File
@@ -0,0 +1,76 @@
// Package busretry waits out a bus that answers nothing for a few seconds (issue 774, the sweep of 2026-10-30: bus
// blackouts at every user-list reload). nats-server 2.11's reloadAuthorization rebuilds the accounts, and for a few
// seconds the mesh's account has none of its imports, $JS.API.> among them: every request is answered "no responders"
// and every JetStream call "jetstream not enabled". A write or a call made in that window failed, and was lost.
//
// Only an error that says the request reached no one is waited out (Undelivered): nothing was done, so asking again
// is safe even for a write that is not idempotent. A timeout, or any answer, is never asked again: the request may
// have been delivered. The wait is bounded (Bound, about 30 s, backing off), and said on standard error when it ends
// without an answer.
//
// This is the quick fix. The thorough one is a reload that keeps the account's imports, in the bus's holder.
package busretry
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Bound is how long a request that reached no one is asked again; FirstWait the first pause, doubled to MostWait.
var (
Bound = 30 * time.Second
FirstWait = 250 * time.Millisecond
MostWait = 4 * time.Second
)
// Undelivered says err means the request reached no one: no responders, JetStream not enabled (for the account), or
// no response from the stream after the client's own retries on no responders; also said in words by a runtime that
// passed the error on.
func Undelivered(err error) bool {
var answered *Answered
if err == nil || errors.As(err, &answered) {
return false
}
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, jetstream.ErrJetStreamNotEnabled) ||
errors.Is(err, jetstream.ErrJetStreamNotEnabledForAccount) || errors.Is(err, jetstream.ErrNoStreamResponse) ||
errors.Is(err, nats.ErrJetStreamNotEnabled) || errors.Is(err, nats.ErrJetStreamNotEnabledForAccount) {
return true
}
words := strings.ToLower(err.Error())
return strings.Contains(words, "no responders") || strings.Contains(words, "jetstream not enabled")
}
// Answered is an error a callee answered with: the request was delivered, whatever its words say ("jetstream not
// enabled" in a callee's own answer), so Undelivered never counts it and Do never asks it again.
type Answered struct{ Words string }
func (a *Answered) Error() string { return a.Words }
// Do runs try, and again while its error says the request reached no one, until Bound has passed. blackout, when
// given, is asked after each such error and Do stops waiting when it says the bus is not blacked out: a call nobody
// answers because its callee is not running fails at once, as before. what names the request in the error.
func Do[T any](what string, try func() (T, error), blackout func() bool) (T, error) {
v, err := try()
if !Undelivered(err) || (blackout != nil && !blackout()) {
return v, err
}
start, wait := time.Now(), FirstWait
for time.Since(start) < Bound {
time.Sleep(wait)
wait = min(wait*2, MostWait)
v, err = try()
if !Undelivered(err) || (blackout != nil && !blackout()) {
return v, err
}
}
err = fmt.Errorf("%s: the bus answered nothing for %s, asked again until then (a user-list reload drops the "+
"account's imports for a few seconds; this was longer): %w", what, time.Since(start).Round(time.Second), err)
fmt.Fprintf(os.Stderr, "[mesh-sdk] %v\n", err)
return v, err
}
+48
View File
@@ -0,0 +1,48 @@
package busretry
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Issue 774 (bus blackouts at every user-list reload): only a request that reached no one is asked again, within the
// bound; a blackout probe that says the bus answers ends the wait at once.
func TestABusThatAnswersNothingIsWaitedOut(t *testing.T) {
Bound, FirstWait, MostWait = 60*time.Millisecond, time.Millisecond, 4*time.Millisecond
for _, row := range []struct {
name string
errs []error // what each try answers; the last repeats
blackout func() bool
tries int // at least
atMost int
delivered bool
}{
{"no responders, then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, nil, 3, 3, true},
{"jetstream not enabled, then an answer", []error{jetstream.ErrJetStreamNotEnabled, nil}, nil, 2, 2, true},
{"said in words by a runtime", []error{errors.New("nats: no responders available for request"), nil}, nil, 2, 2, true},
{"a timeout may have been delivered: never asked again", []error{context.DeadlineExceeded}, nil, 1, 1, false},
{"an answer that refuses is an answer", []error{errors.New("refused")}, nil, 1, 1, false},
{"nobody answers on a bus that answers: the callee is not running", []error{nats.ErrNoResponders}, func() bool { return false }, 1, 1, false},
{"blacked out past the bound: said", []error{jetstream.ErrJetStreamNotEnabledForAccount}, func() bool { return true }, 3, 1000, false},
} {
tries := 0
_, err := Do("the test", func() (int, error) {
e := row.errs[min(tries, len(row.errs)-1)]
tries++
return tries, e
}, row.blackout)
if tries < row.tries || tries > row.atMost || (err == nil) != row.delivered {
t.Errorf("%s: %d tries, %v", row.name, tries, err)
}
if strings.HasPrefix(row.name, "blacked out") && (!Undelivered(err) || !strings.Contains(fmt.Sprint(err), "the bus answered nothing")) {
t.Errorf("%s: %v", row.name, err)
}
}
}
+30 -18
View File
@@ -126,23 +126,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
continue
}
made := by[a.Name]
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
loads := BundleLoads(m, a, run)
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
// it reached it by, and a manifest carrying that address names an installation —
// registration refused node-tools for exactly this on 2026-10-02. The build record
@@ -152,7 +136,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
Binary: BinaryOf(a),
Binary: BinaryOf(a), Bus: a.Bus,
})
}
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
@@ -715,3 +699,31 @@ func undeliveredBundles(m Manifest) []string {
}
return problems
}
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
return loads
}
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
+17 -2
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -27,14 +28,28 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
if _, err := ParseManifest(bundled); err != nil {
m, err := ParseManifest(bundled)
if err != nil {
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
}
// And so does its built manifest, its bundle in Bundles and its build read away (issue 774: nats's built manifest
// was refused, and with it every plan of the control-node).
built, err := m.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}})
if err != nil {
t.Fatal(err)
}
raw, err = json.Marshal(built)
if err != nil {
t.Fatal(err)
}
if _, err := ParseManifestForInputs(raw); err != nil {
t.Fatalf("the built manifest of a bus module whose tools bundle declares what it emits was refused: %v", err)
}
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
"tools":["bus_streams"]}`)
m, err := ParseManifest(quiet)
m, err = ParseManifest(quiet)
if err != nil {
t.Fatalf("a bus module with an account and tools was refused: %v", err)
}
+98
View File
@@ -0,0 +1,98 @@
package catalogue
import (
"fmt"
"regexp"
"strings"
)
// **The data items of a node's modules, placed, read from the composition** (novox/hq ADR 0571 clause 3, ADR 0233 §22 to
// §25): disk-load measures each item every module on its node declares, and the node-backup holder, mounts and nfs-server
// read the readings. Each item is its module's declaration with its path resolved to this node's place, its protection
// and how it is measured: the same lines the node-backup holder is handed as `${backup:data}`, composed by the same
// function, so a reader that composes from the bus and a holder handed the file read one list.
// PlacedDataItem is one data item a module on a node declares, as placed on that node.
type PlacedDataItem struct {
Module string `json:"module"`
ID string `json:"item"`
Class string `json:"class"`
// Path is the item's place on the node; CoveredBy the path whose snapshot keeps it (the item, or the item its dump
// writes into), empty when it is not backed up.
Path string `json:"path"`
CoveredBy string `json:"covered_by,omitempty"`
// Protection is backup, redundancy, both or none; Measure walk, dataset or shallow.
Protection string `json:"protection,omitempty"`
Measure string `json:"measure,omitempty"`
}
var (
dataModuleHeader = regexp.MustCompile(`^#\s*([a-z0-9][a-z0-9-]*)$`)
dataSafePath = regexp.MustCompile(`^/[^\s'"\\$` + "`" + `]*$`)
)
// ParseDataItems reads the node-backup holder's data text (`${backup:data}`: a `# <module>` line, then one line per item,
// `item <id> <class> <path> <covered-by or -> <protection> <measure>`) into each module's items, in order. A line it does
// not read is refused, naming the module: a path with a space or a quote is never handed to a measuring shell.
func ParseDataItems(text string) (map[string][]PlacedDataItem, error) {
out := map[string][]PlacedDataItem{}
module := ""
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := dataModuleHeader.FindStringSubmatch(line); m != nil {
module = m[1]
continue
}
if strings.HasPrefix(line, "#") || module == "" {
continue
}
f := strings.Fields(line)
if len(f) < 5 || len(f) > 7 || f[0] != "item" || !dataSafePath.MatchString(f[3]) || (f[4] != "-" && !dataSafePath.MatchString(f[4])) {
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
}
it := PlacedDataItem{Module: module, ID: f[1], Class: f[2], Path: f[3], Measure: MeasureWalk}
if len(f) >= 6 {
it.Protection = f[5]
}
if len(f) == 7 {
it.Measure = f[6]
}
if it.Measure != MeasureWalk && it.Measure != MeasureDataset && it.Measure != MeasureShallow {
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
}
if f[4] != "-" {
it.CoveredBy = f[4]
}
out[module] = append(out[module], it)
}
return out, nil
}
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
func (r Resolution) DataItems(with Rendering) (items map[string][]PlacedDataItem, unplaced []string, err error) {
left := r.LeftOut(with.Settings, with.Adopted)
for module, why := range with.NotPlaced {
if _, already := left[module]; !already {
left[module] = why
}
}
modules := make([]Manifest, 0, len(r.Modules))
for _, m := range r.Modules {
if _, isLeft := left[m.Module]; isLeft {
m = backupView(m)
}
modules = append(modules, m)
}
text, unplaced, err := seatContributions(modules, Manifest{}, BackupSeat+":"+BackupKindData, with,
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
if err != nil {
return nil, unplaced, err
}
items, err = ParseDataItems(text)
return items, unplaced, err
}
+10 -26
View File
@@ -1,7 +1,6 @@
package catalogue
import (
"encoding/json"
"slices"
"strings"
"testing"
@@ -46,31 +45,6 @@ func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
if it, _ := m.DataItem("dumps"); it.BackedUp() {
t.Fatal("a rebuildable item that says none is backed up")
}
// Issue 774's new words first: an offer's credential form and an artifact's bus are accepted and change nothing: the
// manifest that says them composes byte-equal to the one that does not, and no form still means a pair.
plain := strings.Replace(declaredStore, `"resources":[`, `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/x","binary":"x","loads":["x"]}]},"resources":[`, 1)
for _, form := range []string{"pair", "none"} {
worded := strings.Replace(plain, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"`+form+`"}}]`, 1)
worded = strings.Replace(worded, `"loads":["x"]}`, `"loads":["x"],"bus":{"state":["acts"],"removes":["engine-acts"]}}`, 1)
a, b := mustParse(t, plain), mustParse(t, worded)
if b.Provides[0].Credential != nil || b.CredentialFormOf("postgres-database") != a.CredentialFormOf("postgres-database") ||
a.CredentialFormOf("postgres-database") != CredentialFormPair || len(b.Build.Artifacts[0].Bus) == 0 {
t.Fatalf("form %s: offer %+v, form %q against %q", form, b.Provides[0], b.CredentialFormOf("postgres-database"),
a.CredentialFormOf("postgres-database"))
}
b.Build.Artifacts[0].Bus = nil
ra, erra := (Resolution{Node: "anchor", Modules: []Manifest{a}}).Compose(Rendering{})
rb, errb := (Resolution{Node: "anchor", Modules: []Manifest{b}}).Compose(Rendering{})
ja, _ := json.Marshal(ra.Resources)
jb, _ := json.Marshal(rb.Resources)
if erra != nil || errb != nil || string(ja) != string(jb) || len(ra.Resources) == 0 {
t.Fatalf("form %s composes %s against %s (%v, %v)", form, jb, ja, errb, erra)
}
}
if _, err := ParseManifest([]byte(strings.Replace(declaredStore, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"shared"}}]`, 1))); err == nil ||
!strings.Contains(err.Error(), `the form "shared", not "pair" or "none"`) {
t.Fatalf("a form the release does not know: %v", err)
}
}
// Every rule of the section itself, refused at parse in the words of the module.
@@ -218,6 +192,16 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
if data != want {
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
}
// ADR 0571 clause 3: the same text, read into items, as disk-load and the backup holder read it; a path a shell
// would split is refused.
items, err := ParseDataItems(data)
if err != nil || len(items) != 3 || items["media"][0] != (PlacedDataItem{Module: "media", ID: "films", Class: "irreplaceable",
Path: "/tank/films", Protection: "redundancy", Measure: MeasureDataset}) || items["pg"][0].CoveredBy != "/srv/store/dumps" {
t.Fatalf("the data lines read as %+v: %v", items, err)
}
if _, err := ParseDataItems("# a\nitem x cache /a b - none walk\n"); err == nil {
t.Fatal("a path with a space was read")
}
if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil {
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
}
+116 -28
View File
@@ -221,6 +221,11 @@ type Rendering struct {
// They win over anything the mesh would assign and over a manifest's own long-form mapping.
Given map[string]map[int]int
// Planned says this rendering composes the plans the seat mesh-provisioner keeps (compose.Planning). A contribution a
// plan carries is then refused where its port is none the module listens on: no machine port stands for it, and its
// provider dials what the plan says (issue 774, research 072 row 34).
Planned bool
// Taken is the modules taken on this adopted node (novox/hq ADR 0100). The guard is derived
// from these only (ADR 0103): a port of a module assigned but not taken may still be the
// predecessor's.
@@ -277,6 +282,10 @@ type Rendering struct {
// templated files are handed to the client (handed.go); without either, it composes as before.
Hands bool
Provisioner string
// Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the
// vault answered the node composing itself; where it names a module, Handing follows it alone (issue 774). Nil, or a
// module it does not name, is judged by the manifest and the machine, as the composer of the plans does.
Assigned map[string]bool
}
// RootField is a user resource's field saying the account must never become root without a person
@@ -700,13 +709,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
handsSecrets := with.Handing(m)
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
if handsSecrets {
owner := m.SecretsOwner
if m.Module == RuntimeModule && r.Account != "" {
owner = r.Account
}
if o := m.OwnSecrets[name].Owner; o != "" {
owner = o
}
owner := r.ownSecretOwner(m, name)
first = append(first, ownedBy(owner, map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
}))
@@ -718,13 +721,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if string(box) == string(ForeseenVaultBox) {
*foreseen = append(*foreseen, m.Module+"/"+name)
}
owner := m.SecretsOwner
if m.Module == RuntimeModule && r.Account != "" {
owner = r.Account
}
if o := m.OwnSecrets[name].Owner; o != "" {
owner = o
}
owner := r.ownSecretOwner(m, name)
first = append(first, ownedBy(owner, map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
}))
@@ -754,14 +751,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// the manifest, because a manifest cannot say ${machine:account} safely — a node with no
// account has nothing to resolve it to, and then the runtime runs as root and the file
// stays root's.
owner := m.SecretsOwner
if m.Module == RuntimeModule && r.Account != "" {
owner = r.Account
}
if o := m.OwnSecrets[name].Owner; o != "" {
owner = o
}
first = append(first, ownedBy(owner, map[string]any{
first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
}))
}
@@ -1084,15 +1074,22 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if err != nil {
return nil, err
}
// **What the mesh calls the module through each provision it requires** (issue 792): the names its binding
// file carries, composed by the same call, handed to the provider through the plan's entry.
if _, ok := handed.plans[m.Module]; ok && handing != nil {
// **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose
// provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own,
// so the seat keys it and the helper hears it.
keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs)
if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) {
for _, to := range requirementsContributedTo(m) {
own, err := r.ownNames(m, to, with.Settings[m.Module])
if err != nil {
return nil, err
}
handed.name(m.Module, to, own)
contributed, err := r.contributedOf(m, to, with)
if err != nil {
return nil, err
}
handed.contribute(m.Module, to, contributed)
}
}
// A handed collector is filled from items alone: each old form its collections would have held is said
@@ -1368,8 +1365,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if err != nil {
return nil, err
}
owner[fmt.Sprint(process["id"])] = RuntimeModule
out = append(out, process)
if process != nil { // none for a held runtime's stand-in: what runs of it is carried
owner[fmt.Sprint(process["id"])] = RuntimeModule
out = append(out, process)
}
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
owns, err := r.ownRuntimes(with)
if err != nil {
@@ -1394,7 +1393,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
words[m.Module] = w
}
// And a file a module's words name is one the runtime is restarted for when it changes.
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil {
restarts, _ := process["restart-on"].([]any)
seen := map[string]bool{}
for _, id := range restarts {
@@ -1677,6 +1676,19 @@ func (r Resolution) provisionsAs(m Manifest) string {
return m.SecretsOwner
}
// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the
// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR
// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none).
func (r Resolution) ownSecretOwner(m Manifest, name string) string {
if o := m.OwnSecrets[name].Owner; o != "" {
return o
}
if r.Account != "" && (m.Module == RuntimeModule || ToolsBusSecret(m, name)) {
return r.Account
}
return m.SecretsOwner
}
// grantPath is where one consumer's sealed credential lands on the providing machine.
//
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
@@ -1825,6 +1837,82 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
return out, nil
}
// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received
// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by
// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a
// plan and a file cannot say different things. Nil where m contributes nothing to it.
//
// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs,
// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan
// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000
// on another machine (published at 20012) until it did (2026-10-31).
func (r Resolution) contributedOf(m Manifest, to string, with Rendering) (json.RawMessage, error) {
layers := with.Settings[m.Module]
form := ContributedForm{At: r.At}
add := func(local string, values map[string]any) error {
values, err := r.atAssignedPort(m, to, values, with)
if err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
}
form.Each = append(form.Each, ContributedOne{Local: local, Values: raw})
return nil
}
if raw, ok := m.Contributes[to]; ok {
values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to)
if err != nil {
return nil, err
}
if values, err = withShares(m, to, values); err != nil {
return nil, err
}
if err := add("", values); err != nil {
return nil, err
}
}
for _, local := range sortedKeys(m.ContributesMany[to]) {
values, err := r.composed(m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to)
if err != nil {
return nil, err
}
if err := add(local, values); err != nil {
return nil, err
}
}
if len(form.Each) == 0 {
return nil, nil
}
return EncodeContributed(form)
}
// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number
// `${port:<software port>}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's
// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed
// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address.
//
// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the
// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition
// with no ports) such a port is left as it is, as machinePort leaves it.
func (r Resolution) atAssignedPort(m Manifest, to string, values map[string]any, with Rendering) (map[string]any, error) {
number, ok := asPort(values["port"])
if !ok {
return values, nil
}
_, given := with.Given[m.Module][number]
_, placed := with.Ports[m.Module][number]
if !given && !placed {
if with.Planned {
return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+
"machine port stands for it", m.Module, to, number, r.Node)
}
return values, nil
}
return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: with.machinePort(m.Module, number)}}), nil
}
// composed is one contribution as its provider receives it: settled with this node's settings, its
// endpoint's port filled in, and its names composed from the label.
//
+25
View File
@@ -1,5 +1,7 @@
package catalogue
import "fmt"
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
@@ -105,6 +107,29 @@ func SeatSays(emits []string, event string) bool {
return false
}
// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead,
// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A
// consumer of `<seat>.<event>` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish
// on `mesh.seat.<seat>.event.<event>`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught
// here, when the manifest is read at the build, not when nothing is heard on the machines.
func seatEventProblems(m Manifest) []string {
var problems []string
for _, c := range m.Claims {
seat, known := SeatNamed(c.Name)
if !known || len(seat.Emits) == 0 {
continue
}
for _, e := range m.Emits {
if SeatSays(seat.Emits, e) {
problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+
"say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+
"no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e))
}
}
}
return problems
}
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
// `>` or `**` the rest.
func topicMatches(pattern, event string) bool {
+20
View File
@@ -49,6 +49,26 @@ func EngineVerbs() []Verb {
"hands its lease away, beside the controller's mirror for this machine and their difference. With write, the " +
"key written again from what the engine composed.",
Input: schema(map[string]string{"write": "\"true\": write this machine's key again from what the engine composed"}, nil, "write")},
// Read-only (issue 774): why a module waits for a yes, or what its next apply would change. Optional until every
// engine serves it.
{Name: "composition", Optional: true, Description: "One module's composition on this machine beside what runs of " +
"it, read-only: the composition its want's build has in the bucket composed-<node> (the latest revision, or the " +
"one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs " +
"(applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, " +
"and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for " +
"a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers " +
"that join or leave.",
Input: schema(map[string]string{"module": "the module, as placed on this machine; node for the node's own",
"digest": "a composition's digest (sha256:…), to read that revision rather than the latest",
"diff": "\"true\": list the resources that differ from what runs"}, []string{"module"})},
// The recovery of a composition that failed (issue 774, 2026-10-31: ace kept route-proxy failed for a vault that
// answered again a minute later). Optional until every engine serves it.
{Name: "compose", Optional: true, Description: "Compose this machine's modules now and say how it went, so a " +
"composition that failed, or a module held, recovers without a restart: what the pass refused or held, each " +
"with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no " +
"responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried " +
"again. Changes only what a pass the engine makes on its own would change.",
Input: schema(map[string]string{"module": "a module placed on this machine whose apply is also tried again (optional)"}, nil)},
}
}
+179 -6
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"encoding/json"
"errors"
"fmt"
"maps"
@@ -66,6 +67,16 @@ type Plan struct {
Provisions []PlanProvision `json:"provisions,omitempty"`
}
// Hands is whether the plan hands its module to the provisioner: it names something the node's provisioner client fills
// (a template archive, a file, an own secret or an item), and the node composes none of it. A plan naming provisions alone
// hands nothing (catalogue.Keyed, issue 774): the seat keys what a provider's helper answers of a module not handed, and
// that module's files and own secrets stay its node's composition. The one rule every reader of a plan asks; whether a
// plan exists is never it (on 2026-10-30 the vault read every route-only plan as handed and answered twenty modules'
// own secrets to nobody).
func (p Plan) Hands() bool {
return p.Templates.Source != "" || len(p.Files) > 0 || len(p.Secrets) > 0 || len(p.Items) > 0
}
// PlanItem is one item the module contributes: to which collection, and its template inside the archive.
//
// A named item has its Name and Attributes (design 57 L3, issue 777): the client records them with the item and
@@ -124,16 +135,91 @@ type PlanProvision struct {
// alone knows the node's domains, and carried unchanged by the provider's helper into the consumer's entry. Nil
// where the consumer contributes nothing to the provision, and the entry is then as it was.
Names map[string]string `json:"names,omitempty"`
// Contributed is what the consumer gives its provider through this provision, opaque here (issue 774, research
// 072 row 34): the bytes of a ContributedForm, each contribution composed as its provider receives it and where
// the consumer's machine is. What a contribution's values mean is the provider's own contract, published by the
// provider's repository (route-proxy's for a route), which decodes them; the seat, its plans, its client and the
// helper carry them unread. On the provision's first entry alone; nil where the consumer contributes nothing to it.
Contributed json.RawMessage `json:"contributed,omitempty"`
}
// ContributedVersion is the version of ContributedForm this SDK writes and reads.
const ContributedVersion = 1
// ContributedForm is the generic form of PlanProvision.Contributed: every contribution one consumer makes to one
// requirement, composed (settled with its node's settings, its endpoint's port filled in, its names joined to the
// node's domains: Resolution.composed, the one composition a received file was written from), and At, where the
// consumer's machine is on the private network, empty when it is on none. The provisioner knows no field of any
// contribution: Values are the bytes of an object in the provider's contract.
type ContributedForm struct {
V int `json:"v"`
At string `json:"at,omitempty"`
Each []ContributedOne `json:"each"`
}
// ContributedOne is one contribution: Local its local name among several (`contributes` naming several), empty for a
// requirement's one, and Values the contribution's object.
type ContributedOne struct {
Local string `json:"local,omitempty"`
Values json.RawMessage `json:"values"`
}
// EncodeContributed is f as the bytes PlanProvision.Contributed carries, refused where DecodeContributed would refuse
// them, so a plan never carries what its reader cannot take.
func EncodeContributed(f ContributedForm) (json.RawMessage, error) {
f.V = ContributedVersion
raw, err := json.Marshal(f)
if err != nil {
return nil, err
}
if _, err := DecodeContributed(raw); err != nil {
return nil, err
}
return raw, nil
}
// DecodeContributed reads PlanProvision.Contributed strictly: this version, no field it does not know, at least one
// contribution, each local name once, each value a JSON object. What a value says is not judged here.
func DecodeContributed(raw json.RawMessage) (ContributedForm, error) {
var f ContributedForm
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return ContributedForm{}, fmt.Errorf("what a consumer contributed is not readable: %w", err)
}
if dec.More() {
return ContributedForm{}, errors.New("what a consumer contributed carries more than one document")
}
if f.V != ContributedVersion {
return ContributedForm{}, fmt.Errorf("what a consumer contributed is of version %d, this SDK reads %d", f.V,
ContributedVersion)
}
if len(f.Each) == 0 {
return ContributedForm{}, errors.New("what a consumer contributed holds no contribution")
}
seen := map[string]bool{}
for _, one := range f.Each {
if seen[one.Local] {
return ContributedForm{}, fmt.Errorf("what a consumer contributed names %q twice", one.Local)
}
seen[one.Local] = true
var object map[string]json.RawMessage
if err := json.Unmarshal(one.Values, &object); err != nil || object == nil {
return ContributedForm{}, fmt.Errorf("the contribution %q is not a JSON object", one.Local)
}
}
return f, nil
}
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
// out: the vault has no provider's value to make for it.
func provisionsOf(needs []Needed, module, as string, contributes map[string]map[string]any,
func provisionsOf(needs []Needed, module string, handed bool, as string, contributes map[string]map[string]any,
named map[string]bool) []PlanProvision {
var out []PlanProvision
for _, n := range needs {
if n.For != module || n.ByRecord || n.Module == "" || n.From == "" {
// **The one rule** (Keyed, issue 774): what the seat keys and the want waits for.
if n.For != module || !Keyed(handed, n) {
continue
}
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
@@ -283,16 +369,43 @@ func handedBy(m Manifest, archive Built) *Handed {
return h
}
// handing is whether this composition hands the module's templated files to the provisioner.
// handing is whether this composition hands the module's templated files to the provisioner: handed (Handing), with
// the templates its build kept. A module the vault judged handed whose build kept none is refused where its secrets
// are placed ("nothing would fill it"), never composed with values the vault does not answer.
func (with Rendering) handing(m Manifest) bool {
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
return with.Handing(m) && m.Handed != nil
}
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
// client asks the vault.
func (with Rendering) Handing(m Manifest) bool { return with.handing(m) }
//
// **Where the vault judged the module, its word is the rule** (Rendering.Assigned, issue 774): the vault leaves a
// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node
// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31
// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by
// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule.
func (with Rendering) Handing(m Manifest) bool {
if hands, judged := with.Assigned[m.Module]; judged {
return hands
}
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
}
// AssignmentHands is whether a module's value in the seat mesh-provisioner's plans (the assignments mirror's shape,
// `{"provisioning": <Plan>}`) hands its module to the provisioner: Plan.Hands of the plan it carries, false with no
// plan. A value that does not read is an error, never "not handed". The one rule the vault judges `own` by and a node
// composing itself follows (Rendering.Assigned, issue 774).
func AssignmentHands(raw []byte) (bool, error) {
var a struct {
Provisioning *Plan `json:"provisioning"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return false, err
}
return a.Provisioning != nil && a.Provisioning.Hands(), nil
}
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
// <module>`.
@@ -589,6 +702,56 @@ type handedFiles struct {
required map[string]map[string]bool
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
names map[string]map[string]map[string]string
// keyedOnly is each module with a plan only for what Keyed keys of a consumer not handed: a provision its
// provider's helper answers (issue 774), such as a route.
keyedOnly map[string]bool
// contributed is what each handed module gives each requirement's provider, encoded (PlanProvision.Contributed).
contributed map[string]map[string]json.RawMessage
}
// contribute records what a handed module gives one requirement's provider, encoded; nothing for none.
func (h *handedFiles) contribute(module, provision string, raw json.RawMessage) {
if len(raw) == 0 {
return
}
if h.contributed == nil {
h.contributed = map[string]map[string]json.RawMessage{}
}
if h.contributed[module] == nil {
h.contributed[module] = map[string]json.RawMessage{}
}
h.contributed[module][provision] = raw
}
// keyed starts the plan of a module begin started none for, when the composition keys any of its needs as a consumer
// not handed (Keyed, issue 774): one whose provider's helper answers a provision of it (grafana's route). A module
// handed that keeps no template or item (a held one's stub, issue 1126) is not handed here either: what it keeps
// stays the controller's but for what a helper answers. Its plan names provisions alone: no templates, files or
// secrets. Answers whether it started one.
func (h *handedFiles) keyed(node string, m Manifest, needs []Needed) bool {
if _, started := h.plans[m.Module]; started {
return false
}
if !slices.ContainsFunc(needs, func(n Needed) bool { return n.For == m.Module && Keyed(false, n) }) {
return false
}
if h.identities == nil {
h.identities = map[string]string{}
h.required = map[string]map[string]bool{}
}
h.identities[m.Module] = ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
h.plans[m.Module] = Plan{}
if h.keyedOnly == nil {
h.keyedOnly = map[string]bool{}
}
h.keyedOnly[m.Module] = true
if len(m.Contributes) > 0 {
if h.contributes == nil {
h.contributes = map[string]map[string]map[string]any{}
}
h.contributes[m.Module] = m.Contributes
}
return true
}
// name records the names composed for a handed module through one requirement, as its binding file carries them.
@@ -768,7 +931,8 @@ func (h *handedFiles) finished(needs []Needed) map[string]Plan {
for module, plan := range h.plans {
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
// is that provision's entry, with its name and file (issue 757).
provisions := provisionsOf(needs, module, h.identities[module], h.contributes[module], h.required[module])
provisions := provisionsOf(needs, module, !h.keyedOnly[module], h.identities[module], h.contributes[module],
h.required[module])
for _, credential := range h.provisions[module] {
i := slices.IndexFunc(provisions, func(p PlanProvision) bool {
return p.Provision == credential.Provision && p.Local == credential.Local
@@ -784,6 +948,15 @@ func (h *handedFiles) finished(needs []Needed) map[string]Plan {
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
}
sortProvisions(provisions)
// **What the consumer contributes, on the provision's first entry** (issue 774): a provider serving from the
// plans alone reads each contribution once, however many local names the requirement has.
gave := map[string]bool{}
for i := range provisions {
if raw := h.contributed[module][provisions[i].Provision]; len(raw) > 0 && !gave[provisions[i].Provision] {
gave[provisions[i].Provision] = true
provisions[i].Contributed = slices.Clone(raw)
}
}
plan.Provisions = provisions
h.plans[module] = plan
}
+29 -4
View File
@@ -42,9 +42,10 @@ func consumerOn(t *testing.T, m Manifest, w World) Resolution {
node := workstation()
node.At = "workstation.internal"
stores := shelf(m,
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
Credential: &OfferCredential{Form: CredentialFormPair}}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormPair}}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
r, err := Resolve(stores, []string{m.Module}, node, w)
if err != nil {
@@ -130,7 +131,10 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
return mustParse(t, raw+`}`)
}
for credential, want := range map[string]string{
``: CredentialFormPair,
// No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it.
``: "",
`,"credential":{"form":"pair"}`: CredentialFormPair,
`,"credential":{"form":"none"}`: CredentialFormNone,
`,"credential":{"own":"admin"}`: CredentialFormShared,
`,"credential":{"own":"token"}`: CredentialFormGiven,
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
@@ -149,10 +153,29 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
if len(got) != 1 || got[0].CredentialForm != want {
t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want)
}
// Every form a plan names is in the one list each reader checks (issue 774); an undeclared one is not.
if IsCredentialForm(want) != (want != "") {
t.Errorf("the form %q of an offer with %q: in CredentialForms %v", want, credential, IsCredentialForm(want))
}
}
if forms := strings.Join(CredentialForms(), ","); forms != "shared,pair,issued,given,none" || IsCredentialForm("maybe") {
t.Errorf("the credential forms are %s, and maybe is one: %v", forms, IsCredentialForm("maybe"))
}
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
t.Errorf("a module offering nothing has the credential form %q", got)
}
// The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none.
for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form",
`,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`,
`,"credential":{"form":"none"}`: ""} {
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}`
var m Manifest
_ = json.Unmarshal([]byte(raw), &m)
problems := strings.Join(CredentialFormProblems(m), "; ")
if (refused == "") != (problems == "") || !strings.Contains(problems, refused) {
t.Errorf("%q: %q, want %q", credential, problems, refused)
}
}
}
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
@@ -164,7 +187,9 @@ func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
CredentialForm: CredentialFormPair, Contributes: map[string]any{"name": "nextcloud"}, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify"),
// What the binding file says as `${bound:postgres-database:name}` (issue 792).
Names: map[string]string{"name": "nextcloud"}}}
Names: map[string]string{"name": "nextcloud"},
// And what a received file carried of it, composed, with where its machine is: opaque bytes (issue 774).
Contributed: json.RawMessage(`{"v":1,"at":"workstation.internal","each":[{"values":{"name":"nextcloud"}}]}`)}}
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
t.Fatalf("the plan names %+v, want %+v", got, want)
}
+12
View File
@@ -65,6 +65,15 @@ func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) {
t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want)
}
// Issue 774 (postgres on novox, 2026-10-31): where the vault judged it handed, its secrets are the vault's even on a
// machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers.
judged, err := r.Compose(Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}})
if err != nil {
t.Fatalf("a module the vault judged handed is refused: %v", err)
}
if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(judged.Plans["bot"].Secrets, want) {
t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.Plans["bot"].Secrets, want)
}
// A process naming the token's file still restarts when its client writes it.
for _, res := range c.Resources {
if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") {
@@ -99,6 +108,9 @@ func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
for name, with := range map[string]Rendering{
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
// Issue 774: where the vault judged the module not handed, the node composes it so, whatever else it reads.
"the vault judged it not handed": {Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000",
Assigned: map[string]bool{"bot": false}},
} {
c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with))
if got != before {
+67
View File
@@ -0,0 +1,67 @@
package catalogue
import "slices"
// The one rule of which provisions the seat mesh-provisioner keys and a want waits for (novox/hq ADR 0570 §1, §2, §4;
// issue 774). The plan composition puts a need in a consumer's plan by it, the holder of mesh-provisioner keys what the
// plans name, and the assigner's rule 8 waits for what Awaited answers: three parts, one rule, so they cannot drift
// again (on 2026-10-30 the assigner waited for every `requires` while the plans named only some, and every new build of
// grafana, dnsmasq, mesh-knowledge, mesh-mail and the builders waited for a key nobody would make).
//
// A need a module answers (not a record, not nothing) is keyed when its consumer is handed to the provisioner, or the
// provider's offer says the provider helper answers it. A need of a consumer not handed whose provider's helper does
// not answer it is still answered by the controller's composition: it is neither keyed nor waited for.
// HandedByManifest is whether a module is handed to the provisioner as far as its manifest says (Rendering.Handing
// without the machine's own words, and begin's): marked moved, not a held module's stub, with a template its build
// kept or an item.
func (m Manifest) HandedByManifest() bool {
return m.Moved && m.Handed != nil && !m.Stubbed && (len(m.Handed.Templates) > 0 || len(m.Items) > 0)
}
// Keyed is whether a need is a provision of the seat mesh-provisioner: a key, an entry in its consumer's plan, and a
// wait of the consumer's want. consumerHanded is whether the composition hands the consumer to the provisioner.
func Keyed(consumerHanded bool, n Needed) bool {
if n.ByRecord || n.Module == "" || n.From == "" {
return false
}
return consumerHanded || n.AnsweredByHelper
}
// Awaited is, of what consumer requires (not optionally), each provision Keyed answers yes for, judged from manifests
// alone, as the assigner reads them: providers answers the modules providing a provision. A provision is a need when a
// provider offers it to the mesh, or one on the consumer's machine serves facts about it (Resolve: one answered on the
// machine with nothing served binds nothing). It is keyed when the consumer is handed, or every provider's offer is
// answered by the helper: where the providers disagree, which one answers is the composition's, and nothing is waited
// for that might never be keyed. Sorted, each once.
func Awaited(consumer Manifest, providers func(provision string) []Manifest) []string {
var out []string
for _, r := range consumer.Requires {
if consumer.OptionalRequires[r] || slices.Contains(out, r) {
continue
}
by := providers(r)
if len(by) == 0 {
continue
}
need, helper := false, true
for _, p := range by {
for _, o := range p.Provides {
if o.Name != r {
continue
}
if o.At() == ScopeMesh || len(p.Serves[r]) > 0 {
need = true
}
}
if !p.AnsweredByHelperFor(r) {
helper = false
}
}
if need && (consumer.HandedByManifest() || helper) {
out = append(out, r)
}
}
slices.Sort(out)
return out
}
+203
View File
@@ -0,0 +1,203 @@
package catalogue
import (
"encoding/json"
"reflect"
"slices"
"strings"
"testing"
)
// The one rule replays the live cases of 2026-10-30 (issue 774): what the assigner's rule 8 waits for is what the
// composition keys, judged from the manifests alone.
func TestAwaitedIsWhatTheCompositionKeys(t *testing.T) {
built := &Handed{Source: "store://t", Digest: "sha256:t", Templates: map[string]string{"c": "c"}}
offer := func(module, name string, scope string, helper bool, serves bool) Manifest {
o := Offer{Name: name, Scope: scope}
if helper {
o.AnsweredBy = AnsweredByHelper
}
m := Manifest{Module: module, Provides: []Offer{o}}
if serves {
m.Serves = map[string]map[string]any{name: {"port": 1}}
}
return m
}
gitea, distribution := offer("gitea", "forge-account", ScopeMesh, false, false), offer("distribution", "artifact-store", ScopeMesh, false, false)
goTools, nodeJS := offer("go", "go-toolchain", ScopeNode, false, false), offer("nodejs", "node-toolchain", ScopeNode, false, false)
keycloak, proxy := offer("keycloak", "oidc-client", ScopeMesh, false, false), offer("route-proxy", "route", ScopeMesh, true, false)
adapter := offer("route-adapter", "route", ScopeMesh, false, false)
ollama := offer("ollama", "llm", ScopeNode, false, true)
by := func(ms ...Manifest) func(string) []Manifest {
return func(p string) []Manifest {
var out []Manifest
for _, m := range ms {
if slices.Contains(m.Offers(), p) {
out = append(out, m)
}
}
return out
}
}
for _, c := range []struct {
name string
consumer Manifest
providers func(string) []Manifest
want []string
}{
{"a handed builder waits for what binds, not for a toolchain on its own machine (mesh-build-go on ace)",
Manifest{Module: "mesh-build-go", Moved: true, Handed: built,
Requires: []string{"forge-account", "go-toolchain", "artifact-store", "node-tools"}},
by(gitea, distribution, goTools), []string{"artifact-store", "forge-account"}},
{"node-toolchain alike (mesh-build-node)",
Manifest{Module: "mesh-build-node", Moved: true, Handed: built, Requires: []string{"node-toolchain", "forge-account"}},
by(gitea, nodeJS), []string{"forge-account"}},
{"a module not handed waits for its route, which the helper answers, and not for its login the controller still makes (grafana on ace)",
Manifest{Module: "grafana", Requires: []string{"route", "oidc-client", "influxdb-api"},
OptionalRequires: map[string]bool{"influxdb-api": true}},
by(keycloak, proxy), []string{"route"}},
{"where providers disagree on the helper, nothing is waited for that might never be keyed",
Manifest{Module: "grafana", Requires: []string{"route"}}, by(proxy, adapter), nil},
{"a moved module with no template is not handed (a held stub, issue 1126)",
Manifest{Module: "bot", Moved: true, Handed: &Handed{}, Requires: []string{"oidc-client", "route"}},
by(keycloak, proxy), []string{"route"}},
{"a provision on its own machine that serves facts binds, and is keyed for a handed consumer",
Manifest{Module: "chat", Moved: true, Handed: built, Requires: []string{"llm", "llm"}}, by(ollama), []string{"llm"}},
} {
if got := Awaited(c.consumer, c.providers); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: awaited %v, want %v", c.name, got, c.want)
}
}
}
// A module not handed has a plan for what a provider's helper answers of it, and for nothing else (issue 774: grafana
// had no plan, so no route key, and route-proxy served no route for it); a need the controller still answers stays out.
func TestAModuleNotHandedHasAPlanForWhatAHelperAnswers(t *testing.T) {
consumer := notifierConsumer(t, false, false)
consumer.Requires = append(consumer.Requires, "route")
consumer.Contributes = map[string]map[string]any{"route": {"host": "notify.example.org"}}
consumer.ContributesMany = map[string]map[string]map[string]any{"route": {"api": {"label": "notify-api", "port": 9000}}}
node := workstation()
node.At, node.PublicDomain = "workstation.internal", "example.org"
proxy := Manifest{Module: "proxy", Version: "1", Provides: []Offer{{Name: "route", Scope: ScopeMesh, AnsweredBy: AnsweredByHelper}}}
store := Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
Credential: &OfferCredential{Form: CredentialFormPair}}}, Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}}
w := twoStores("anchor")
w.Offered["route"] = []Provider{{Node: "anchor", At: "anchor.internal", Module: "proxy"}}
r, err := Resolve(shelf(consumer, proxy, store), []string{consumer.Module}, node, w)
if err != nil {
t.Fatal(err)
}
r.Account = "op"
for i, n := range r.Needs {
// As the plan sets it (compose's answeredByHelper).
r.Needs[i].AnsweredByHelper = n.Name == "route"
}
_, before := composeWith(t, r, Rendering{})
c, got := composeWith(t, r, handingTo("store:5000"))
if got != before {
t.Fatalf("the plan changed what is composed:\nbefore: %s\nnow: %s", before, got)
}
plan, ok := c.Plans["notify"]
if !ok || len(plan.Provisions) != 1 || plan.Provisions[0].Provision != "route" || plan.Provisions[0].ProviderModule != "proxy" {
t.Fatalf("the plan is %+v (%v), want the route alone", plan, ok)
}
if plan.Templates != (PlanTemplates{}) || plan.Files != nil || plan.Secrets != nil ||
!strings.Contains(plan.Provisions[0].As, "notify") || plan.Provisions[0].Contributes["host"] != "notify.example.org" {
t.Fatalf("the plan names %+v", plan)
}
// What it contributes, composed as a received file carried it, and where its machine is, as opaque bytes the
// provider's own contract reads (issue 774, research 072 row 34): route-proxy serves the route from the plan alone.
given, err := DecodeContributed(plan.Provisions[0].Contributed)
if err != nil || given.At != "workstation.internal" || len(given.Each) != 2 || given.Each[0].Local != "" ||
string(given.Each[0].Values) != `{"host":"notify.example.org"}` || given.Each[1].Local != "api" ||
string(given.Each[1].Values) != `{"internal-name":"notify-api.anchor.internal","label":"notify-api","name":"notify-api.example.org","port":9000}` {
t.Fatalf("the plan gives %+v (%v)", given, err)
}
// Its port is where the machine publishes it, the number ${port:9000} gives the module and its declaration publishes,
// never the software's own where the placement moved it (2026-10-31: route-proxy dialled gitea's 3000, published at
// 20000): its provider dials what the plan says. Where a plan carries it, a port the module does not listen on is
// refused by module, node and port.
for _, p := range []struct {
name string
ports map[string]map[int]int
given map[string]map[int]int
planned bool
port float64
refused string
}{
{"assigned by the placement (gitea 3000 at 20000)", map[string]map[int]int{"notify": {9000: 20009}}, nil, true, 20009, ""},
{"given by the node over the placement", map[string]map[int]int{"notify": {9000: 20009}},
map[string]map[int]int{"notify": {9000: 9900}}, true, 9900, ""},
{"a bare published port the placement holds none for keeps its number (influxdb 8086)",
map[string]map[int]int{"notify": {9000: 9000}}, nil, true, 9000, ""},
{"a mapping the module fixed (as mailu 7080:80)", map[string]map[int]int{"notify": {9000: 7080}}, nil, true, 7080, ""},
{"a port the module does not listen on", nil, nil, true, 0,
"notify contributing to route: its port 9000 on workstation is none of the ports it listens on"},
{"a declaration with no ports leaves it as written", nil, nil, false, 9000, ""},
} {
with := handingTo("store:5000")
with.Ports, with.Given, with.Planned = p.ports, p.given, p.planned
c, err := r.Compose(with)
if p.refused != "" {
if err == nil || !strings.Contains(err.Error(), p.refused) {
t.Errorf("%s: composed with %v, want refused with %q", p.name, err, p.refused)
}
continue
}
if err != nil {
t.Errorf("%s: %v", p.name, err)
continue
}
given, err := DecodeContributed(c.Plans["notify"].Provisions[0].Contributed)
var api map[string]any
if err == nil {
err = json.Unmarshal(given.Each[1].Values, &api)
}
if err != nil || api["port"] != p.port || given.At != "workstation.internal" {
t.Errorf("%s: the plan gives %v at %q (%v), want port %v", p.name, api, given.At, err, p.port)
}
}
// Read strictly: a form the SDK does not write is refused, whatever its contributions say.
for raw, refused := range map[string]string{
`{"v":2,"each":[{"values":{}}]}`: "version 2",
`{"v":1,"each":[]}`: "no contribution",
`{"v":1,"each":[{"values":{}}],"route":{}}`: "unknown field",
`{"v":1,"each":[{"values":{}},{"values":{}}]}`: "twice",
`{"v":1,"each":[{"local":"api","values":["a"]}]}`: "not a JSON object",
`{"v":1,"each":[{"values":{}}]} {}`: "more than one",
`{"v":1,"at":"10.0.0.2","each":[{"values":{"x":true}}]}`: "",
} {
_, err := DecodeContributed(json.RawMessage(raw))
if (refused == "") != (err == nil) || err != nil && !strings.Contains(err.Error(), refused) {
t.Errorf("DecodeContributed(%s) = %v, want %q", raw, err, refused)
}
}
// And it hands nothing: Plan.Hands, the one rule every reader of a plan asks, by each part a plan can name.
if plan.Hands() {
t.Fatalf("a plan naming the route alone hands its module: %+v", plan)
}
for _, c := range []struct {
name string
plan Plan
hands bool
}{
{"empty", Plan{}, false},
{"provisions alone", Plan{Provisions: plan.Provisions}, false},
{"a digest without an archive", Plan{Templates: PlanTemplates{Digest: "sha256:1"}}, false},
{"templates alone", Plan{Templates: PlanTemplates{Source: "notify.tar", Digest: "sha256:1"}}, true},
{"files alone", Plan{Files: []PlanFile{{ID: "conf", Path: "/etc/notify.conf"}}}, true},
{"secrets alone", Plan{Secrets: []PlanSecret{{Name: "admin"}}}, true},
{"items alone", Plan{Items: []PlanItem{{Collection: "routes", Template: "route.tmpl"}}}, true},
{"secrets and provisions", Plan{Secrets: []PlanSecret{{Name: "admin"}}, Provisions: plan.Provisions}, true},
} {
if got := c.plan.Hands(); got != c.hands {
t.Errorf("%s: Hands() is %v, want %v", c.name, got, c.hands)
}
}
if want := Awaited(consumer, func(p string) []Manifest {
return map[string][]Manifest{"route": {proxy}, "postgres-database": {store}}[p]
}); !reflect.DeepEqual(want, []string{"route"}) {
t.Fatalf("the assigner waits for %v, and the plan keys the route alone", want)
}
}
+9 -6
View File
@@ -24,12 +24,15 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
if string(out) != `["changes",{"name":"moves","max-mib":2048}]` {
t.Fatalf("written back as %s", out)
}
// Issue 774's new words first: max-age-days is accepted and written back; nothing reads it yet.
aged, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
if out, _ := json.Marshal(aged.Logs); err != nil || aged.Logs[0].MaxAgeDays != 400 || string(out) != `[{"name":"acts","max-age-days":400}]` {
t.Fatalf("max-age-days read as %+v, written %s: %v", aged.Logs, out, err)
// 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it.
aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
if err != nil || aged.Logs[0].MaxAgeDays != 400 {
t.Fatalf("an aged log read as %+v, %v", aged.Logs, err)
}
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} {
if out, _ := json.Marshal(aged.Logs); string(out) != `[{"name":"acts","max-age-days":400}]` {
t.Fatalf("an aged log written back as %s", out)
}
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} {
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
}
@@ -48,7 +51,7 @@ func TestALogIsNamedLocallyAndCapped(t *testing.T) {
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`},
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
} {
_, err := ParseManifest([]byte(c.manifest))
+67 -25
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
@@ -287,18 +288,12 @@ type OfferCredential struct {
Own string `json:"own,omitempty"`
IssuedBy string `json:"issued-by,omitempty"`
Tool string `json:"tool,omitempty"`
// Form is the release's word for an offer that names no own secret and no issuer (novox/hq ADR 0570; issue 774's
// new words first): CredentialFormPair or CredentialFormNone. Accepted and read by nothing yet: an offer that says
// only its form is parsed as one that says no credential, which is a pair, as before.
// Form says, for an offer that names no own secret and no issuer, which it is: CredentialFormPair, a login of
// each consumer's own, or CredentialFormNone, no credential at all (the lead, 2026-10-29, for novox/hq ADR 0570):
// never left to a default.
Form string `json:"form,omitempty"`
}
// The forms an offer's credential may say by itself (OfferCredential.Form).
const (
CredentialFormWordPair = "pair"
CredentialFormWordNone = "none"
)
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
const IssuedBySoftware = "software"
@@ -372,8 +367,22 @@ const (
// CredentialFormGiven is given by a person (novox/hq ADR 0337): the offer's credential, or the own secret it
// shares, is issued outside the mesh; the mesh never makes it.
CredentialFormGiven = "given"
// CredentialFormNone is an offer whose consumers present no credential: its outcome is the provider's have, and
// the vault mints nothing for it.
CredentialFormNone = "none"
)
// CredentialForms is the one list of the credential forms a plan's entry may name (issue 774): every part that reads
// a plan checks a form here, so a form added above reaches each of them at once. Each part kept its own copy before,
// and a copy left without "none" refused every plan naming it.
func CredentialForms() []string {
return []string{CredentialFormShared, CredentialFormPair, CredentialFormIssued, CredentialFormGiven,
CredentialFormNone}
}
// IsCredentialForm is whether form is one of CredentialForms.
func IsCredentialForm(form string) bool { return slices.Contains(CredentialForms(), form) }
// CredentialFormOf is the credential form this module's offer of a provision declares, read from the offer's
// `credential` alone (issue 768): no new manifest field, since that field already says whether the provider shares
// one of its own secrets, its software issues it, or a person gives it, and saying none is a login per consumer.
@@ -385,7 +394,7 @@ func (m Manifest) CredentialFormOf(provision string) string {
}
switch c := o.Credential; {
case c == nil:
return CredentialFormPair
return "" // undeclared: refused at the catalogue (CredentialFormProblems), never read as a pair
case c.IssuedBy == IssuedOutside:
return CredentialFormGiven
case c.IssuedBy == IssuedBySoftware:
@@ -395,11 +404,33 @@ func (m Manifest) CredentialFormOf(provision string) string {
case c.Own != "":
return CredentialFormShared
}
return CredentialFormPair
return o.Credential.Form
}
return ""
}
// CredentialFormProblems refuses an offer that does not say its credential's form (the lead, 2026-10-29): an own
// secret, an issuer, or `form` pair or none, and `form` only where neither of the others is said.
func CredentialFormProblems(m Manifest) []string {
var problems []string
for _, o := range m.Provides {
c := o.Credential
switch {
case c == nil || (c.Own == "" && c.IssuedBy == "" && c.Form == ""):
problems = append(problems, fmt.Sprintf("%s provides %q and says no credential form: say `credential` with "+
"`form` pair (a login of each consumer's own) or none (no credential), or the own secret or issuer it is",
m.Module, o.Name))
case c.Form != "" && (c.Own != "" || c.IssuedBy != ""):
problems = append(problems, fmt.Sprintf("%s provides %q with `form` beside an own secret or an issuer; `form` "+
"says only pair or none", m.Module, o.Name))
case c.Form != "" && c.Form != CredentialFormPair && c.Form != CredentialFormNone:
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name,
c.Form, CredentialFormPair, CredentialFormNone))
}
}
return problems
}
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
func (m Manifest) ProvisionsSharing(own string) []string {
var out []string
@@ -1021,6 +1052,8 @@ type Bundle struct {
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
Binary string `json:"binary,omitempty"`
// Bus is the artifact's bus account, kept on the resolved manifest so the grant composition reads it (BusAccounts).
Bus *ProcessBus `json:"bus,omitempty"`
}
// Build says how to produce this module's artifacts from its source.
@@ -1158,10 +1191,10 @@ type Artifact struct {
// delivered version. A bundle that carried the package's name was delivered correctly, reported
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
Binary string `json:"binary,omitempty"`
// Bus is the release's word for a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; issue 774's new words
// first): accepted as it is written and read by nothing yet.
Bus json.RawMessage `json:"bus,omitempty"`
// Bus is a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): read as a process's
// (ProcessBus), a principal named after the bundle's binary (BusAccounts), its secret broker-<binary>. A tools bundle
// is run by the node's tool runtime per call, so it declares its account here and runs no idle service for it.
Bus *ProcessBus `json:"bus,omitempty"`
// URL is where an archive's upstream file is fetched from, for an archive that is a release
// somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6).
@@ -1783,6 +1816,10 @@ func (m Manifest) marshalFields() ([]byte, error) {
return json.Marshal(keys)
}
// CredentialProvisionPrefix starts the name of a provision's credential, the vault's provision beside it (novox/hq ADR
// 0570 clause 6): `secret-<provision>`.
const CredentialProvisionPrefix = "secret-"
// SecretFile is one file a module is given a credential in: the local name it goes by inside
// the module (empty for the ordinary one-file case, where the requirement's name serves) and where.
type SecretFile struct {
@@ -1940,11 +1977,17 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
"%s says its instances are %q; the one word is %q, for a module that is the same on every machine",
m.Module, m.Instances, InstancesInterchangeable))
}
for i, offer := range m.Provides {
for _, offer := range m.Provides {
p := offer.Name
if !name.MatchString(p) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
}
// A provision's credential is a provision of its own, `secret-<provision>` (novox/hq ADR 0570 clause 6, the
// lead 2026-10-29): no module provides a name of that form, so the two keys never meet.
if strings.HasPrefix(p, CredentialProvisionPrefix) {
problems = append(problems, fmt.Sprintf("%s provides %q; a name starting %q is the mesh's for a provision's "+
"credential, kept by the vault (novox/hq ADR 0570)", m.Module, p, CredentialProvisionPrefix))
}
// A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it
// leaves room for the shortest identity the mesh makes, or it would refuse every consumer.
if id := offer.Identity; id != nil && !id.None {
@@ -1993,15 +2036,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
} else if offer.Credential != nil {
own, declared := m.OwnSecrets[offer.Credential.Own]
switch {
case offer.Credential.Own == "" && offer.Credential.IssuedBy == "" && offer.Credential.Tool == "" &&
offer.Credential.Form != "":
// The form alone (issue 774's new words first): pair or none, accepted and meaning what no credential
// meant before, a pair (the offer is read as saying none).
if f := offer.Credential.Form; f != CredentialFormWordPair && f != CredentialFormWordNone {
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, p, f,
CredentialFormWordPair, CredentialFormWordNone))
}
m.Provides[i].Credential = nil
case offer.Credential.Own == "" && offer.Credential.Form != "":
// The form alone, pair or none (CredentialFormProblems judges it).
case offer.Credential.Own == "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential that names no own secret", m.Module, p))
@@ -2110,6 +2146,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
problems = append(problems, declaredSeatProblems(m)...)
problems = append(problems, RunsAsProblems(m)...)
problems = append(problems, ProcessBusProblems(m)...)
problems = append(problems, seatEventProblems(m)...)
problems = append(problems, ReadsHolderProblems(m)...)
problems = append(problems, FilesHolderProblems(m)...)
if m.Computed != "" && len(m.Resources) > 0 {
@@ -3341,6 +3378,11 @@ func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string
// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for.
func (m Manifest) shipsABundle() bool {
// A built manifest says its bundles in Bundles, its build read away (Resolve; issue 774: nats's built manifest was
// refused for the emits its tools bundle says).
if len(m.Bundles) > 0 {
return true
}
if m.Build == nil {
return false
}
+187 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
)
@@ -29,6 +30,191 @@ type ProcessBus struct {
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
// seat:<seat>.<verb> (as Manifest.Invokes), or * for every tool on the mesh.
Invokes []string `json:"invokes,omitempty"`
// Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR
// 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process.
Removes []string `json:"removes,omitempty"`
}
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: the controller's consumer on
// DEAD_LETTER_NOTICES (the lease write's step, clause 5), and keys of the node-engine's acts bucket. A further consumer
// or bucket is a change of this set, through a decision.
const (
RemovesControllerDeadLetterConsumer = "DEAD_LETTER_NOTICES.controller"
RemovesEngineActs = "mesh-host_acts"
// RemovesAssignmentsStream is the stream ASSIGNMENTS, removed whole at the lease write (novox/hq research 072 row 33):
// its 350 messages have no consumer, and the placement is the provisioner's from then on.
RemovesAssignmentsStream = "ASSIGNMENTS"
)
// BusRemovals is the closed set of what a bus account may remove.
var BusRemovals = []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream}
// BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564
// Limits 4), by its id (the process resource's, or the bundle's binary), with what its bus says.
type BusAccount struct {
ID string
Bus ProcessBus
// Tools says it is a tools bundle's, run by the node's tool runtime, not a process the node runs.
Tools bool
}
// BusAccounts is every own bus account of the module, processes first in resource order, then tools bundles in artifact
// order: what the grant composition composes a principal for, each delivered in its own secret broker-<id>. A tools
// bundle's is read from the repository's build, or from the resolved manifest's bundles.
func (m Manifest) BusAccounts() []BusAccount {
var out []BusAccount
buses := m.ProcessBuses()
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
if b, ok := buses[id]; ok && fmt.Sprint(r["type"]) == "process" {
out = append(out, BusAccount{ID: id, Bus: b})
}
}
seen := map[string]bool{}
if m.Build != nil {
for _, a := range m.Build.Artifacts {
if a.Bus != nil && a.Kind == ArtifactBundle && BinaryOf(a) != "" && !seen[BinaryOf(a)] {
seen[BinaryOf(a)] = true
out = append(out, BusAccount{ID: BinaryOf(a), Bus: *a.Bus, Tools: true})
}
}
}
for _, b := range m.Bundles {
if b.Bus != nil && b.Binary != "" && !seen[b.Binary] {
seen[b.Binary] = true
out = append(out, BusAccount{ID: b.Binary, Bus: *b.Bus, Tools: true})
}
}
return out
}
// ToolsBusSecret says name is the own secret a tools bundle's bus account is held in: `broker-<binary>` for a bundle
// whose bus the manifest declares (ADR 0564 Limits 4). Its file belongs to the runtime's account, which runs the bundle.
func ToolsBusSecret(m Manifest, name string) bool {
for _, a := range m.BusAccounts() {
if a.Tools && name == "broker-"+a.ID {
return true
}
}
return false
}
// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared
// as an own secret `broker-<binary>`, at `${dir:<id>}/broker-<binary>` for a directory resource every parent of which the
// runtime's account may pass (passableDir); the file itself is 0600 and the runtime account's.
func toolsSecretProblems(m Manifest) []string {
var problems []string
for _, a := range m.BusAccounts() {
if !a.Tools {
continue
}
name := "broker-" + a.ID
own, declared := m.OwnSecrets.Plain()[name]
if !declared {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+
"account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name))
continue
}
dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}")
if !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") || !passableDir(m, dir, 0) {
problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s, which the node's tool runtime cannot "+
"reach: a tools bundle's account lies in a directory resource the runtime's account may pass, every parent "+
"included, such as {\"id\":\"tools-secret\",\"type\":\"directory\",\"path\":\"%s/%s-tools\",\"mode\":\"0711\"} "+
"with the secret at ${dir:tools-secret}/%s; never under the module's own or mesh place, which are its "+
"own (novox/hq ADR 0564 Limits 4)", m.Module, name, own.Path, meshStateRoot, m.Module, name))
}
}
return problems
}
// meshStateRoot is the mesh's directory of every module's mesh place on a node of the default data root: root's, mode
// 0755, so the runtime's account passes it, while each module's own place beneath it is 0700.
const meshStateRoot = defaultDataRoot + "/" + placeMesh
// passableDir says the runtime's account may pass the directory resource id and every parent the mesh makes for it: its
// own mode lets others pass (the last digit odd), and it is either stated directly under meshStateRoot or beneath a
// placed directory that is passable in turn. A pathless directory resolves under the module's own root or its mesh
// place, which are the module's own, so it never is.
func passableDir(m Manifest, id string, depth int) bool {
if depth > 4 {
return false
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != id {
continue
}
mode, _ := r["mode"].(string)
if mode == "" || !strings.ContainsAny(mode[len(mode)-1:], "1357") {
return false
}
path, _ := r["path"].(string)
switch {
case strings.HasPrefix(path, "${dir:"):
parent, _, _ := strings.Cut(strings.TrimPrefix(path, "${dir:"), "}")
return passableDir(m, parent, depth+1)
case strings.HasPrefix(path, "/"):
return filepathDir(strings.TrimRight(path, "/")) == meshStateRoot
}
return false
}
return false
}
func filepathDir(p string) string {
if i := strings.LastIndex(p, "/"); i > 0 {
return p[:i]
}
return "/"
}
// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle
// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a
// module that does not hold mesh-broker. Every other field is judged as a process's.
func toolsBusProblems(m Manifest) []string {
var problems []string
var artifacts []Artifact
if m.Build != nil {
artifacts = m.Build.Artifacts
}
claimsBroker := false
for _, c := range m.Claims {
claimsBroker = claimsBroker || c.Name == "mesh-broker"
}
for _, a := range artifacts {
if a.Bus == nil {
continue
}
if a.Kind != ArtifactBundle || BinaryOf(a) == "" {
problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+
"binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
continue
}
b := *a.Bus
if len(b.Queues) > 0 || b.Asks {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+
"bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
}
for _, r := range b.Removes {
if !slices.Contains(BusRemovals, r) {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+
"(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", ")))
}
}
if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+
"module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
}
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "process" {
continue
}
if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 {
problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+
"may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"]))
}
}
return problems
}
// UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or
@@ -87,7 +273,7 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus {
// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or
// seat verb's shape on invokeProblems.
func ProcessBusProblems(m Manifest) []string {
var problems []string
problems := append(toolsBusProblems(m), toolsSecretProblems(m)...)
streams := map[string]bool{}
for _, s := range m.Streams {
streams[s.Name] = true
+59
View File
@@ -47,6 +47,65 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) {
!b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) {
t.Fatalf("the bus read as %+v", b)
}
// 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a
// process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the
// bus module's (mesh-broker) alone.
toolsIn := func(claims, bus, mode, where string) string {
return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` +
`"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` +
`"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode +
`","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` +
`"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}`
}
tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") }
broker := `[{"name":"mesh-broker","scope":"mesh"}]`
for _, c := range []struct {
name, manifest, refused string
}{
{name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts","ASSIGNMENTS"]}`)},
{name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"},
{name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"},
{name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"},
{name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"},
{name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"},
// The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29).
{name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700",
"/var/lib/mesh/nats-tools"), refused: "cannot reach"},
// Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29).
{name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711",
"${dir:mesh-state}/tools"), refused: "cannot reach"},
} {
m, err := ParseManifest([]byte(c.manifest))
if c.refused != "" {
if err == nil || !strings.Contains(err.Error(), c.refused) {
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
}
continue
}
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
accounts := m.BusAccounts()
if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools ||
!strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream}) {
t.Errorf("%s: the accounts are %+v", c.name, accounts)
}
// Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay
// its own.
if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" ||
(Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" {
t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools"))
}
// Resolved, the build goes and its bundles keep the bus (build.go).
resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}}
if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" {
t.Errorf("%s: resolved, the accounts are %+v", c.name, got)
}
}
if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools {
t.Errorf("the process's account is %+v", got)
}
}
func strSliceEqual(a, b []string) bool {
+4
View File
@@ -263,6 +263,10 @@ type Needed struct {
// then name `${bound:<provision>:<key>}` and `${secret:<provision>}`, which its machine's client fills from the
// provider's entry and the vault. Set by the plan, which reads the provider's manifest.
ProviderMoved bool
// AnsweredByHelper is set when the providing module's offer says the provider helper answers it (issue 792): a
// provision the seat mesh-provisioner keys whether or not its consumer is handed (Keyed). Set by the plan, which
// reads the provider's manifest.
AnsweredByHelper bool
}
// Refusal is why a set of assignments cannot become a declaration.
+22 -4
View File
@@ -124,7 +124,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
runtime = &r.Modules[i]
}
}
if runtime == nil {
if runtime == nil || runtime.Stubbed {
// Held (its stand-in, Stubbed): its process is never applied, what runs of it is carried, and its stand-in is
// never refused by a rule about its bundle or its broker (issue 774: node-tools held for a build not done, then
// "its build produced 0 bundle(s)", refused every module of ace, g14 and shanks).
return nil, nil
}
if len(runtime.Bundles) != 1 {
@@ -240,6 +243,16 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
return process, nil
}
// NoRuntimeToRunError is a module of its own account with no runtime program on its node to run it from. Held says the
// runtime is there and held (its stand-in, Stubbed): the module cannot compose because the runtime does not, so a
// composition that holds the runtime holds it too, never the node (issue 774).
type NoRuntimeToRunError struct {
Held bool
said string
}
func (e *NoRuntimeToRunError) Error() string { return e.said }
// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8).
func OwnRuntimeID() string { return "own-runtime" }
@@ -250,7 +263,11 @@ func OwnRuntimeID() string { return "own-runtime" }
func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
var own []Manifest
for _, m := range r.Modules {
if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) {
// A held module's stand-in (Stubbed) has no runtime of its own composed, and so none of this function's rules
// about one: none of its composition is applied, and what runs of it is carried as it runs. Refusing it here
// refused its whole node (issue 774, 2026-10-30 21:04: mesh-self-check, held for its broker not made, then
// "runs as its own account and declares no own secret broker", failed 74 modules on ace and 79 on novox).
if m.RunsAs != "" && !m.Stubbed && !(with.Adopted && m.Filtering != nil) {
own = append(own, m)
}
}
@@ -264,8 +281,9 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
}
}
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+
"from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node)
return nil, &NoRuntimeToRunError{Held: runtime != nil && runtime.Stubbed, said: fmt.Sprintf("%s runs as its own "+
"account in a runtime of its own, and %s is not here to run it from: assign %s to %s first (novox/hq ADR 0259)",
own[0].Module, RuntimeModule, RuntimeModule, r.Node)}
}
program := runtime.Bundles[0]
var out []map[string]any
+29 -1
View File
@@ -155,7 +155,9 @@ var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// ReadsGranted is the state a holder reads because modules on its machine offer it in pieces of a
// kind its seat receives as data (novox/hq ADR 0260), each with the one key the piece shows — the
// machine's own when it names none — sorted and once each. The bus grants the holder these keys and
// machine's own when it names none — sorted and once each. A module's items of such a kind (design 57 L3) grant
// what they show too, always at the machine's own key; an item whose capability the machine lacks grants a key
// that is never written, which is no wider than its own machine's. The bus grants the holder these keys and
// no others: the holder names no contributor, and reads only what is offered to it where it runs.
func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []StateRead {
seen := map[StateRead]bool{}
@@ -183,6 +185,21 @@ func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []State
}
}
}
// A moved contributor's items of the kind (design 57 L3): what each shows, read from its attributes as a
// contribution's data, and always this machine's own key (ADR 0260), never another's (issue 774:
// power's draw block became an item and i3status-rust was issued nothing).
for _, it := range m.Items {
if it.Collection != "contribution:"+s.Name+":"+r.Kind {
continue
}
for _, sr := range r.Shape.Reads(itemData(it)) {
sr.Key = machine
if module, _, err := ReadState(sr.Read); err == nil && module == m.Module && module != holder.Module &&
stateKeyName.MatchString(sr.Key) {
seen[sr] = true
}
}
}
}
}
}
@@ -199,6 +216,17 @@ func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []State
return out
}
// itemData is an item's attributes in the shape of a contribution's data: each at the top and among the options, where
// a shape's Reads looks for what a piece shows (barBlockReads: shows, options.state).
func itemData(it Item) map[string]any {
data, options := map[string]any{}, map[string]any{}
for k, v := range it.Attributes {
data[k], options[k] = v, v
}
data["options"] = options
return data
}
// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float.
func wholeIn(v any, lo, hi int) (int, bool) {
f, ok := v.(float64)
+10
View File
@@ -394,6 +394,16 @@ func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *tes
if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 {
t.Fatalf("a holder alone is granted %v", got)
}
// Issue 774 (design 57 L3): the block as an item grants the same read, at this machine's own key alone, even where
// the item names another machine's.
for _, attrs := range []map[string]string{{"shows": "state", "state": "power.draw"}, {"shows": "state", "state": "power.draw", "key": "other"}} {
moved := Manifest{Module: "power", State: []StateDeclaration{{Name: "draw"}}, Items: []Item{{
Collection: "contribution:node-bar:block", Name: "power-draw", IfCapability: "power-meter", Attributes: attrs}}}
if got := ReadsGranted(barHolder(), []Manifest{barHolder(), moved}, "laptop"); len(got) != 1 ||
got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) {
t.Errorf("the holder is granted %v by the item %v", got, attrs)
}
}
}
func TestAModuleOffersItsOwnStateOnly(t *testing.T) {
+93
View File
@@ -0,0 +1,93 @@
package catalogue
import (
"fmt"
"slices"
"strings"
)
// A seat verb a module invokes is one some holder serves (issue 774, sweep item 1). messenger invoked
// `seat:node-engine.root-free`, which nothing in the mesh answered, so every ask that offers an approval was held on
// Telegram, silently. A manifest names what it calls (novox/hq ADR 0152); that a seat by that name exists, promises
// that verb, and has a holder is only known over the whole catalogue, so it is judged here, beside the seats' other
// rules (CatalogueProblems).
// UnheldSeat ends the problem of a `seat:<seat>.<verb>` whose seat no module given claims: over the whole catalogue a
// refusal, over some manifests alone a seat whose holder was not given (IsUndeclaredSeat says both).
const UnheldSeat = "which no module claims, so nothing answers it"
// SeatInvoke splits an invoke `seat:<seat>.<verb>` into its seat and verb; ok is false for anything else (a module's
// tool, `*`).
func SeatInvoke(invoke string) (seat, verb string, ok bool) {
rest, isSeat := strings.CutPrefix(invoke, "seat:")
if !isSeat {
return "", "", false
}
seat, verb, ok = strings.Cut(rest, ".")
return seat, verb, ok && seat != "" && verb != ""
}
// seatInvokeProblems are the `seat:<seat>.<verb>` a module or one of its processes invokes that nothing answers: a seat
// no module declares and the mesh does not define, a verb the seat does not promise, or a seat with no holder (a
// seat a module declares and no module claims; a mesh seat's holder, the mesh itself or a module of any repository,
// is not judged).
func seatInvokeProblems(shelf Shelf, declared map[string]SeatDeclaration) []string {
claimed := map[string]bool{}
for _, m := range shelf {
for _, c := range m.Claims {
claimed[c.Name] = true
if s, ok := SeatNamed(c.Name); ok {
claimed[s.Name] = true
}
}
}
var problems []string
for _, module := range shelfOrder(shelf) {
m := shelf[module]
for _, invoke := range invokesOf(m) {
seat, verb, ok := SeatInvoke(invoke)
if !ok {
continue
}
var verbs []Verb
held := claimed[seat]
if s, isMesh := SeatNamed(seat); isMesh {
// A mesh seat is held by the mesh itself or by a module of any repository: one repository's catalogue
// cannot say it has no holder, so only its verbs are judged (the lead, 2026-10-30: mesh-control's check
// runs over the captured mesh-catalog alone, whose mesh-self-check calls the watchman's seat).
verbs, held = s.Serves, true
} else if d, isModule := declared[seat]; isModule {
verbs = d.Serves
} else {
problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UndeclaredSeat, module, invoke, seat))
continue
}
if !slices.ContainsFunc(verbs, func(v Verb) bool { return v.Name == verb }) {
problems = append(problems, fmt.Sprintf("%s invokes %s, and the seat %s serves no verb %q", module, invoke, seat, verb))
continue
}
if !held {
problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UnheldSeat, module, invoke, seat))
}
}
}
return problems
}
// invokesOf is everything a module says it invokes: its own and each process's bus, once each.
func invokesOf(m Manifest) []string {
out := append([]string(nil), m.Invokes...)
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "process" {
continue
}
if _, present := r["bus"]; !present {
continue
}
if b, _, err := processBusOf(r); err == nil {
out = append(out, b.Invokes...)
}
}
slices.Sort(out)
return slices.Compact(out)
}
+12 -1
View File
@@ -272,7 +272,9 @@ type Shelf map[string]Manifest
const UndeclaredSeat = "which no module declares and the mesh does not define"
// IsUndeclaredSeat says whether a problem CatalogueProblems gave is a seat no manifest given declares.
func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) }
func IsUndeclaredSeat(problem string) bool {
return strings.HasSuffix(problem, UndeclaredSeat) || strings.HasSuffix(problem, UnheldSeat)
}
func CatalogueProblems(shelf Shelf) []string {
var problems []string
@@ -282,6 +284,12 @@ func CatalogueProblems(shelf Shelf) []string {
problems = append(problems, RestrictedHearingProblems(shelf)...)
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...)
// Every offer says its credential's form, never left to a default (the lead, 2026-10-29, for novox/hq ADR 0570).
for _, module := range shelfOrder(shelf) {
problems = append(problems, CredentialFormProblems(shelf[module])...)
// And its data in the bus's predefined forms, a stream of its own refused (the lead, 2026-10-29).
problems = append(problems, PredefinedFormProblems(shelf[module])...)
}
// Who declares what, and who declared it first.
declaredBy := map[string]string{}
@@ -343,6 +351,9 @@ func CatalogueProblems(shelf Shelf) []string {
}
}
// A seat verb a module invokes is one some holder serves (issue 774).
problems = append(problems, seatInvokeProblems(shelf, declared)...)
// Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses.
named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy)
problems = append(problems, pathProblems...)
+38 -7
View File
@@ -51,12 +51,43 @@ func TestTwoModulesCannotDeclareTheSameSeat(t *testing.T) {
}
// Where ADR 0110's guarantee lands under a derived set: a typo is refused, not resolved to
// nothing at runtime.
// nothing at runtime. And a seat verb a module invokes is one a holder serves (issue 774, sweep item 1: messenger
// invoked a verb nothing answered, and Telegram held every ask silently).
func TestUsingASeatNobodyDeclaresIsRefused(t *testing.T) {
shop := Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}
got := problemsFor(t, Shelf{"telegram": telegram(), "shop": shop})
if !strings.Contains(got, "telegram-sendr") || !strings.Contains(got, "no module declares") {
t.Fatalf("a misspelled seat was accepted: %s", got)
unclaimed := telegram()
unclaimed.Claims = nil
for _, row := range []struct {
name, want string
shop Manifest
seat Manifest
note bool // over some manifests alone it is a note (IsUndeclaredSeat)
}{
{"a misspelled seat used", `uses the seat "telegram-sendr", ` + UndeclaredSeat,
Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}, telegram(), true},
{"a declared verb invoked of a held seat", "", Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, telegram(), false},
{"a verb the seat does not promise", `the seat telegram-sender serves no verb "resend"`,
Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.resend"}}, telegram(), false},
{"a verb of a misspelled seat", `a verb of the seat "telegram-sendr", ` + UndeclaredSeat,
Manifest{Module: "shop", Invokes: []string{"seat:telegram-sendr.status"}}, telegram(), true},
{"a verb of a declared seat nobody claims", `a verb of the seat "telegram-sender", ` + UnheldSeat,
Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, unclaimed, true},
{"a verb of a mesh seat whose holder lives in another repository", "",
Manifest{Module: "shop", Invokes: []string{"seat:mesh-watchdogs.signals"}}, telegram(), false},
{"a verb a mesh seat does not promise", `the seat mesh-controller serves no verb "root-freed"`,
Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-freed"}}, telegram(), false},
{"the controller's verb: the controller answers its seat", "",
Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-free"}}, telegram(), false},
{"the engine's verb: every node's engine holds its seat", "",
Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-free"}}, telegram(), false},
{"a verb the engine's seat does not promise", `the seat node-engine serves no verb "root-freed"`,
Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-freed"}}, telegram(), false},
} {
got := CatalogueProblems(Shelf{"telegram": row.seat, "shop": row.shop})
joined := strings.Join(got, "; ")
if row.want == "" && joined != "" || row.want != "" && (len(got) != 1 || !strings.Contains(joined, row.want) ||
IsUndeclaredSeat(got[0]) != row.note) {
t.Errorf("%s: %s", row.name, joined)
}
}
}
@@ -109,7 +140,7 @@ func TestAClaimMustMatchTheDeclaredScope(t *testing.T) {
func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
// It delivers the bus, so its holder provides the bus — the rule this check now enforces.
m := Manifest{Module: "nats",
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormNone}}},
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
if got := problemsFor(t, Shelf{"nats": m}); got != "" {
t.Fatalf("a mesh seat was refused by the derived check: %s", got)
@@ -186,7 +217,7 @@ func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
// A store whose bus seat delivers something this module does provide.
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
raw := []byte(`{"module":"a-bus","version":"1",` +
`"provides":[{"name":"mesh-bus","scope":"mesh"}],` +
`"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` +
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
m, err := ParseManifest(raw)
+13 -1
View File
@@ -98,7 +98,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil {
t.Errorf("acts takes no id: %v", engine.Serves[5].Input)
}
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages" ||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose" ||
strings.Join(engine.Emits, ",") != "asked,done,refused" {
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
}
@@ -188,6 +188,18 @@ func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) {
if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 {
t.Fatalf("a claim on a declared seat was refused: %v", problems)
}
// The second of a kind (issue 1952; the lead, 2026-10-29): a holder that lists its seat's event among its own emits
// would say it where no consumer hears it, so the manifest is refused, naming the seat's subject; the seat's
// holder saying nothing of its own is accepted.
holder := func(emits string) []byte {
return []byte(`{"module":"mesh-self-check","version":"1","emits":` + emits + `,"claims":[{"name":"mesh-self-check","scope":"mesh","serves":["doctor","probes"]}]}`)
}
if _, err := ParseManifest(holder(`["checked"]`)); err == nil || !strings.Contains(err.Error(), "mesh.seat.mesh-self-check.event.checked") {
t.Errorf("a seat's event listed as the module's own: %v", err)
}
if _, err := ParseManifest(holder(`[]`)); err != nil {
t.Errorf("a holder saying its seat's event on the seat was refused: %v", err)
}
}
// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing
+4
View File
@@ -59,6 +59,10 @@ func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, e
for name := range m.OwnSecrets.Plain() {
if value := with.Needed[m.Module][name]; value != "" {
sealed[name] = value
} else if with.Foreseen[m.Module][name] {
// A stand-in where its own file has one (the Foreseeing and Planning modes, issue 774): a file naming it
// composes as its own file does, never refused for a value no such composition reads.
sealed[name] = ForeseenSealed
}
}
for _, to := range m.SecretRequirements() {
+35 -27
View File
@@ -21,34 +21,42 @@ func fileNamed(out []map[string]any, id string) map[string]any {
return nil
}
// A module's own credential, put into a file it wrote.
// A module's own credential, put into a file it wrote: the value sealed to the node, or, in a composition asked ahead of
// the send or for a plan alone, the stand-in its own file has (issue 774: baserow's config.json refused ace's plans).
func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) {
r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea",
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "[security]\nSECRET_KEY = ${secret:admin}\n",
}},
}}}
out, err := r.Declaration(Rendering{
Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}},
})
if err != nil {
t.Fatal(err)
}
file := fileNamed(out, "gitea.conf")
if file == nil {
t.Fatalf("no file in %v", out)
}
got, _ := file["secrets"].(map[string]any)
if got["admin"] != "sealed-admin" {
t.Errorf("the file was sent without its secret: %v", file)
}
// The hole is still a hole on the wire. The mesh does not fill it in; the host does, on the
// machine, which is the only place both halves exist.
if !strings.Contains(file["content"].(string), "${secret:admin}") {
t.Errorf("the mesh substituted it itself: %v", file["content"])
for _, row := range []struct {
name string
with Rendering
want string
}{
{"sealed", Rendering{Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}}}, "sealed-admin"},
{"foreseen", Rendering{Foreseen: map[string]map[string]bool{"gitea": {"admin": true}}}, ForeseenSealed},
} {
r := Resolution{Node: "anchor", Modules: []Manifest{{
Module: "gitea",
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
"content": "[security]\nSECRET_KEY = ${secret:admin}\n",
}},
}}}
out, err := r.Declaration(row.with)
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
file := fileNamed(out, "gitea.conf")
if file == nil {
t.Fatalf("%s: no file in %v", row.name, out)
}
got, _ := file["secrets"].(map[string]any)
if got["admin"] != row.want {
t.Errorf("%s: the file was sent without its secret: %v", row.name, file)
}
// The hole is still a hole on the wire. The mesh does not fill it in; the host does, on the
// machine, which is the only place both halves exist.
if !strings.Contains(file["content"].(string), "${secret:admin}") {
t.Errorf("%s: the mesh substituted it itself: %v", row.name, file["content"])
}
}
}
+29 -1
View File
@@ -48,7 +48,7 @@ var ReservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
// package cannot import; a test keeps them agreeing.
var MeshStreamNames = []string{"CONTROL", "NODES", "ASSIGNMENTS", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
var MeshStreamNames = []string{"CONTROL", "NODES", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
// StreamDeclaration is one stream a module owns.
type StreamDeclaration struct {
@@ -630,3 +630,31 @@ func uncovered(patterns []string) []string {
slices.Sort(out)
return out
}
// **A module keeps its data in the bus's predefined forms** (the lead and the operator, 2026-10-29; the operator's rule of
// 2026-10-22, "data storage is done using our predefined nats bus options"): a state, a log or an object store, which the
// runtime reads and writes for its tools and other modules read by a declared `reads`. A stream of its own is outside
// them and refused, naming it. StreamsKeptUntilMoved are the streams declared before this check, each allowed by name
// until its module moves it; the list only shrinks.
var StreamsKeptUntilMoved = map[string][]string{
"brother": {"device-asks"},
"mesh-build-go": {"toolchains-go"},
"mesh-build-node": {"toolchains-node"},
"mesh-mail": {"mail", "mail-sends"},
"only-office": {"convert-asks"},
"mesh-delivery-assigner": {"placement"},
"mesh-delivery-checker": {"checks"},
"mesh-delivery-forge-bridge": {"forge"},
}
// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it.
func PredefinedFormProblems(m Manifest) []string {
var out []string
for _, s := range m.Streams {
if !slices.Contains(StreamsKeptUntilMoved[m.Module], s.Name) {
out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+
"a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name))
}
}
return out
}
+8
View File
@@ -96,6 +96,14 @@ func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 {
t.Fatalf("one owner refused: %v", got)
}
// And a stream of its own is outside the bus's predefined forms (the lead, 2026-10-29): refused, naming it, unless it
// is one kept by name until its module moves it.
if !strings.Contains(got, `a keeps the stream "mail", outside the bus's predefined forms`) {
t.Fatalf("a stream of its own was not refused: %s", got)
}
if got := PredefinedFormProblems(Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 {
t.Fatalf("a stream kept until it moves was refused: %v", got)
}
}
// The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4).
+44
View File
@@ -3,6 +3,7 @@ package catalogue
import (
"fmt"
"strings"
"time"
)
// What the mesh does when a module's build moves (novox/hq ADR 0236, extending ADR 0162 §3 and ADR
@@ -118,3 +119,46 @@ func (m Manifest) irreplaceable() string {
}
return ""
}
// PersonPolicy is a person's choice of a module's upgrade policy, as the assigner keeps it in its state `policies`, one
// key per module (novox/hq ADR 0565 clause 7, issue 1953 L5-S2): the policy, why, who as the runtime proved the caller,
// and when. Set and taken back through `mesh-wants.upgrade`, each recorded as a hand act.
type PersonPolicy struct {
V int `json:"v"`
Module string `json:"module"`
Policy string `json:"policy"`
Why string `json:"why"`
By string `json:"by"`
At time.Time `json:"at"`
}
// PersonPolicyVersion is the version of a person's policy's layout.
const PersonPolicyVersion = 1
// Check refuses a choice the assigner could not keep: a policy that is not roll, together or record, one other than
// roll without why, or one with no person.
func (p PersonPolicy) Check() error {
switch {
case p.Module == "":
return fmt.Errorf("a person's policy names its module")
case p.Policy != PolicyRoll && p.Policy != PolicyTogether && p.Policy != PolicyRecord:
return fmt.Errorf("%s: a policy is %q, %q or %q, not %q", p.Module, PolicyRoll, PolicyTogether, PolicyRecord, p.Policy)
case strings.TrimSpace(p.Why) == "":
return fmt.Errorf("%s: a person's policy says why", p.Module)
case strings.TrimSpace(p.By) == "":
return fmt.Errorf("%s: a person's policy names who chose it", p.Module)
}
return nil
}
// Upgrade is a module's policy and where it comes from (ADR 0565 clause 7): the one derivation every reader takes,
// `mesh-wants.upgrade` and each node agent's judgement of a move alike. A person's choice over the module's word, its
// irreplaceable data and the default; the bus is never rolled, whoever says so (ADR 0236). By names the person when one
// chose.
func Upgrade(m Manifest, chosen *PersonPolicy) (policy, from, why, by string) {
policy, from, why = DerivedUpgrade(m)
if from == FromBus || chosen == nil {
return policy, from, why, ""
}
return chosen.Policy, FromPerson, chosen.Why, chosen.By
}
+21 -4
View File
@@ -32,11 +32,28 @@ func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) {
if policy != c.policy || from != c.from {
t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from)
}
// ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the
// derivation as it was.
person := &PersonPolicy{V: PersonPolicyVersion, Module: c.m.Module, Policy: PolicyTogether, Why: "one zone", By: "person.jochen"}
policy, from, _, by := Upgrade(c.m, person)
wantPolicy, wantFrom, wantBy := PolicyTogether, FromPerson, "person.jochen"
if c.from == FromBus {
wantPolicy, wantFrom, wantBy = c.policy, FromBus, ""
}
if policy != wantPolicy || from != wantFrom || by != wantBy {
t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by)
}
if policy, from, _, _ := Upgrade(c.m, nil); policy != c.policy || from != c.from {
t.Errorf("%s with no choice: %s from %s", c.name, policy, from)
}
}
}
// A policy other than roll says why, and an unknown one is refused.
func TestAnUpgradePolicySaysWhy(t *testing.T) {
for _, p := range []PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: PolicyRecord, By: "p"},
{Module: "m", Policy: PolicyRecord, Why: "x"}} {
if p.Check() == nil {
t.Errorf("%+v was accepted", p)
}
}
// A policy other than roll says why, and an unknown one is refused.
for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} {
if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 {
t.Errorf("%+v was accepted", u)
+3 -2
View File
@@ -32,6 +32,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/kvlimit"
)
func main() {
@@ -190,7 +191,7 @@ func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string
if err != nil {
return fmt.Errorf("the bucket %s: %w", bucket, err)
}
if _, err := composed.Put(ctx, key, encoded); err != nil {
if _, err := kvlimit.Put(ctx, composed, key, encoded); err != nil {
return fmt.Errorf("%s in %s: %w", key, bucket, err)
}
value, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot,
@@ -202,7 +203,7 @@ func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string
if err != nil {
return fmt.Errorf("the bucket %s: %w", delivery.ControllerComposedBucket, err)
}
if _, err := digests.Put(ctx, key, value); err != nil {
if _, err := kvlimit.Put(ctx, digests, key, value); err != nil {
return fmt.Errorf("%s in %s: %w", key, delivery.ControllerComposedBucket, err)
}
return nil
+116
View File
@@ -130,6 +130,22 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
n.Kept = kept
}
}
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
for i := range reading.Nodes {
n := &reading.Nodes[i]
if n.Name != node || n.RunsTools == nil {
continue
}
kept := make(map[string]catalogue.Manifest, len(n.Kept))
for m, man := range n.Kept {
if man.Stubbed && man.RunsAs == "" {
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
}
kept[m] = man
}
n.Kept = kept
}
isCarried := func(module, id string) bool {
if module == "" {
module, _ = declaration.ModuleGuess(id)
@@ -293,6 +309,106 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
return false
}
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
type resource struct {
ID string `json:"id"`
Module string `json:"module"`
Type string `json:"type"`
Path string `json:"path"`
Source string `json:"source"`
Digest string `json:"digest"`
Env map[string]string `json:"env"`
}
var env map[string]string
out := map[string][]catalogue.Bundle{}
for _, c := range running {
for _, raw := range c.Resources {
var r resource
if json.Unmarshal(raw, &r) != nil {
continue
}
owner := r.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(r.ID)
}
switch {
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
env = r.Env
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
if r.Path == catalogue.BundlePath(owner, name) {
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
}
}
}
}
if env == nil {
return nil
}
var words map[string]map[string]string
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
module, path, ok := strings.Cut(entry, "=")
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
continue
}
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
bundles := out[module]
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
if !ok || i < 0 {
continue // no archive of it runs: nothing to load
}
var own map[string]string
for w, v := range words[module] {
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
continue // the mesh's to say, composed again
}
if own == nil {
own = map[string]string{}
}
own[w] = v
}
bundles[i].Loads = append(bundles[i].Loads, load)
bundles[i].Env = own
}
for _, bundles := range out {
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
}
return out
}
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
var out []catalogue.Bundle
for _, b := range runs {
if len(b.Loads) == 0 {
for _, built := range man.Bundles {
if built.Name == b.Name {
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
}
}
if man.Build != nil {
run := catalogue.RunByAResource(man)
for _, a := range man.Build.Artifacts {
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
}
}
}
}
out = append(out, b)
}
return out
}
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
// alone (issue 1126).
+86
View File
@@ -8,6 +8,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
@@ -96,6 +97,91 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
t.Errorf("a node the mesh does not have was composed")
}
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
// held module's new build's bundles are not delivered.
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
store := catalogue.Manifest{Module: "artifacts", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
if err != nil {
t.Fatal(err)
}
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
runtimeRuns := func(dropped bool) delivery.Composed {
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
if dropped {
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
}
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
}
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
for _, row := range []struct {
name string
set func(n *Node) delivery.Composed // and what runs of lamp
fps map[string]string
dropped bool
}{
{"folded at a build of the old form", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, false},
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, true},
{"held, its new build not delivered", func(n *Node) delivery.Composed {
n.Held = map[string]bool{"lamp": true}
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
return lr
}, map[string]string{"lamp": fpFor('1')}, false},
} {
tools := aMesh(t)
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
anchor := nodeOf(t, &tools, "anchor")
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
anchor.SealingKey = keyOf(t, "anchor")
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
var env map[string]any
for _, c := range out {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Env map[string]any `json:"env"`
}
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
env = r.Env
}
}
}
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
}
}
}
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
+12 -26
View File
@@ -23,6 +23,10 @@ const (
// Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
// refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
Foreseeing
// Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
// for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
// The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
Planning
)
// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
@@ -253,28 +257,6 @@ func (c *composer) wouldMakeSecretForModule(node Node, module, name string) erro
return nil
}
// busCredentialIssued refuses an own secret called `broker` whose bus account nobody issued (novox/hq issue 203): the broker
// credential is never invented, an account on the bus minted by `module issue`.
func (c *composer) busCredentialIssued(node, module, name string) error {
var user string
switch name {
case "broker":
user = node + "." + module
case ProvisionerSecret:
user = "provisioner." + node
default:
return nil
}
if !slices.Contains(c.in.BusMinted, user) {
return fmt.Errorf(
"%s on %s has no bus credential: nothing was issued for %s, and a push "+
"would seal a placeholder its process cannot read (novox/hq issue 203). "+
"`module issue %s --node %s`, then push again",
module, node, user, module, node)
}
return nil
}
// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds.
func givenUnderAnOldKey(module, node, name string) error {
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
@@ -344,12 +326,16 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c
continue
}
sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
if !held && mode == Planning {
mark(m.Module, name)
continue
}
if !held && mode == Foreseeing {
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
// named; refused by it -- refused here, the same words (novox/hq issue 275).
if err := c.busCredentialIssued(node.Name, m.Module, name); err != nil {
return nil, nil, err
}
// named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
// foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written
// on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
// (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
return nil, nil, err
}
+20 -22
View File
@@ -116,34 +116,32 @@ func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
}
}
// 1364/AC3 (the send's refusals, novox/hq issue 203 and 275): in the Foreseeing mode a bus credential nobody issued and a given secret
// under an old key are refused in the controller's words; with the credential issued, the secret is foreseen. No secret value is in
// an error.
// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
// "has no bus credential: nothing was issued for ace.mesh-self-check").
func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
broker := catalogue.Manifest{Module: "gate", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/gate/broker", Taken: catalogue.TakenAtStart}}}
broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}}
in := withAssistant(t)
in.Catalogue["gate"] = broker
in.Catalogue["mesh-self-check"] = broker
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "gate")
_, err := Render(in, "laptop", Foreseeing)
want := "gate on laptop has no bus credential: nothing was issued for laptop.gate, and a push would seal a placeholder " +
"its process cannot read (novox/hq issue 203). `module issue gate --node laptop`, then push again"
if err == nil || err.Error() != want {
t.Fatalf("got %v, want %q", err, want)
}
// Reading refuses nothing for it: the machine is not running it.
if _, err := Render(in, "laptop", Reading); err != nil {
t.Fatalf("Reading refused a secret the machine is not running: %v", err)
}
in.BusMinted = []string{"laptop.gate"}
if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["gate"]["broker"] {
t.Errorf("an issued bus credential is not foreseen: %v", got.Rendering.Foreseen)
laptop.Assigned = append(laptop.Assigned, "mesh-self-check")
for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} {
in.BusMinted = minted
got, err := Render(in, "laptop", Foreseeing)
if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] {
t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err)
}
// Reading refuses nothing for it: the machine is not running it.
if _, err := Render(in, "laptop", Reading); err != nil {
t.Fatalf("Reading refused a secret the machine is not running: %v", err)
}
}
// A given secret under an old key.
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue},
{Node: "laptop", Module: "gate", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
_, err = Render(in, "laptop", Foreseeing)
{Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
_, err := Render(in, "laptop", Foreseeing)
wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " +
"sealing key. The mesh cannot make another; issue it again"
if err == nil || err.Error() != wantOld {
+67 -11
View File
@@ -2,6 +2,7 @@ package frombus
import (
"context"
"errors"
"fmt"
"maps"
"regexp"
@@ -41,7 +42,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
failed := map[string]string{}
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() })
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil)
if err != nil {
return refuse(err)
}
@@ -239,7 +240,9 @@ const MaxShown = 256 << 10
var (
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
// grantOfStandIn is the provision a grant's stand-in is of.
grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`)
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
)
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
@@ -321,7 +324,7 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
left[m] = map[string]bool{}
}
left[m][leave] = true
if leave != leaveAll {
if leave != leaveAll && leave != leaveOwn {
failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
}
}
@@ -335,7 +338,7 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
if err != nil {
module, leave := culprit(in, node, fps, mode, failed, err)
if module == "" {
return nil, nil, nil, err
return nil, nil, nil, firstReason(err, failed)
}
hold(module, err.Error(), leave)
continue
@@ -359,6 +362,16 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
if _, done := failed[m]; !done {
hold(m, "stands in for "+strings.Join(of[m], ", "), "")
again = true
continue
}
// Already held, and still a stand-in in another module's composition: a grant not yet issued for what
// it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution
// to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1).
for _, x := range of[m] {
if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] {
hold(m, failed[m], g[1])
again = true
}
}
}
if !again && len(nodes) > 0 {
@@ -373,9 +386,46 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
return out, skipped, stood, nil
}
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); any other word leaves out
// what it contributes to that provision alone.
const leaveAll = "*"
// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held
// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for
// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's
// words, which were all the proposal's refusal said).
func firstReason(err error, failed map[string]string) error {
var named []string
for _, m := range sortedKeys(failed) {
if strings.Contains(err.Error(), m) {
named = append(named, m+" is held: "+failed[m])
}
}
if len(named) == 0 {
return err
}
return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
}
// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
func blames(m string, again, err error) bool {
if m == catalogue.RuntimeModule {
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
// after it, each for its own words, and the node composes the rest.
var none *catalogue.NoRuntimeToRunError
return again == nil || errors.As(again, &none) && none.Held
}
return again == nil || again.Error() != err.Error()
}
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets
// alone, its offer kept; any other word leaves out what it contributes to that provision alone.
//
// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774,
// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its
// login while it is held.
const (
leaveAll = "*"
leaveOwn = "own-secrets"
)
// assignedOn is the modules placed on node, in placement order.
func assignedOn(in compose.Input, node string) []string {
@@ -419,7 +469,7 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose.
}
changes := func(m, leave string) bool {
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
return again == nil || again.Error() != err.Error()
return blames(m, again, err)
}
for _, m := range order {
if _, done := failed[m]; done {
@@ -456,6 +506,9 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose.
if isBare(man) {
continue
}
if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) {
return m, leaveOwn
}
if changes(m, leaveAll) {
return m, leaveAll
}
@@ -484,9 +537,9 @@ func stubbed(in compose.Input, node, module, leave string) compose.Input {
// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
// its offer: a provision's contributions, or with leaveAll everything it contributes and its own secrets, none of a
// held module's own composition being applied, so what it has not been issued (restic's repository on a node it never
// ran on) is no refusal of the rest.
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
// repository on a node it never ran on) is no refusal of the rest.
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
out := in
out.Nodes = append([]compose.Node(nil), in.Nodes...)
@@ -497,6 +550,9 @@ func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left
if left[leaveAll] {
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
} else if len(left) > 0 {
if left[leaveOwn] {
man.OwnSecrets = nil
}
contributes := map[string]map[string]any{}
for p, v := range man.Contributes {
if !left[p] {
+1 -1
View File
@@ -516,7 +516,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
for _, row := range []struct {
left map[string]bool
route, smtp, own bool
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveAll: true}, false, false, false}} {
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} {
k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
+220 -14
View File
@@ -34,6 +34,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"net/netip"
"slices"
"sort"
@@ -93,6 +94,15 @@ type SignersReader interface {
VaultAuthority(ctx context.Context) string
}
// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the
// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in
// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the
// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field),
// composes by the manifest and the machine, as before.
type HandedReader interface {
Handed(ctx context.Context, node string) map[string]bool
}
// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the
// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take
// compose's defaults.
@@ -177,13 +187,15 @@ func InOwn(running []delivery.Composed, p delivery.Placement) []string {
// Input is the composition input for s, read through r.
func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) {
return input(ctx, r, mesh, s, nil)
return input(ctx, r, mesh, s, nil, nil)
}
// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in is
// handed to refuse and composed as a stub (compose.Stub, kept for that node alone) rather than ending the read, so a
// node's answer names every module it cannot compose (issue 1126).
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error)) (compose.Input, error) {
// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose
// manifest the mesh refuses, is handed to refuse and composed as a stub (compose.Stub, kept for that node alone)
// rather than ending the read, so a node's answer names every module it cannot compose (issue 1126, issue 774); a
// module placed elsewhere whose manifest the mesh refuses 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) {
placements, err := r.Placements(ctx)
if err != nil {
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
@@ -248,14 +260,33 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
}
return parse(raw)
}
ownPlaced := map[string]bool{}
for _, pm := range placements[s.Node].Modules {
ownPlaced[pm.Module] = true
}
for _, name := range sortedKeys(modules) {
m := modules[name]
if m.Manifest == "" {
continue // a registered module the indexer has not read: nothing to compose it from
}
man, err := read(name, m.Manifest)
raw, err := r.Manifest(ctx, m.Manifest)
if err != nil {
return compose.Input{}, err
// 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)
}
man, err := parse(raw)
if err != nil {
if refuse == nil {
return compose.Input{}, err
}
// One module's manifest the mesh refuses 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). It stands in as its
// unjudged stub, so what it provides and requires still shapes the rest; it is held on the composing node
// where it is placed there, and elsewhere only its offer is read.
man = unusable(name, raw)
if ownPlaced[name] {
refuse(name, err)
}
}
in.Catalogue[name] = man
}
@@ -308,6 +339,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
}
}
if node == s.Node {
// What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126).
n.RunsTools = compose.ToolsRunning(s.Running)
for _, module := range s.InOwn {
if n.InOwn == nil {
n.InOwn = map[string]bool{}
@@ -374,7 +407,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
if _, ok := 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); err != nil {
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], 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).
@@ -384,6 +417,16 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil {
return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err)
}
// And which of its modules the vault left out of that answer as handed: the node hands exactly those.
if hr, ok := r.(HandedReader); ok {
if handed := hr.Handed(ctx, s.Node); handed != nil {
for i := range in.Nodes {
if in.Nodes[i].Name == s.Node {
in.Nodes[i].Handed = handed
}
}
}
}
}
// The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which
// the composition reads for a core module as its kept boxes.
@@ -406,7 +449,18 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
// elsewhere, a stand-in for the node's own) refuses the node, err.
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) {
held = map[string]string{}
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() })
// A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774,
// 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid).
defer func() {
if err != nil && len(held) > 0 {
var said []string
for _, m := range sortedKeys(held) {
said = append(said, m+": "+held[m])
}
err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; "))
}
}()
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps)
if err != nil {
return nil, nil, held, err
}
@@ -424,6 +478,123 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
return out, skipped, held, err
}
// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it:
// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret.
func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) {
in, err := Input(ctx, r, mesh, s)
if err != nil {
return nil, err
}
return PlansOf(in, s.Node)
}
// PlansHeld is Plans composed module by module (issue 774: one bad module must not drop its node): a module placed on
// the scope's node whose build cannot be read or taken in, or whose plan refuses the node's, is held, by the one rule
// Compose follows (composeEach, culprit): composed as its stub so what it provides and requires still shapes the rest,
// named in held with why, and none of its plan in plans. Only a refusal that is no single module's refuses the node,
// err. The seat mesh-provisioner's holder composes so: a node with one bad manifest still has every other module's plan.
func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[string]catalogue.Plan, held map[string]string, err error) {
held = map[string]string{}
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil)
if err != nil {
return nil, held, err
}
plans, err = plansHeldOf(in, s.Node, held)
return plans, held, err
}
// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until
// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub).
func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) {
for _, m := range sortedKeys(held) {
in = stubbed(in, node, m, "")
}
compose1 := func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }
// Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed.
for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ {
plans, err := compose1(in)
if err == nil {
for m := range held {
delete(plans, m)
}
return plans, nil
}
var order []string
for _, m := range assignedOn(in, node) {
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
order = append(order, m)
}
}
module, leave := "", ""
find:
for _, l := range []string{"", leaveAll} {
for _, m := range order {
if _, done := held[m]; done && l == "" {
continue
}
if _, again := compose1(stubbed(in, node, m, l)); again == nil || again.Error() != err.Error() {
module, leave = m, l
break find
}
}
}
if module == "" {
return nil, err
}
if _, done := held[module]; !done {
held[module] = err.Error()
}
in = stubbed(in, node, module, leave)
}
return nil, fmt.Errorf("frombus: the plans of %s do not compose with %d module(s) held: %s", node, len(held),
strings.Join(sortedKeys(held), ", "))
}
// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the
// input holds none for stands in, so no value and no record of one is needed.
func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
return plansIn(in, node, compose.Planning)
}
// 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)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
with := rendered.Rendering
with.Generators = gens
composed, err := resolved.Plan.Compose(with)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
return composed.Plans, nil
}
// unusable is what stands in for a module's manifest the mesh refuses: its stub (compose.Stub)
// read from its JSON unjudged, so what it provides, requires and serves still shapes the rest, and without its items,
// since nothing judged what they would put into another module's collection. Bytes that are no manifest of the module
// stand in as a stub that offers nothing.
func unusable(module string, raw []byte) catalogue.Manifest {
var m catalogue.Manifest
if json.Unmarshal(raw, &m) != nil || m.Module != module {
m = catalogue.Manifest{Module: module}
}
m.Items = nil
return compose.Stub(m)
}
// parser reads a manifest as the indexer does, against the mesh's predefined keys when given.
func parser(mesh Mesh) func([]byte) (catalogue.Manifest, error) {
return func(raw []byte) (catalogue.Manifest, error) {
@@ -467,7 +638,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
// (issue 1126).
func fromBuilds(ctx context.Context, r Reader, 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)) 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
// and requires still shapes the rest.
stub := func(module string, err error) {
@@ -527,9 +698,27 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
}
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
// the scope keeps at another build is.
keptAt := maps.Clone(s.KeptAt)
for _, pm := range p.Modules {
if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 &&
keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) {
if keptAt == nil {
keptAt = map[string]string{}
}
keptAt[pm.Module] = wanted[pm.Module]
}
}
runs := map[string]bool{}
for _, c := range s.Running {
if c.Module != "" {
runs[c.Module] = true
}
}
for _, pm := range p.Modules {
m, ok := modules[pm.Module]
if !ok || m.FP2 == "" || s.KeptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
// A module it runs inside its own composition is composed there as before: no build of the new form is what
// it runs (Scope.InOwn).
continue
@@ -547,7 +736,14 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
continue
}
if b == nil || b.State != delivery.BuildDone {
continue // no done build at the trunk's: composed from the indexer's manifest, as before
if refuse != nil && runs[pm.Module] {
// A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no
// runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30:
// node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused).
stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+
"keeps running what it runs", pm.Module, m.FP2))
}
continue // nothing of it runs: composed from the indexer's manifest, as before
}
resolved, err := built(pm.Module, m.FP2, b)
if err != nil {
@@ -563,8 +759,8 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
if in.Nodes[i].Name != s.Node {
continue
}
for _, module := range sortedKeys(s.KeptAt) {
fp := s.KeptAt[module]
for _, module := range sortedKeys(keptAt) {
fp := keptAt[module]
b, err := r.Build(ctx, module, fp)
switch {
case err != nil:
@@ -809,3 +1005,13 @@ func membershipOf(own delivery.Composed) string {
}
return ""
}
// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans
// composes (novox/hq ADR 0571 clause 3): disk-load measures these.
func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) {
in, err := Input(ctx, r, mesh, s)
if err != nil {
return nil, nil, err
}
return compose.DataItems(in, s.Node)
}
+127
View File
@@ -14,6 +14,7 @@ import (
"os"
"reflect"
"slices"
"sort"
"strings"
"testing"
@@ -26,6 +27,7 @@ import (
// bus is a Reader over values held in memory, counting whose config values were read.
type bus struct {
vaultAuthority string
handed map[string]bool
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
modules map[string]delivery.Module
@@ -78,6 +80,7 @@ func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error)
return b.placements, nil
}
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
@@ -137,6 +140,12 @@ func TestInput(t *testing.T) {
// saying why (answer_rows_test.go).
t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
t.Run("no store reached says why", noStoreReachedSaysWhy)
// Issue 774: a node refused whole says what it held on the way.
t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld)
t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes)
t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild)
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
ctx := context.Background()
xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
@@ -238,6 +247,13 @@ func TestInput(t *testing.T) {
bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
}
// Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's.
judged := mk()
judged.handed = map[string]bool{"media": true, "xterm": false}
if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil ||
!reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil {
t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err)
}
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
}
@@ -694,6 +710,47 @@ func TestInput(t *testing.T) {
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
}
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
noSecrets := movedMesh(true)
noSecrets.own = nil
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatalf("the plans from the bus: %v", err)
}
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
if err != nil {
t.Fatal(err)
}
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
gj, _ := json.MarshalIndent(seat, "", " ")
wj, _ := json.MarshalIndent(controllers, "", " ")
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
}
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil || len(unplaced) > 0 || len(items) == 0 {
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
}
for module, its := range items {
for _, it := range its {
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
t.Errorf("%s's item %+v is not placed", module, it)
}
}
}
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
// mesh-provisioner and disk-load, the lead 2026-10-29).
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
for _, body := range noSecrets.archives {
return body, nil
}
return nil, errors.New("no archive")
}), Mesh{}, Scope{Node: "n1"})
if err != nil || !reflect.DeepEqual(viaStates, items) {
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
}
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
@@ -739,3 +796,73 @@ func TestInput(t *testing.T) {
}
}
}
// mapStates are read states by name, key to value, as a module's runtime gives them.
type mapStates map[string]map[string][]byte
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
v, ok := m[state][key]
return v, ok, nil
}
func (m mapStates) Keys(state string) ([]string, error) {
var out []string
for k := range m[state] {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// statesOf is b laid out as the states StatesReader reads.
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
t.Helper()
out := mapStates{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if out[state] == nil {
out[state] = map[string][]byte{}
}
out[state][key] = raw
}
placements, _ := b.Placements(ctx)
for _, p := range placements {
values, err := delivery.PlacementValues(p)
for k, raw := range values {
put(PlacementRead, k, raw, err)
}
}
records, _ := b.Records(ctx)
for node, r := range records {
k, _ := delivery.HaveNodeKey(node)
rec := r
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(HaveRead, k, raw, err)
}
modules, _ := b.Modules(ctx)
for name, m := range modules {
raw, err := delivery.EncodeModule(m)
put(ModulesRead, name, raw, err)
var mf struct{ Module string }
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
k, _ := delivery.ManifestsKey(m.Manifest)
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
put(ManifestsRead, k, raw, err)
}
for key, build := range b.builds {
module, fp, _ := strings.Cut(key, " ")
k, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(BuildsRead, k, raw, err)
}
for nm, values := range b.values {
for k, v := range values {
value, _ := json.Marshal(v)
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
put(ResolvedRead, nm+"."+k, raw, err)
}
}
return out
}
@@ -0,0 +1,183 @@
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
package frombustest
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"embed"
"encoding/json"
"errors"
"strings"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
//go:embed testdata
var testdata embed.FS
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
type Bus struct {
Placement map[string]delivery.Placement
Record map[string]delivery.NodeRecord
Module map[string]delivery.Module
Manifests map[string][]byte
Value map[string]map[string]any // by node.module
Own []compose.OwnSecretRow
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
Archives map[string][]byte // by digest
Serves map[string]map[string]any // another node's values, by node.module
Held bool
}
var _ frombus.Reader = (*Bus)(nil)
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.Placement, nil
}
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.Manifests[digest]
if !ok {
return nil, errors.New("no such manifest")
}
return raw, nil
}
func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) {
return b.Value[node+"."+module], nil
}
func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil }
func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
var out []compose.Credential
var issued []compose.Issued
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
if b.Held {
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
}
}
return out, issued, nil
}
func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
return b.Builds[module+" "+fp], nil
}
func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) {
body, ok := b.Archives[digest]
if !ok {
return nil, errors.New("the store holds no such blob")
}
return body, nil
}
func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
out := map[string]any{}
for _, k := range keys {
if v, ok := b.Serves[node+"."+module][k]; ok {
out[k] = v
}
}
return out, nil
}
// Node is the one node of the mesh Provisioned composes.
const Node = "n1"
// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's
// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans
// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider.
func Provisioned() (*Bus, error) {
read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) }
resticRaw, err := read("catalogue-c05aa5e6-restic.module.json")
if err != nil {
return nil, err
}
var restic map[string]any
if err := json.Unmarshal(resticRaw, &restic); err != nil {
return nil, err
}
restic["requires"] = []string{"postgres-database"}
if resticRaw, err = json.Marshal(restic); err != nil {
return nil, err
}
clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json")
if err != nil {
return nil, err
}
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"serves":{"artifact-store":{"port":5000}}}`)
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
`"serves":{"postgres-database":{"port":5432}}}`)
var packed bytes.Buffer
zw := gzip.NewWriter(&packed)
tw := tar.NewWriter(zw)
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
text, err := read("catalogue-c05aa5e6-restic.files/" + f)
if err != nil {
return nil, err
}
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
return nil, err
}
if _, err := tw.Write(text); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
archive := delivery.ManifestDigest(packed.Bytes())
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
fps := FPs()
b := &Bus{
Placement: map[string]delivery.Placement{Node: {Node: Node,
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
Builds: map[string]*delivery.BuilderBuild{
"restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
"store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
"postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
},
Archives: map[string][]byte{archive: packed.Bytes()},
Held: true,
}
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
b.Manifests[delivery.ManifestDigest(raw)] = raw
}
return b, nil
}
// FPs are the fingerprints Provisioned's modules are built at.
func FPs() map[string]string {
return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
"store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
}
@@ -0,0 +1,109 @@
package frombustest
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node
// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld
// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer
// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33,
// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node
// it is not placed on holds nothing.
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
fp := "src2:" + strings.Repeat("9", 64)
for _, row := range []struct {
name string
bad string // the indexer's manifest of a module "backup" placed on n1; empty places none
// built is the manifest backup's done build names; empty gives it no build
built string
why string // words backup's hold says
// elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held.
elsewhere bool
}{
{name: "every module composes"},
{name: "a built manifest that cannot be used",
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
built: `{"module":"backup","version":"1","requires":["amqp"]}`, why: "backup"},
{name: "a plan that refuses",
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`},
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true},
} {
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
if row.bad != "" {
if !row.elsewhere {
p := b.Placement[Node]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
b.Placement[Node] = p
}
digest := delivery.ManifestDigest([]byte(row.bad))
b.Manifests[digest] = []byte(row.bad)
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
if row.built != "" {
builtDigest := delivery.ManifestDigest([]byte(row.built))
b.Manifests[builtDigest] = []byte(row.built)
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
}
if _, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}); err == nil && !row.elsewhere {
t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name)
}
}
if row.why != "" || row.bad == "" {
// The node's own composition holds by the same rule: every other module composed, the bad one named.
fps := FPs()
if row.bad != "" {
fps["backup"] = fp
}
out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
if err != nil {
t.Fatalf("%s: the node's composition: %v", row.name, err)
}
if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
}
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why)
}
}
plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
restic, handed := plans["restic"]
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
}
if row.bad == "" || row.elsewhere {
if len(held) > 0 {
t.Errorf("%s: held %v", row.name, held)
}
continue
}
if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 {
t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why)
}
if _, planned := plans["backup"]; planned {
t.Errorf("%s: the held module has a plan", row.name)
}
}
}
// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item
// with attributes its bar places by none of.
const unusableItem = `{"module":"backup","version":"1","moved":true,` +
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
`"items":[{"collection":"contribution:node-bar:block",` +
`"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`
@@ -0,0 +1,60 @@
{
"module": "mesh-provisioner-client",
"version": "1",
"claims": [
{
"name": "node-provisioner",
"scope": "node"
}
],
"own-secrets": {
"provisioner-broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-provisioner-client",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-provisioner-client",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-provisioner-client"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-provisioner-client",
"place": ".",
"unknown": "program"
},
{
"id": "client",
"type": "process",
"name": "mesh-provisioner-client",
"artifact": "client",
"run": [
"./mesh-provisioner-client"
],
"env": {
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
"MESH_PROVISIONER_STATE": "${dir:state}"
},
"user": "mesh-provisioner-client",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "client",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-provisioner-client",
"binary": "mesh-provisioner-client"
}
]
}
}
@@ -0,0 +1,2 @@
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
${backup:lines}
@@ -0,0 +1,2 @@
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
${backup:data}
@@ -0,0 +1,6 @@
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
daily=${config:keep-daily}
weekly=${config:keep-weekly}
monthly=${config:keep-monthly}
hand=${config:keep-hand-taken-days}
@@ -0,0 +1,199 @@
{
"module": "restic",
"version": "1",
"moved": true,
"claims": [
{
"name": "node-backup",
"scope": "node",
"serves": [
"backed-up",
"now",
"restore"
]
}
],
"emits": [
"backed-up"
],
"tools": [
"backup_health",
"backup_delete_retired",
"backup_deleted"
],
"own-secrets": {
"repository": "${dir:state}/repository.secret"
},
"data": {
"own": [
{
"id": "repository",
"path": "${dir:repository}",
"class": "rebuildable",
"backup": "none",
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
},
{
"id": "state",
"path": "${dir:state}",
"class": "cache",
"why": "what the last nights and measurements were; the next night writes it again"
}
]
},
"config": {
"keep-daily": {
"type": "number",
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 14
},
"keep-weekly": {
"type": "number",
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 8
},
"keep-monthly": {
"type": "number",
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 6
},
"keep-hand-taken-days": {
"type": "number",
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 7
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": ".",
"unknown": "program"
},
{
"id": "repository",
"type": "directory",
"mode": "0700",
"unknown": "program"
},
{
"id": "declared",
"type": "file",
"path": "${dir:state}/backups.conf",
"mode": "0600",
"template": "files/backups.conf"
},
{
"id": "data-declared",
"type": "file",
"path": "${dir:state}/data.conf",
"mode": "0600",
"template": "files/data.conf"
},
{
"id": "retention",
"type": "file",
"path": "${dir:state}/retention.conf",
"mode": "0644",
"trusted": true,
"template": "files/retention.conf"
},
{
"id": "tool",
"type": "package",
"package": "restic"
},
{
"id": "sqlite",
"type": "package",
"package": "sqlite"
},
{
"id": "night",
"type": "process",
"name": "restic-night",
"artifact": "tools",
"run": [
"./restic-backups",
"night"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "tool",
"tool": "backup_health",
"interval": "60s",
"timeout": "20s",
"looks": 2,
"grace": "60s"
}
},
{
"id": "measure",
"type": "process",
"name": "restic-measure",
"artifact": "tools",
"run": [
"./restic-backups",
"measure"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/restic-backups",
"binary": "restic-backups",
"loads": [
"restic-backups"
],
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
+255
View File
@@ -0,0 +1,255 @@
package frombus
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
t.Helper()
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
fps := map[string]string{}
for i, raw := range manifests {
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
}
b.placements = map[string]delivery.Placement{"n1": p}
return b, fps
}
// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last
// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid).
func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}`
needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}`
b, fps := aNode(t, refused, needs)
_, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) ||
!strings.Contains(err.Error(), `"shows" is none of them`) {
t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held)
}
}
// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub
// composes, since its stub always fails otherwise.
func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
err := errors.New("lamp refuses")
for _, row := range []struct {
module string
again error
blamed bool
}{
{"lamp", nil, true},
{"lamp", errors.New("other"), true},
{"lamp", errors.New("lamp refuses"), false},
{catalogue.RuntimeModule, nil, true},
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true},
{catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false},
} {
if got := blames(row.module, row.again, err); got != row.blamed {
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
}
}
}
// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed
// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A
// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at
// another build than the trunk's is composed from the build its want names.
func aRunningModuleWaitsForItsBuild(t *testing.T) {
dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}}
for _, row := range []struct {
name string
want string // the build node-tools' want names
done bool // the trunk's build is done
held string // words node-tools' hold says; empty: composed
digest string // the runtime bundle's digest composed
}{
{name: "the trunk's build not done yet", want: next, held: "no done build yet"},
{name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")},
{name: "the trunk's build done", want: next, done: true, digest: dg("2")},
} {
b, fps := aNode(t, store, tools, lamp)
b.held = true // every grant held, so a contribution's credential stands in for nothing
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
mod := b.modules["node-tools"]
mod.FP2 = next
b.modules["node-tools"] = mod
b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
if row.done {
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
}
fps["node-tools"] = row.want
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
if err != nil {
t.Errorf("%s: the node is refused: %v", row.name, err)
continue
}
if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
}
if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
}
if row.digest != "" {
key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
}
}
}
}
func rawStrings(rs []json.RawMessage) []string {
var out []string
for _, r := range rs {
out = append(out, string(r))
}
return out
}
// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's
// on ace should have; a refusal naming no held module is as it was.
func aHeldModulesFirstReasonLeads(t *testing.T) {
held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"}
for _, row := range []struct {
err, want string
}{
{"mesh-self-check runs as its own account and declares no own secret broker",
"mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " +
"its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"},
{"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"},
} {
err := errors.New(row.err)
got := firstReason(err, held)
if got.Error() != row.want || !errors.Is(got, err) {
t.Errorf("refused %q, want %q", got, row.want)
}
}
}
// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their
// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then
// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the
// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own
// secrets: the node composes every other module, and the held one keeps the words it was first held for.
func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
dg := "sha256:" + strings.Repeat("1", 64)
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
own := func(name string) string {
return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
`"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` +
`"resources":[{"id":"account","type":"user","name":"` + name + `"}],` +
`"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
}
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
for _, row := range []struct {
name string
issued []string // the own-account modules whose broker is issued
building bool // node-tools' build is not done, and nothing of it runs
held map[string]string // the modules held, each with words of the reason it was first held for
// ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's
// usual state): a held module's contribution then stands in for its grant, and is left out (review round 1).
ungranted bool
}{
{name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}},
{name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}},
{name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}},
{name: "both issued", issued: []string{"check", "heal"}},
// The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node.
{name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true,
held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}},
} {
provider := store
if row.ungranted {
provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1)
}
b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal"))
// Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none.
b.held = !row.ungranted
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
for _, m := range row.issued {
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
}
for _, m := range []string{"node-tools", "check", "heal"} {
name := "tools"
if m == "node-tools" {
name = "runtime"
}
mod := b.modules[m]
mod.FP2 = fps[m]
b.modules[m] = mod
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
if m == "node-tools" && row.building {
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
}
}
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
if err != nil {
t.Errorf("%s: the node is refused: %v", row.name, err)
continue
}
// A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
// of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
// out of its stand-in, never its contributions, so the provider's received file names every consumer.
storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
// And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
// not issued is composed without that contribution, never with "foreseen: made by the next send".
for key, c := range out {
if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") {
t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got)
}
}
for _, m := range []string{"check", "heal"} {
if row.ungranted {
break
}
if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) {
t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got)
}
}
for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
key, _ := delivery.ComposedKey("n1", m, fps[m])
_, composed := out[key]
why, isHeld := held[m]
want, wantHeld := row.held[m]
if isHeld != wantHeld || composed == wantHeld {
t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld)
} else if isHeld && !strings.Contains(why, want) {
t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want)
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
package frombus
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
// runtime states and an HTTP fetch are compose/fromruntime's.
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The states a module names in its manifest's `reads` to compose through StatesReader.
const (
PlacementRead = delivery.PlacementRead
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
BuildsRead = delivery.BuilderModule + ".builds"
HaveRead = "node-engine.have"
ResolvedRead = "mesh-config.resolved"
)
// StatesReads are those reads, in a manifest's order.
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
type States interface {
Get(state, key string) (value []byte, found bool, err error)
Keys(state string) ([]string, error)
}
// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
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} }
type statesReader struct {
s States
fetch Fetch
}
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
keys, err := r.s.Keys(read)
if err != nil {
return fmt.Errorf("%s: %w", read, err)
}
for _, k := range keys {
v, found, err := r.s.Get(read, k)
if err != nil {
return fmt.Errorf("%s %s: %w", read, k, err)
}
if found {
visit(k, v)
}
}
return nil
}
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
keys := delivery.NewPlacementKeys()
err := r.each(PlacementRead, func(k string, v []byte) {
_, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
})
return keys.All(), err
}
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
out := map[string]delivery.NodeRecord{}
err := r.each(HaveRead, func(k string, v []byte) {
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
out[node] = *h.Record
}
}
})
return out, err
}
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
out := map[string]delivery.Module{}
err := r.each(ModulesRead, func(k string, v []byte) {
if module, err := delivery.ParseModulesKey(k); err == nil {
if m, err := delivery.DecodeModule(v); err == nil {
out[module] = m
}
}
})
return out, err
}
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
key, err := delivery.ManifestsKey(digest)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(ManifestsRead, key)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
}
m, err := delivery.DecodeManifest(v)
if err != nil {
return nil, err
}
return []byte(m.JSON), nil
}
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
var out map[string]any
prefix := node + "." + module + "."
err := r.each(ResolvedRead, func(k string, v []byte) {
name, ok := strings.CutPrefix(k, prefix)
if !ok || strings.Contains(name, ".") {
return
}
if value, laid := catalogue.LaidValue(v); laid {
if out == nil {
out = map[string]any{}
}
out[name] = value
}
})
return out, err
}
func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
return nil, nil
}
func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
return nil, nil, nil
}
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(BuildsRead, key)
if err != nil || !found {
return nil, err
}
b, err := delivery.DecodeBuilderBuild(v)
if err != nil {
return nil, err
}
return &b, nil
}
// archiveWithin bounds one read of a templates archive.
const archiveWithin = 30 * time.Second
func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
if r.fetch == nil {
return nil, fmt.Errorf("this holder reads no archive (%s)", url)
}
fetching, cancel := context.WithTimeout(ctx, archiveWithin)
defer cancel()
body, err := r.fetch(fetching, url)
if err != nil {
return nil, err
}
sum := sha256.Sum256(body)
if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
}
return body, nil
}
func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
all, err := r.Values(ctx, node, module)
var out map[string]any
for _, k := range keys {
if v, ok := all[k]; ok {
if out == nil {
out = map[string]any{}
}
out[k] = v
}
}
return out, err
}
+49
View File
@@ -0,0 +1,49 @@
// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a
// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched
// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command.
package fromruntime
import (
"context"
"fmt"
"io"
"net/http"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
)
// States are a bundle's read states through its runtime.
type States struct{}
// Get is a key's value, false when it has none.
func (States) Get(state, key string) ([]byte, bool, error) {
e, err := stdio.State(state).Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// 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.
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
// 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)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered %s", url, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 256<<20))
}
+10
View File
@@ -142,6 +142,10 @@ type Node struct {
Place Place
// Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
Assigned []string
// Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own
// secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those
// (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller.
Handed map[string]bool
// Capabilities are the ones present on it (inv.ProfileOf).
Capabilities map[string]bool
// PublicDomain is the domain it composes its routed names under (inv.PublicDomainOf).
@@ -210,6 +214,12 @@ type Node struct {
// (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
// never drops it.
Held map[string]bool
// RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the
// node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a
// build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no
// bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost
// the folded systemd-resolved, novox's the folded nats).
RunsTools map[string][]catalogue.Bundle
}
// Place is where a node is, as its placement says (inv.Overlay).
+16
View File
@@ -333,3 +333,19 @@ func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string
sort.Strings(out)
return out
}
// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the
// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load
// reads the list from the bus with no composed file. Unplaced names each item left out and why.
func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) {
c := newComposer(in)
resolved, err := c.planFor(node)
if err != nil {
return nil, nil, err
}
rendered, err := c.render(node, Planning)
if err != nil {
return nil, nil, err
}
return resolved.Plan.DataItems(rendered.Rendering)
}
+4 -2
View File
@@ -58,6 +58,8 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
}
r := placed.Rendering
out.ForeseenPorts = placed.ForeseenPorts
// A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774).
r.Planned = mode == Planning
r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound
out.Withheld, out.Unbound = withheld, unbound
for _, g := range grantsForeseen {
@@ -65,13 +67,13 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
}
// And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client.
handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner}
handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner, Assigned: r.Assigned}
needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode)
if err != nil {
return Rendered{}, err
}
r.Needed = needed
if mode == Foreseeing || len(foreseenOwn) > 0 {
if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 {
r.Foreseen = foreseenOwn
}
var names []string
+1
View File
@@ -73,6 +73,7 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
// not sent the field they would refuse.
r.Hands = takesHanded(node)
r.Provisioner = provisionerAccount(plan)
r.Assigned = node.Handed
r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
r.JudgesRoot = node.Stated && node.Contract >= RootContract
r.ReadsBusy = node.Stated && node.Contract >= BusyContract
+6
View File
@@ -70,6 +70,12 @@ func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(
t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts)
}
unchanged(t, mk, func(in Input) { placed(t, in, "laptop") })
// Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774).
for _, mode := range []Mode{Reading, Foreseeing, Planning} {
if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) {
t.Errorf("mode %d composes with Planned %v", mode, got.Planned)
}
}
}
// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it
+11
View File
@@ -54,6 +54,7 @@ func (c *composer) planFor(nodeName string) (Resolved, error) {
// Issued for this holder alone and kept by the vault (issue 757).
resolved.Needs[i].IssuedToHolder = issuedToHolder(shelf, n)
resolved.Needs[i].ProviderMoved = providerMoved(shelf, n)
resolved.Needs[i].AnsweredByHelper = answeredByHelper(shelf, n)
if n.ByRecord {
// Answered by something the mesh holds, so there is no pair-wise secret between two machines.
licence, _ := c.licence(n.From)
@@ -189,6 +190,16 @@ func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool
return known && (movedProvider(provider) || provider.AnsweredByHelperFor(n.Name))
}
// answeredByHelper is whether a need's provider offers it answered by the provider helper (issue 792): keyed by the seat
// mesh-provisioner for every consumer, handed or not (catalogue.Keyed, issue 774).
func answeredByHelper(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
if n.ByRecord || n.Module == "" {
return false
}
provider, known := shelf[n.Module]
return known && provider.AnsweredByHelperFor(n.Name)
}
// movedProvider is whether a provider's credentials are the vault's: the module is marked moved, or it holds the
// mesh-vault seat (issue 793).
func movedProvider(m catalogue.Manifest) bool {
+28
View File
@@ -358,3 +358,31 @@ func TestALicenceKeyTheInputDoesNotCarryIsAStandInAndNamedForeseen(t *testing.T)
t.Errorf("a key the mesh holds none of: %q %v", n.Sealed, got.Foreseen)
}
}
// A consumer not handed of a provision whose provider's offer says the helper answers it has that provision in a plan of
// its own (issue 774: grafana's route): the composition marks the need answered by the helper, and the plan keys it.
func TestANeedAnsweredByTheHelperIsInTheConsumersPlan(t *testing.T) {
in := theStoreMesh(t)
store := in.Catalogue["store"]
store.Provides = []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
in.Catalogue["store"] = store
got, err := Resolve(in, "laptop")
if err != nil {
t.Fatal(err)
}
if n := need(t, got.Plan, "board", "postgres-database"); !n.AnsweredByHelper {
t.Fatalf("the need is not marked answered by the helper: %+v", n)
}
with := rendered(t, in, "laptop", Foreseeing).Rendering
with.Hands, with.Provisioner = true, "mesh-provisioner-client"
if with.Generators, err = newComposer(in).generators(); err != nil {
t.Fatal(err)
}
c, err := got.Plan.Compose(with)
if err != nil {
t.Fatal(err)
}
if p := c.Plans["board"].Provisions; len(p) != 1 || p[0].Provision != "postgres-database" || p[0].ProviderModule != "store" {
t.Fatalf("board's plan is %+v", c.Plans["board"])
}
}
+5 -2
View File
@@ -141,11 +141,14 @@ const (
RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary
RuleGrant = 6 // the module's credential for F exists (grants.<module>.<fp>)
RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6)
// RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's
// words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres".
RuleProvisions = 8
)
func checkRule(rule int, of string) error {
if rule < RuleBuildDone || rule > RuleComposed {
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the seven (1 to 7)", of, rule)
if rule < RuleBuildDone || rule > RuleProvisions {
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the eight (1 to 8)", of, rule)
}
return nil
}
+1 -1
View File
@@ -147,7 +147,7 @@ func TestAssignerValuesRefused(t *testing.T) {
{Removed: true, FP: fp2of('a')},
{FP: fp2of('a'), Newest: fp2of('b')},
{FP: fp2of('a'), Newest: fp2of('a'), Rule: 3},
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8},
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9},
{FP: fp2of('a'), Composed: hashOf('1'), Hold: true},
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3},
{Removed: true, Put: true},
+27 -2
View File
@@ -35,10 +35,22 @@ func placing(t *testing.T, node string, modules ...string) (string, []byte) {
for _, m := range modules {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
key, err := delivery.PlacementKey(node)
values, err := delivery.PlacementValues(p)
if err != nil {
t.Fatal(err)
}
key, _ := delivery.PlacementNodeKey(node)
return key, values[key]
}
// placingLegacy is placing in the form before, `placement.<node>` (research 072 row 37).
func placingLegacy(t *testing.T, node string, modules ...string) (string, []byte) {
t.Helper()
p := delivery.Placement{Node: node}
for _, m := range modules {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
key, _ := delivery.LegacyPlacementKey(node)
value, err := delivery.EncodePlacement(p)
if err != nil {
t.Fatal(err)
@@ -177,8 +189,21 @@ func TestToolchainGate(t *testing.T) {
t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) {
p := delivery.NewPlaced()
k, v := placing(t, "ace", NodeBuildModule)
// ace in the form before, then converted: its module stands since before, the legacy key read no more.
k, v := placingLegacy(t, "ace", NodeBuildModule)
p.Put(k, v, false, old)
k, v = placing(t, "ace", NodeBuildModule)
if _, changed, err := p.Put(k, v, false, placed); len(changed) != 0 || err != nil {
t.Errorf("the conversion of ace's key changed %v, %v", changed, err)
}
if _, changed, _ := p.Put("ace."+NodeBuildModule, []byte("{}"), false, placed); len(changed) != 0 {
t.Errorf("a module key says which modules stand: %v", changed)
}
if k, v := placingLegacy(t, "ace"); true {
if _, changed, _ := p.Put(k, v, false, placed); len(changed) != 0 {
t.Errorf("the legacy key is read after the node key: %v", changed)
}
}
k2, v2 := placing(t, "g14")
p.Put(k2, v2, false, old)
k2, v2 = placing(t, "g14", NodeBuildModule)
-9
View File
@@ -141,15 +141,6 @@ type FailedFirst struct {
Test string `json:"test,omitempty"`
Machine string `json:"machine,omitempty"`
Words string `json:"words,omitempty"`
// Step, Module and Run say where a failed test's whole output is: the build step (its ask's id) of which build
// module, under which run (the check), its log on that module's own stream as StepLogEvent(Run, Step), read with
// the module's log tool. Absent for a failure that is no test's, and in a verdict written before them.
Step string `json:"step,omitempty"`
Module string `json:"module,omitempty"`
Run string `json:"run,omitempty"`
// Pull is the pull request whose check failed, <owner/repo>#<number>: a verdict outlives its candidate, and a reader
// finds a pull request's last failure by it.
Pull string `json:"pull,omitempty"`
}
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty tests named one by one, and an entry
+132
View File
@@ -0,0 +1,132 @@
package delivery
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// **The control records' layouts, out of the controller** (novox/hq ADR 0565 clauses 2, 3 and 6; issue 1953 L5-S2): what
// the indexer keeps of the catalogue's predefined list and of each open pull request's config declarations, which
// mesh-config reads in place of the controller's mirrors and `pull-declares`; and each node's declared packages, which
// its engine writes in place of the controller's mirror.
// The indexer's two states (clause 2).
const (
// IndexerPredefinedState holds one key, PredefinedListKey: the catalogue's predefined.json at the newest merge taken.
IndexerPredefinedState = "predefined"
PredefinedListKey = "list"
// IndexerConfigCandidatesState holds one key per open pull request (ConfigCandidateKey).
IndexerConfigCandidatesState = "config-candidates"
)
// PredefinedKept is the indexer's key `predefined.list`: the list whole, as predefined.json holds it, with the merge it
// was read at. A merge older than the one the key names never replaces it (ADR 0348 §7).
type PredefinedKept struct {
V int `json:"v"`
Commit string `json:"commit"`
At time.Time `json:"at"`
List json.RawMessage `json:"list"`
}
// ConfigDeclaration is one config key a module's manifest declares, in the layout of the controller's declared-config
// mirror (mesh-controller internal/inventory DeclaredMirrored) unchanged, so mesh-config reads the indexer's as it read
// the mirror's.
type ConfigDeclaration struct {
Kind string `json:"kind"`
Default json.RawMessage `json:"default"`
Why string `json:"why"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum json.RawMessage `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Trusted bool `json:"trusted,omitempty"`
Predefined string `json:"predefined,omitempty"`
Required bool `json:"required,omitempty"`
Runtime bool `json:"runtime,omitempty"`
}
// ConfigCandidate is one open pull request's key of `config-candidates`: its head, and per module the config
// declarations the head's manifests add or change against the trunk's, an empty set when none. Written at each new
// head and each trunk merge that changes a module it names; deleted when the pull request merges or closes.
type ConfigCandidate struct {
V int `json:"v"`
Pull string `json:"pull"`
Head string `json:"head"`
Declares map[string]map[string]ConfigDeclaration `json:"declares"`
At time.Time `json:"at"`
}
// ConfigCandidateKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form mesh-config
// keeps a pull request's values under.
func ConfigCandidateKey(pull string) (string, error) {
owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/")
repo, number, ok2 := strings.Cut(rest, "#")
if !ok || !ok2 || owner == "" || repo == "" || number == "" || strings.ContainsAny(owner+repo+number, ".*> ") {
return "", fmt.Errorf("delivery: %q is not a pull request, owner/repository#number", pull)
}
return owner + "." + repo + "." + number, nil
}
// The node-engine's packages (clause 6): its state `packages`, one key, the node's name, written by that node's engine
// alone.
const EnginePackagesState = "packages"
// PackageDeclared is one package a node's modules declare, the controller's declared-packages mirror's entry unchanged
// (mesh-controller internal/inventory PackageDeclared): its name, the modules that declare it that way, sorted, and
// whether they declare it absent.
type PackageDeclared struct {
Package string `json:"package"`
Modules []string `json:"modules"`
Absent bool `json:"absent"`
}
// DeclaredPackages is one node's key of `packages`, the mirror's value unchanged: its packages sorted by name, absent
// after present. Fixed fields and no time, so the same declaration is the same bytes (ADR 0289 §1).
type DeclaredPackages struct {
Node string `json:"node"`
Packages []PackageDeclared `json:"packages"`
}
// mesh-config's state of the approved values held for a pull request (novox/hq ADR 0565 clause 5): read by the checker
// and the assigner's composition check, which lay a pull request's own held values over its modules' values.
const (
MeshConfigKeptState = "kept"
MeshConfigKeptRead = "mesh-config." + MeshConfigKeptState
)
// KeptValue is one approved held value, mesh-config's layout unchanged (modules/mesh-config pulls.go): kept apart from
// the values mesh-config resolves, laid only over the composition of its own pull request at its head.
type KeptValue struct {
Pull string `json:"pull"`
Head string `json:"head"`
Digest string `json:"declaration"`
Node string `json:"node"`
Module string `json:"module"`
Key string `json:"key"`
// Value is the value held, as the operator approved it.
Value json.RawMessage `json:"value"`
By string `json:"by"`
Via string `json:"via"`
Why string `json:"why"`
// Approved is who approved it, as the proposal kept it.
Approved string `json:"approved"`
Proposal string `json:"proposal"`
At time.Time `json:"at"`
}
// HeldValueKey is a held value's key in `kept`: the pull request's key (ConfigCandidateKey's form), the node, the module and
// the config key, joined by dots, as mesh-config writes it.
func HeldValueKey(pull, node, module, key string) (string, error) {
p, err := ConfigCandidateKey(pull)
if err != nil {
return "", err
}
return p + "." + node + "." + module + "." + key, nil
}
// The assigner's state of a person's upgrade policy, one key per module (clause 7; catalogue.PersonPolicy).
const AssignerPoliciesState = "policies"
+150
View File
@@ -0,0 +1,150 @@
package delivery
import (
"encoding/json"
"strings"
"testing"
"time"
)
// ADR 0565 clauses 2 and 6 (issue 1953 L5-S2): the control records' layouts read what their writers write. The
// packages value is the controller's declared-packages mirror's, as its writer's own test pins it (mesh-controller
// internal/inventory/declared_packages_test.go at 0fbb5ef3); the config declaration is its declared-config mirror's
// (DeclaredMirrored); a pull request's key is mesh-config's form.
func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
for _, c := range []struct {
name string
raw string
into any
}{
{"declared packages", `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
`{"package":"nano","modules":["web"],"absent":true}]}`, &DeclaredPackages{}},
{"a config candidate", `{"v":1,"pull":"novox/mesh-catalog#42","head":"abc","declares":{"mail":{"quota":{"kind":"config",` +
`"default":10,"why":"a quota","type":"integer","minimum":1}}},"at":"2026-10-29T12:00:00Z"}`, &ConfigCandidate{}},
{"a held value", `{"pull":"novox/mesh-catalog#42","head":"abc","declaration":"sha256:d","node":"novox","module":"mail",` +
`"key":"quota","value":20,"by":"person.jochen","via":"telegram","why":"more room","approved":"the operator",` +
`"proposal":"p-1","at":"2026-10-29T12:00:00Z"}`, &KeptValue{}},
// A node's health report as mesh-host 65fbc1e6 encodes it (its test's look, struct order kept).
{"a node's health report", `{"v":1,"node":"anchor","network":{"state":"unhealthy","since":"2026-10-29T20:00:00Z",` +
`"parts":[{"part":"resolv-conf","state":"unhealthy","writer":"NetworkManager","owner":"dnsmasq","since":"2026-10-29T20:00:00Z"}]},` +
`"peers":[{"key":"A=","addresses":["10.10.0.2/32"],"seen":false},{"key":"B=","seen":true}],` +
`"units":{"state":"degraded","failed":[{"unit":"cups.service","scope":"system","since":"2026-10-29T20:00:00Z"}]},` +
`"relogin":[{"account":"jochen","module":"brother","resource":"brother.scanner","reason":"relogin needed: the session began before scanner","since":"2026-10-29T20:00:00Z"}],` +
`"found":[{"module":"photos","resource":"photos.library","path":"/srv/photos","reason":"used as found: owned by root","since":"2026-10-29T20:00:00Z"}],` +
`"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
// The engine's verdict on an account declared never to become root (ADR 0266), and its contract.
{"a node's health report, the agent account judged", `{"v":1,"node":"anchor","root":[{"account":"agent",` +
`"module":"claude-code","resource":"claude-code.agent","state":"healthy","since":"2026-10-29T20:00:00Z"}],` +
`"contract":13,"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
// A part pointing at another machine names it (issue 774): the hub's tunnel, held at the hub's machine.
{"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{}},
{"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 {
t.Fatalf("%s: %v", c.name, err)
}
again, _ := json.Marshal(c.into)
var want, got any
_ = json.Unmarshal([]byte(c.raw), &want)
_ = json.Unmarshal(again, &got)
if string(jsonOf(want)) != string(jsonOf(got)) || ((c.name == "declared packages" || strings.HasPrefix(c.name, "a node's health report")) && string(again) != c.raw) {
t.Errorf("%s reads back as %s", c.name, again)
}
}
if a, b := (NodeHealth{Node: "anchor", At: time.Now()}), (NodeHealth{Node: "anchor"}); !SameNodeHealth(a, b) ||
SameNodeHealth(a, NodeHealth{Node: "anchor", Peers: []PeerSeen{{Key: "A="}}}) {
t.Error("two reports differing only in when they were written are not the same, or differing ones are")
}
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
t.Error("a report naming no 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"}}},
"novox": {Node: "novox", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"},
{Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}},
})
if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside {
t.Errorf("a claim outside the set is %+v", o)
}
keys := map[string][]byte{}
for k, v := range held {
keys[k], _ = json.Marshal(v)
}
if h, err := HoldersOf(keys, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" {
t.Errorf("a replicated seat's holders are %v, %v", h, err)
}
if h, _ := HoldersOf(keys, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 {
t.Errorf("a node seat's holder is %v, keys %v", h, held)
}
// ADR 0571 clauses 4, 5: the modules and the machines, from the indexer's and the placement seat's keys.
if m, err := ModuleNames(map[string][]byte{"letta": nil, "gitea": nil}); err != nil || strings.Join(m, ",") != "gitea,letta" {
t.Errorf("the modules are %v, %v", m, err)
}
if _, err := ModuleNames(map[string][]byte{"a.b": nil}); err == nil {
t.Error("a key that is no module's was read as one")
}
if n := MachineNames(map[string][]byte{"placement.novox": nil, "placement.ace": nil, "ace.node": nil, "ace.app": nil, "g14.app": nil, "proposal.p1": nil}); strings.Join(n, ",") != "ace,novox" {
t.Errorf("the machines are %v", n)
}
if h, _ := HoldersOf(keys, "node-files", "novox"); h != nil {
t.Errorf("a node seat nobody holds there is held by %v", h)
}
// ADR 0571 clause 1: the builder's log, a start and an end, and the duration between them.
at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
start := BuildLogEntry{Event: BuildStartedEvent, Module: "letta", FP: "src2:a", Source: "abc", Repository: "novox/mesh-catalog",
Directory: "modules/letta", Commit: "abc123", Node: "ace", At: at}
end := start
end.Event, end.At, end.Outcome, end.Manifest, end.Against = BuildEndedEvent, at.Add(3*time.Minute), "done", "sha256:m", []string{"artifact-store://base/image@sha256:b"}
end.Made = []BuiltArtifact{{Name: "image", Kind: "image", Digest: "sha256:1", Reference: "artifact-store://letta/image@sha256:1"}}
for _, e := range []BuildLogEntry{start, end} {
raw, err := EncodeBuildLogEntry(e)
back, derr := DecodeBuildLogEntry(raw)
if err != nil || derr != nil || back.Event != e.Event || back.Outcome != e.Outcome || back.Directory != "modules/letta" ||
back.Repository != "novox/mesh-catalog" || back.Commit != "abc123" {
t.Errorf("%s read back as %+v, %v %v", e.Event, back, err, derr)
}
}
if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute {
t.Errorf("the build took %s, %v", d, ok)
}
older := end
older.At, older.FP = at.Add(-time.Hour), "src2:b"
other := end
other.Module = "mesh-catalog"
if n := NewestPerModule([]BuildLogEntry{older, end, other}); n["letta"].FP != "src2:a" || len(n) != 2 {
t.Errorf("each module's newest is %v", n)
}
if e := EndedSince([]BuildLogEntry{end, start, older}, at.Add(-2*time.Hour)); len(e) != 2 || e[0].FP != "src2:b" {
t.Errorf("the ended builds since are %v", e)
}
if k, err := BuildLogKey("letta"); k != "letta" || err != nil || BuildLogRead != "mesh-delivery-builder.build-log" {
t.Errorf("the key is %q, %v", k, err)
}
if _, err := BuildLogKey("a.b"); err == nil {
t.Error("a module with a dot was taken as a key")
}
for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" },
func(e *BuildLogEntry) { e.Event, e.Outcome, e.Against = BuildStartedEvent, "", nil }} {
bad := end
edit(&bad)
if _, err := EncodeBuildLogEntry(bad); err == nil {
t.Errorf("%+v was encoded", bad)
}
}
if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil {
t.Errorf("a held value's key is %q, %v", k, err)
}
for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} {
if got, err := ConfigCandidateKey(pull); got != want || (want == "") != (err != nil) {
t.Errorf("%s keyed %q, %v", pull, got, err)
}
}
}
func jsonOf(v any) []byte {
b, _ := json.Marshal(v)
return b
}
+72
View File
@@ -0,0 +1,72 @@
package delivery
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The data items of each module placed on a node, as its engine composed them** (novox/hq ADR 0577 clauses 1 and 4):
// the node-engine's state `data` (EngineDataBucket), one key `<node>.<module>` (EngineDataKey) per module placed on the
// node that declares data items, written by that node's engine alone, when the module's composition changes and only
// when the value differs, and deleted when the module leaves the node. That node's disk-load watches its node's keys
// (EngineDataNodeKeys) and measures what they name; it composes nothing itself. Only the contract is here: the state's
// names, its key and its value; the engine composes the items and disk-load measures them.
// EngineDataState, EngineDataBucket and EngineDataRead are the node-engine's state data, by its local name, as the bus
// names it and as a reader declares it.
const (
EngineDataState = "data"
EngineDataBucket = "mesh-host_" + EngineDataState
EngineDataRead = "mesh-host." + EngineDataState
)
// EngineDataVersion is the version of EngineData's layout.
const EngineDataVersion = 1
// EngineData is one key's value: the module's data items on the node as the engine composed them (each item's id, its
// path resolved on that node, its measure, class, protection and what covers it), and the digest of the module's
// composition they came from (ComposedDigestOf).
type EngineData struct {
V int `json:"v"`
Node string `json:"node"`
Module string `json:"module"`
Digest string `json:"digest"`
Items []catalogue.PlacedDataItem `json:"items"`
}
// EngineDataKey is a module's key on a node, `<node>.<module>`, its module part escaped as a have's is (HaveKey).
func EngineDataKey(node, module string) (string, error) { return HaveKey(node, module) }
// EngineDataNodeKeys is the pattern of every key of a node, `<node>.*`: what that node's disk-load watches.
func EngineDataNodeKeys(node string) (string, error) {
if err := checkName("node", node); err != nil {
return "", err
}
return node + ".*", nil
}
// EncodeEngineData writes a value at this layout's version.
func EncodeEngineData(d EngineData) ([]byte, error) {
d.V = EngineDataVersion
if _, err := EngineDataKey(d.Node, d.Module); err != nil {
return nil, err
}
if d.Items == nil {
d.Items = []catalogue.PlacedDataItem{}
}
return json.Marshal(d)
}
// DecodeEngineData reads a value; it refuses another version.
func DecodeEngineData(raw []byte) (EngineData, error) {
var d EngineData
if err := json.Unmarshal(raw, &d); err != nil {
return EngineData{}, fmt.Errorf("delivery: a module's data items are not readable: %w", err)
}
if d.V != EngineDataVersion {
return EngineData{}, fmt.Errorf("delivery: a module's data items of version %d, and this reads %d", d.V, EngineDataVersion)
}
return d, nil
}
+24 -3
View File
@@ -11,6 +11,10 @@ import (
// SourcePrefix2 opens every source fingerprint of the new form.
const SourcePrefix2 = "src2:"
// NoPackageVersion opens the reason a module has no fingerprint while a mesh package it uses has no published version
// the indexer holds (novox/hq ADR 0573 §2); the packages follow, by module path.
const NoPackageVersion = "no published version of the package "
// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the
// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the
// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only.
@@ -37,15 +41,19 @@ type SourceInputs2 struct {
Contexts map[string]string
Locks map[string]string
Toolchains map[string]Toolchain2
Unpinned string
// Packages are the version of each mesh package the module uses, by package (novox/hq ADR 0573 §2): a package with
// no version gives no fingerprint. None leaves the fingerprint as it was before packages entered it.
Packages map[string]string
Unpinned string
}
// SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs'
// lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is
// empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order
// of maps and a base given twice change nothing. The lines are "module <m>", "tree <t>" and "builder <fp>", then
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>" and "toolchain <artifact> <language>
// <version> <recipe>", sorted, all joined by a newline.
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>", "toolchain <artifact> <language>
// <version> <recipe>" and "package <package> <version>", sorted, all joined by a newline. A module that uses no mesh
// package has no package line, so its fingerprint is the one it had before packages entered (novox/hq ADR 0573 §2).
func SourceFingerprint2(in SourceInputs2) (fp, none string) {
if in.Unpinned != "" {
return "", in.Unpinned
@@ -58,6 +66,16 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
return "", "no lock"
}
}
var unpublished []string
for p, v := range in.Packages {
if v == "" {
unpublished = append(unpublished, p)
}
}
if len(unpublished) > 0 {
sort.Strings(unpublished)
return "", NoPackageVersion + strings.Join(unpublished, ", ")
}
lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder}
bases := map[string]bool{}
for _, b := range in.Bases {
@@ -75,6 +93,9 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
for a, t := range in.Toolchains {
lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe)
}
for p, v := range in.Packages {
lines = append(lines, "package "+p+" "+v)
}
sort.Strings(lines[3:])
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return SourcePrefix2 + hex.EncodeToString(sum[:]), ""
+14
View File
@@ -62,6 +62,9 @@ func TestFingerprint2Changes(t *testing.T) {
"a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" },
"a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" },
"a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} },
// Issue 1585 (ADR 0573 §2): a package's new published version, and a package used at all.
"a package added": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c1"} },
"a package version": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c2"} },
} {
in := inputs2()
f(&in)
@@ -111,6 +114,17 @@ func TestFingerprint2None(t *testing.T) {
if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" {
t.Errorf("no tree: fp = %q, none = %q", fp, none)
}
// Issue 1585: a package with no published version gives none, naming it; an empty set of packages changes nothing.
in = inputs2()
in.Packages = map[string]string{"git.novox.be/novox/b/go": "", "git.novox.be/novox/a/go": "", "git.novox.be/novox/c/go": "c1"}
if fp, none := SourceFingerprint2(in); fp != "" || none != NoPackageVersion+"git.novox.be/novox/a/go, git.novox.be/novox/b/go" {
t.Errorf("no package version: fp = %q, none = %q", fp, none)
}
in = inputs2()
in.Packages = map[string]string{}
if fp, _ := SourceFingerprint2(in); fp != mustFP2(t, inputs2()) {
t.Errorf("no package changes the fingerprint: %s", fp)
}
in = inputs2()
in.Tree, in.Unpinned = "", "a context is a Go program's build source"
if _, none := SourceFingerprint2(in); none != in.Unpinned {
+51
View File
@@ -1,6 +1,7 @@
package delivery
import (
"reflect"
"strings"
"testing"
)
@@ -33,6 +34,22 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
if got.Manifests["a"] != oddManifest || !SameForge(got, withV(f)) {
t.Errorf("the message changed on the way: %q", got.Manifests["a"])
}
// ADR 0565 §2 (issue 1953 D1): a merged message carries the predefined list exact, or says the merge holds none; a
// pull message carries neither.
f.Predefined = []byte(`{"domain":{"type":"string"}}`)
if data, err := EncodeForge(f); err != nil {
t.Fatal(err)
} else if got, err := DecodeForge(data); err != nil || string(got.Predefined) != `{"domain":{"type":"string"}}` {
t.Errorf("the predefined list read back as %s, %v", got.Predefined, err)
}
f.Predefined, f.PredefinedRead = nil, true
if _, err := EncodeForge(f); err != nil {
t.Errorf("a merge holding no predefined list: %v", err)
}
pull := Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: PullOpened, PredefinedRead: true}
if _, err := EncodeForge(pull); err == nil || !strings.Contains(err.Error(), "only a merged message") {
t.Errorf("a pull message carrying the predefined list: %v", err)
}
}
func withV(f Forge) Forge { f.V = Version; return f }
@@ -50,6 +67,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
// No repository holds placement (ADR 0567 §11): a message carrying placement files is refused, on a merge or a pull.
"placement files": {func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) }, "mesh-placement"},
"placements read": {func(f *Forge) { f.RetiredRead = true }, "mesh-placement"},
// ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both.
"predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"},
"predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"},
} {
f := mergedWithInputs()
tc.mut(&f)
@@ -144,4 +164,35 @@ func TestModuleKeepsInputs2(t *testing.T) {
if _, err := EncodeModule(m); err == nil {
t.Error("Inputs2 without a manifest digest was encoded")
}
// Issue 1585 (ADR 0573 §2): the mesh packages a go.sum names, those of other repositories, and the latest published
// version of each: the trunk commit of the deepest module of its repository whose directory holds it.
sum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:a=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:b=\n" +
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:c=\n" +
"github.com/nats-io/nats.go v1.0.0 h1:d=\ngit.novox.be/novox/mesh-host v0.0.0-1 h1:e=\n"
pkgs := MeshPackagesOf([]byte(sum))
if want := []string{"git.novox.be/novox/mesh-controller/modules/route-proxy/contract", "git.novox.be/novox/mesh-host", "git.novox.be/novox/mesh-sdk/go"}; !reflect.DeepEqual(pkgs, want) {
t.Errorf("the packages of a go.sum: %v, want %v", pkgs, want)
}
if got := OtherPackages(pkgs, "novox/mesh-controller"); len(got) != 2 || got[0] != "git.novox.be/novox/mesh-host" {
t.Errorf("the packages of other repositories: %v", got)
}
held := map[string]Module{
"mesh-sdk": {Repository: "novox/mesh-sdk", Path: RootPath, Commit: "c-sdk"},
"route-proxy": {Repository: "novox/mesh-controller", Path: "modules/route-proxy", Commit: "c-rp"},
"controller": {Repository: "novox/mesh-controller", Path: RootPath, Commit: "c-ctl"},
"route-proxy2": {Repository: "novox/mesh-controller", Path: "modules/route", Commit: "c-wrong"},
"no-path": {Repository: "novox/mesh-host", Commit: "c-host"},
}
versions, missing := PackageVersionsOf(pkgs, held)
if want := (PackageVersions{"git.novox.be/novox/mesh-sdk/go": "c-sdk", "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": "c-rp"}); !reflect.DeepEqual(versions, want) {
t.Errorf("versions %v, want %v", versions, want)
}
if len(missing) != 1 || missing[0] != "git.novox.be/novox/mesh-host" {
t.Errorf("missing %v: a module that names no directory holds no package", missing)
}
kept.Packages = []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-host"}
if fp, none := SourceFingerprint2(kept.WithPackages("m", tc, versions)); fp != "" || !strings.HasPrefix(none, NoPackageVersion) {
t.Errorf("a package with no version: %q %q", fp, none)
}
}

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