Compare commits

..
Author SHA1 Message Date
jschoubben fd9bf5e30a mesh-sdk: the bootstrapped mesh-delivery-checker follows mesh-delivery 432df759, copied whole (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 20:51:21 +01:00
jschoubben 4cf9b72146 mesh-sdk: the bootstrapped mesh-delivery-checker is the contracts' seat at mesh-delivery 03809d8d, where try gains members (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 20:47:59 +01:00
jschoubben cffabdf21d mesh-sdk: the refusal of contracts modules and domain SDKs reads every path segment, so a folder module counts (ADR 0635 §1, ADR 0636 §8; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 19:42:00 +01:00
jschoubben a3fee9f85b Merge branch 'b9j/registry' into feat/registry-milestone 2026-11-05 19:26:06 +01:00
jschoubben 7cdcc34a6f RemovedDeliveryVerbs leaves mesh-sdk for the seat mesh-delivery's client (ADR 0634 section 2, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:45:50 +01:00
jschoubben 9c1f52a902 The wanted accounts' layout leaves mesh-sdk for the grants part's package grantsstate (ADR 0634 section 1, ADR 0635 section 4, issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:43:21 +01:00
jschoubben fa56961ae8 mesh-sdk: refuse every contracts module and domain SDK, module names in the shared packages; the bootstrap equals its contracts' files, one row per seat (ADR 0635 §1, ADR 0636 §8; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 18:27:48 +01:00
jschoubben 0d3a10ff20 catalogue: the registry reference forms and ArtifactSweepSeat leave for the registry clients and the sweep seat's contract; seat-names keeps one rule's seat (S-8; issue 1966)
Resolve keeps its own address strip (kept, ADR 0155), which needs no seat name. The one seat name left is
ManifestNeeds' store holder (ADR 0632 §2), beside its rule in manifest_reads.go.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:38:04 +01:00
jschoubben cabd3c0a8d catalogue: PublishSeats and the npm and go registry seat names go (no user); seat-names shrinks (S-8)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:20:14 +01:00
jschoubben 7f34fc0fa4 Delete health's condition and findings: no importer is left; the module answer stays (S-7, ADR 0634 §5)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:18:52 +01:00
jschoubben 4099164aad Delete go/overlay: no importer is left; the package list, notInSDK and seat-names follow (S-7, ADR 0634 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:18:36 +01:00
jschoubben 14f956e4cd Delete go/delivery: moved whole to mesh-delivery's SDK and joined; the package list, notInSDK, untilTheJoin and seat-names follow (S-7, ADR 0634 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:18:12 +01:00
jschoubben 1465a8a831 Delete go/buildstep: no importer is left in the joined mesh-delivery; the package list follows (S-7, ADR 0634 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:17:55 +01:00
jschoubben 39ac1917de Delete go/provisions: no importer is left in any tree; the list of packages and notInSDK follow (S-7, ADR 0634 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:05:04 +01:00
jschoubben 8b6da23def Delete go/channel: no importer is left in any tree; the list of packages and notInSDK follow (S-7, ADR 0634 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:05:03 +01:00
jschoubben de8c881cb6 Delete go/handacts: no importer is left in any tree; the list of packages and notInSDK follow (S-7, ADR 0634 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:05:02 +01:00
jschoubben 15b65445c8 buscompose: BusRemovalGrants, ControllerLeftover* and WatchmanHears go, the grants part's busgrants holds them (ADR 0634 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:04:28 +01:00
jschoubben 1815fd11b9 membership: CredentialIn and BareAddress go, no caller in any tree (ADR 0635 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 17:04:01 +01:00
jschoubben 59f8659837 Package list test: composer, parts and provider may not come back (S-8 part, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:42:10 +01:00
jschoubben b8eb1afd14 health: readings.go (disk-load's readings, restic's last good night) leaves, held by disk-load's diskreadings and restic's backupgood (S-7 part, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:41:53 +01:00
jschoubben d0563ed7c1 buscompose: VaultSeat, AccountIssuedSubject and ResolvedBucket leave (no importer; the grants part's copy names them), with the untilTheJoin and seat-names entries (S-5 first part, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:40:52 +01:00
jschoubben 7a142c9b5b forgekey keeps the command runner: the key keeper and key parsing are ssh-client's sshkeys, KindBuilder, ExecRunnerClean and Said the delivery sdk's (S-3, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:39:07 +01:00
jschoubben 989a3c6670 Delete go/provider and its harness: the provisioner's client holds it, no importer left on any join head (S-7, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:37:59 +01:00
jschoubben 20495478b6 Delete go/parts: delivery sdk's parts holds it, no importer left on any join head (S-7, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:37:57 +01:00
jschoubben c84a2e58e8 Delete go/composer: delivery sdk's composer holds it, no importer left on any join head (S-7, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:37:56 +01:00
jschoubben a25c600810 seat list: ReloadIfUnread reads the list again once when a host's events path is live; event hub: StartUntilBound binds a late consumer without a restart (B9e; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 16:19:26 +01:00
jschoubben 9489385165 seat list: the bus of a process on its own credential (a verb called through its connection, the seat's events as handlers of the module's event hub), so the checker and the grants part read the list as every part does (B7e; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:35:19 +01:00
jschoubben 4111794690 event hub: a start that found no durable on the bus is tried again at the next start, so a part is not lost for the order in which the bus makes its consumer (B7e; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:25:56 +01:00
jschoubben b7f6cd6bf4 seat list: the mesh-catalog definition, in the bootstrap and the test list, lists manifest-accepted and manifest-refused like the contract (B7d finding 1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:24:52 +01:00
jschoubben 7aea59a789 seat list: the node-engine definition follows the contract's lists in a node's facts (copies kept equal; ADR 0636 §7; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:19:14 +01:00
jschoubben 52ab209fcf seatlisttest equals the contracts on the join heads (renamed seats mesh-node-enroller, mesh-module-assigner; domain; B2's delivery seats); the bootstrap is held equal to it by a test; the compiled-list golden is gone (B7c finding 2; ADR 0634 §9, ADR 0636 §7; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:10:17 +01:00
jschoubben e648fe5374 process: one EventHub pulls the module's one durable consumer, handlers by subject pattern; the views reader registers on it instead of pulling (class fix of B7c finding 1; ADR 0635 §5; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 15:07:39 +01:00
jschoubben 7ea9572f03 keys: the version check, CheckName and IsHex64 move here from the Delivery domain's kept package, since Delivery and Identity and access share them (ADR 0634 §1, §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:40:09 +01:00
jschoubben e67f85b5fe seatlist: one test of the changed event heard before the seat answered, not two (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:37:53 +01:00
jschoubben 02fe0a015b views: an owner's changes are ordinary events on the shared events stream, heard on the reader's one durable consumer; Follow takes no stream, Other takes the rest (ADR 0635 §5, D-25; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:36:41 +01:00
jschoubben 129dc8cffb seatlist bootstrap: node-engine with its verbs seat-list and seat-list-again and its state findings, mesh-catalog as B2's definition now holds it; every bootstrap file equals its contracts file (ADR 0636 §2, §7; issue 1966) 2026-11-05 14:35:20 +01:00
jschoubben 2b9cdb5771 seatlist: Start returns at once and reads the seat on a goroutine; Ready says when the first read ended, Status says it is reading (ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:35:19 +01:00
jschoubben 99e6badabf seatlist bootstrap: mesh-catalog (with seat-definitions and its two events) and mesh-delivery-checker as the delivery contracts define them, plus the domain they lack (ADR 0636 §2; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:32:38 +01:00
jschoubben 9fb2c42138 seatlist: a reader started while the seat was silent is heard at the seat's changed event, which reads the whole list (ADR 0636 §1; issue 1966) 2026-11-05 14:32:25 +01:00
jschoubben b81622842b seatlist: an event heard before the seat ever answered reads the whole list (issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:32:19 +01:00
jschoubben e0d2298f58 seatlist: the again tool answers a VerbCallers-style list, so the operator's sessions can be named (ADR 0636 §7; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-05 14:31:27 +01:00
jschoubben a0480588ce seatdef: a definition names its glossary domain, one of the eleven headings (ADR 0636 §5; issue 1966) 2026-11-05 14:30:56 +01:00
jschoubben 8dcdc9b2a4 go/motion: whether a module is being delivered on a node, over plain want and have values; read by the delivery view and the condition keeper (S-X1, ADR 0634 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:46:01 +01:00
jschoubben b7ad47ab2a Seat list reader: an integration case over a real bus, from the bootstrap alone to the seat's answer and its events (ADR 0636 §8; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:45:30 +01:00
jschoubben 351f8abba7 views: the hand-act recorder and the finding writer come from the caller as interfaces, no hand-acts or health import (ADR 0635 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:44:38 +01:00
jschoubben e8ae3ec8d3 go/keys: the version of a kept value and the key-part escaping that more than one domain shares (ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:42:59 +01:00
jschoubben b611bb9ad5 Seat list reader: calls the seat mesh-catalog's seat-definitions and hears its changed and removed events, acts on nothing new until read, two runtime tools; bootstrap gains mesh-conditions, operator-channel and the contracts' current files (ADR 0636 §1-§4, §7; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 13:42:20 +01:00
jschoubben 1aca4991d4 mesh-sdk compiles no seat list and imports no contracts module: catalogue takes its seats from the host, whole seats' names leave it (ADR 0635 §1, §4; issue 1966)
catalogue's working set starts empty and a host gives it the seats it read (UseSeats over SeatsFromDefinitions);
the node-engine's verbs and events take the seat's name from the caller. Tests take their seats from seatlisttest.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 12:54:23 +01:00
jschoubben 7ea960fe20 Seat list reader: its status for a module's health, and the foundation's bootstrapped definitions (ADR 0635 §3; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 12:49:36 +01:00
jschoubben 50258215b4 Seatdef: the templates test moves to an external test package, the catalogue now reads definitions (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 12:47:46 +01:00
jschoubben 651c28e70d Seat definition format: states replace records (templates, writer, key forms with value schemas; ADR 0608 §1, ADR 0634 §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 12:47:26 +01:00
jschoubben 863b909504 Seat list reader: the seat definitions read from the bus, followed on seat-definition-changed (ADR 0635 §1, §3; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-05 12:47:26 +01:00
jschoubben 2e4eb0be90 Catalogue: a module's needs include its templates' text (ADR 0632 §2; issue 1966)
docker's daemon file and artifact-store-tools' store address move to the image
seat's reach in their templates while their manifests stay the same, so needs
read from the manifest alone let an engine before the reading lose the file.
ModuleNeeds reads the templates TemplatePaths names; the merge message carries
the result per directory for the indexer.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 21:37:43 +01:00
jschoubben ecdbf1a668 Catalogue: the store holder's manifest needs form:seat-reach (ADR 0632 §2; issue 1966)
It is read on every node and leaves an engine before the reading without the
old way to find the store.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 21:03:59 +01:00
jschoubben 8e7001509a Catalogue: the store's seats deliver nothing, and a seat's reach is the reader capability form:seat-reach (ADR 0632 §1, §2; issue 1966)
CanHold asks the store's holder no provision once the seats stop delivering
artifact-store; a manifest naming ${seat:<seat>:reach} needs an engine that
fills it from the seat holder's listen, so the delivery holds it from one
that does not.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 20:53:50 +01:00
jschoubben bbf1166436 Put back the indirect requirements SDK-8 raised for no change of its own: antithesis-sdk-go v0.8.0 needs modules the running node-engine's composer cannot find offline (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 19:46:58 +01:00
jschoubben 05b3e2e1b9 Require mesh-contracts/go at ae5266e77639, the group's final contracts head (issue 1966)
mesh/delivery ready: it is merged on the Approve
mesh/check failed (7): ace: novox/mesh-catalog#612 does not merge into the trunk: files in conflict: module…; and 6 more | 05b3e2e1b94f on 5a38a1221659
mesh/delivery-group group feat/registry-milestone rejected: composed together: the checker's verdict at the members' heads (3a038b9f8b4d): ace
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:43:59 +01:00
jschoubben f94abb95aa R3-F-sdk: the first change said after a restart clears a view-unsaid finding left standing
raised started false on every run of follow, so a finding a run before a restart raised was never cleared.
A flag of its own clears it once at the first change said; the raise keeps its own flag, so a change not
said in the new run is still raised past its bound.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:41:18 +01:00
jschoubben e5d89c5eed Require mesh-contracts/go at 347a3053580a, the group's contracts head after review round 2 (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:22:36 +01:00
jschoubben 3526e9ef2a R2-G-sdk-contracts 2: mesh-contracts/go is a direct requirement
catalogue imports the seat set, so the requirement belongs in the direct block; its version is unchanged
(the group's re-pin follows).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:17:12 +01:00
jschoubben be1ffdfedb R2-G-sdk-contracts 1: a view change not said past its bound is the owner's finding
A change said again and again without the bus taking it left every reader standing still with only a log
line. Past UnsaidAfter (Bound by default) the emitter writes the owner's finding view-unsaid naming the
state, as it does for a value too large, and clears it once the change is said.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:16:54 +01:00
jschoubben d420491ca1 R2-G-sdk-contracts 4: a state may be declared read-only
Only the processes whose own bus.state names such a state write it; the module's tools read it and
never write it. nats declares its warrant spend record so, and the judge's rule and the grants build
on the field. Decoded and written back; given twice it is refused as every field is.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:13:41 +01:00
jschoubben 232f38de99 R2-G-catalog-B 5: the idle watch looks at every open transfer once it is taken
A transfer whose idle key ended while the holder's bundle was down had its marker seen by no watch, so it
stayed open for good. FollowIdle now hands every open transfer (IDs) to Expired once the watch is taken:
one past its bound is dropped, one within it armed again for the rest.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:09:37 +01:00
jschoubben c54648e743 Require mesh-contracts/go at ec9529022c44, the group's contracts head, whose seat list the catalogue reads (issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:40:16 +01:00
jschoubben 719956b745 R1-F-parts: the receiving side keeps a transfer's idle key and reads its cap from the binding
distribution and gitea each carried the same idle bound (one expiring key per open transfer, its end
dropping a transfer idle for 24 hours or arming it again for the rest) and the same read of incoming's
cap from its binding: the second instance of the kind, so it moves to the Receiver every holder shares.
Arm is called on each open that leaves a transfer open, a key not written failing it loudly; Expired
handles a key's end; Bound reads the binding's cap over Cap. BusArm, BusCap and FollowIdle bind them to
the holder's own state and objects.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:24:55 +01:00
jschoubben eb2978b3ce R1-F-sdk 5: catalogue.Decode refuses a manifest giving one field twice, naming its path
A decoder keeps the last of two equal keys without a word, so the indexer's judge read a manifest a
merge of two branches had given one field twice as if one side never was. mesh-catalog's
checks/manifests refuses it already; Decode now refuses it the same way, at any depth.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:58:09 +01:00
jschoubben 5e67b548a0 R1-F-sdk 4: one rule every window keeper steps by, and a read naming no node closes none
distribution's two windows and the builder's old-form window each closed for good on a read that named
no node (an empty node_haves, an unread placement): the second of a kind (security-upgrade review F3).
delivery.StepWindow over delivery.WindowHeld is the one rule both call; a read naming no node judges
nothing, as a first view's empty answer at revision 0 (ADR 0615).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:24:02 +01:00
jschoubben aef9957450 R1-F-sdk 2: a view event not said is said again before the mark moves past it
A failed say was logged and passed over, and the next change's mark moved past it, so a reader
missed the change for good (design 25 §12.2). The emitter now says the same change again on a
backoff until it is said or its context ends.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:22:18 +01:00
jschoubben c4de50e91e R1-F-sdk 1, 3: no test imports a seat's contract module, and the guard reads test imports
The import guard skipped _test.go files, so holdings_test.go's import of the mesh-broker seat would have
reached mesh-sdk's go.mod at the pin (ADR 0625 §1, §3). The vault entries' comment says they stay to the
next milestone (issue 1966 note 28).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 16:21:05 +01:00
jschoubben 64e83c7ee2 J8b: catalogue reads the holder's upstream naming, and a done key may name an upstream copy by it
An upstream copy kept by the artifact store is named by the holder, upstream/<host>/<path>@<digest>, in the seat form
(container-image-registry/upstream/...) and the older one (artifact-store://upstream/...). InRegistrySeat reads it, InUpstream
reads its host and path, and referenceProblem accepts it for an upstream artifact, so readers stop reading it on their own.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:58:59 +01:00
jschoubben 7eba326d3d J7 (2): a judge request carries the known packages in a field of its own
The names a manifest's packages may name travelled in the request's Input, whose meaning is the composition's input;
Packages says them plainly, nil being no list and an empty list a list.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:40:49 +01:00
jschoubben 0cc28f5b1f J4 (J3 item 7): merge A3's wip/registry-a3-sdk (c974a11): a holder's receiving side of a file in parts joins its sender
Brings A3's parts receiver and bus adapters, the shared caller lists and builder versions question, a transfer read
back from bus state (0424e87) and catalogue.SeatReference (c10ef07). c974a11 adds nothing: its version_numbered.go
and BuildAnswer.OutOfStep are the join's own. publish stays out (ADR 0627, 1a149d4): packages_list_test keeps the
join's form. TestAHolderTakesAFileInParts is a subtest of TestAFileTravelsInTheParts, so unit tests stay at 476.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:12:03 +01:00
jschoubben c974a11c97 J3 pick for A3p: delivery.VersionNumbered and BuildAnswer.OutOfStep as join-mesh-sdk 712b21b has them
Only version_numbered.go and stepasks.go of 712b21b, so mesh-build-go builds against them
before the join; identical to the join's, dropped there.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:58:14 +01:00
jschoubben 09cf1ea6fc J4 (lead's item): the packages state's series value is a contract its readers take, not the indexer's own type
delivery.SeriesPackage and SeriesVersion, field for field as the indexer writes them (name, the ecosystem name;
package, <module>/<artifact>), with SeriesPackageKey, IsSeriesPackageKey and a checked encode and decode.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:54:33 +01:00
jschoubben 28ce325c2e J4 (A2j finding 2): a verdict names each series change the heads make, so none is lost when the status line is cut
delivery.Verdict carries series, each {module, artifact (or the package's ecosystem name), from, to}, refused when
a field is empty, it changes nothing, or one artifact is named twice.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:53:01 +01:00
jschoubben fe83d66b62 J4 (A2j finding 1): an owner's read tool answers a key's kept history, so a reader sees what changed before its first view
The view's module history, heard messages and a want's since asked the owners' read tools for a key's history and
were refused: views.ReadTool takes key with history and answers {history: [{revision, at, op, value}]} from the
state's kept values (KeptSource through mesh/state.history; viewstest.Source and Serve from the bucket).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:48:21 +01:00
jschoubben 712b21bd74 J3 (A3n finding 3, A2k finding 1): the builder's version.numbered is a contract, and a build's answer names its files out of step
delivery.VersionNumbered with VersionNumberedEvent and VersionNumberedSubject is the body the builder says when it
numbers a version, so a build module consumes it from mesh-sdk instead of the builder's internal package.
BuildAnswer gains out-of-step, so the builder decodes a trunk build's compare by its field, not by name.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:42:35 +01:00
jschoubben dae61959b9 J3 (lead's a): go/views names no module; its caller gives the owner's read tool and views stream
ProcessReader.Follow(owner, state, tool, stream) takes the owner's read tool and its views stream as the owner's
declaration names them, and refuses another owner's tool or a second stream for one owner; OwnerReadTool, StreamOf,
IndexerStream and AssignerStream are gone, and viewstest takes the same two values. The second instance of a module
name in shared code (the earlier review finding), so the package test now refuses a module's name in go/views.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:35:56 +01:00
jschoubben 4827df4d48 J3 (lead's b): two leaves of one module are both kept, the empty accepted event named by its revision
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:29:56 +01:00
jschoubben c10ef07ea3 ADR 0605 §9: catalogue.SeatReference names what a build published in the seat's form, the inverse of InRegistrySeat
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:29:35 +01:00
jschoubben 3218da4ee4 J3: mesh-sdk within its test budget, each file of declaration and the provider harness one test of subtests
The unit test functions fall from 587 to 476 (the budget is 500): the cases of each of twenty test files in
declaration and provider/harness become subtests of one test named for the file, under their own names, their bodies
unchanged. Integration tests stay at 30 (budget 50).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:28:23 +01:00
jschoubben 635aed57fd J3 (A2i finding 2): a build's end names the generated files its test step found out of step
delivery.BuildLogEntry gains out-of-step, an end's alone, as the build module's test step answers it, so the indexer
reads the field from the entry, not the raw message, and its trunk finding can be raised once the builder writes it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:26:26 +01:00
jschoubben 1a3ef8101f J3 (A2f findings 2, 4): a leaving module's accepted event is named by its module; a user naming no series accepts the first numbered one
ManifestAccepted.EventID names the module when the digest is empty, so the indexer's own id for a module leaving the
trunk can go and the id lives in one place. Manifest.AcceptedSeries takes the series the package has a numbered
version in and answers the lowest, the series of its first numbered version (design 32), DefaultSeries while none is.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:25:25 +01:00
jschoubben 5bb0662d19 J3 (A2g ruling): a reader's again tool records its hand act through mesh-hand-acts before it asks
StatusTools takes the reader's handacts.Recorder: again records the act through seat:mesh-hand-acts.hand-act before
it writes the hand ask, and refuses, asking nothing, when the seat does not take it, as the stdio Reader's again does.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:23:40 +01:00
jschoubben 315d7e9b8c J3 (A2g, A2h): a process's pictures of its owners' states are go/views', and a first view says when each key was written
ProcessReader and Picture (A2g's mesh-delivery sdk/firstview at c6c6687) and the test owner (viewstest) move into
go/views, so the builder, checker, teller, view and watchman read the indexer's and the assigner's states through the
one shared follower. The first view's answer carries each key's written time as the bus keeps it, and
Follower.Changed hands it on, so a reader's want time is the owner's write, not its own start (A2h's request).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:22:20 +01:00
jschoubben 0424e87399 ADR 0627 §2: a transfer read back from the bus takes its first chunk
A transfer is kept in a key-value state as JSON, and its empty Chunks map is omitted, so the one
read back for the first chunk had none and Take panicked: every publish in parts through
BusTransfers. The unit tests' transfers now round-trip JSON as the bus does. Found by the forge
module's whole test (CAT-G3).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:19:21 +01:00
jschoubben f9e035fbee J1: a failed have carries the pull it waits on (ENG-9, the lead's ruling on pull-waits; local only) 2026-11-04 14:17:29 +01:00
jschoubben 85b31412b9 J1: node-engine's compose-candidate checks leave mesh-sdk for its client; pull-waits leaves the seat-set row (local only) 2026-11-04 14:17:29 +01:00
jschoubben 3273f5b219 Join: L3's mesh-sdk (f30a53b2) onto A1's wip/registry-a1 (c5fc2ee)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:17:29 +01:00
jschoubben 54e1ecce45 ADR 0627 §2: a holder reads its caller list as mesh-config resolved it, not from an environment nothing sets
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:40:29 +01:00
jschoubben c5fc2ee859 DLV-T (A2d): a fetch request's package named at a Go semantic version is taken, a branch name refused
The row of buildstep that refused v0.1.7 now refuses main: c1d7218 lets PackageVersions take a group's tagged version.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:35:11 +01:00
jschoubben a0465bf0b7 ADR 0625 §10: the README says a seat's code is mesh-contracts', and what mesh-sdk keeps
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:34:50 +01:00
jschoubben e802e67f9a ADR 0627 §2: a holder's caller list per verb and its question to the builder are shared, not copied per holder
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:32:20 +01:00
jschoubben c1d7218b32 DLV-T (A2d): a package's version is a full commit or a Go semantic version, a group's tagged version
delivery.PackageVersions.Check takes v0.1.15-feat-x.2 beside a full commit (ADR 0607 §3, §5): a delivery group's
check names the group's tagged version. A2d's patch, whole.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:31:38 +01:00
jschoubben a410447ff5 SD-M1: a holder's event kept in its own stream is refused under its module's subjects
A family on a holder's event names the holder's own stream, so its subject is mesh.stream.<holder>.event.<event>;
operator-channel's offered and closed said the router's stream but mesh.mod subjects.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:31:16 +01:00
jschoubben 7faa4a1b20 ADR 0627 §1: the vault's answers, verbs and signed event are its contract's, not mesh-sdk's
SignAnswer, Signed, EnrolmentTokenAnswer, NodeAccountAnswer and the names SignVerb, SignedEvent, SignedSubject,
EnrolmentTokenVerb, NodeAccountVerb go; mesh-contracts meshvault holds them. The signers state, the kinds, the
signing contexts and the redeem stay: they are the vault module's and the enrolment's, not the seat's.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:29:47 +01:00
jschoubben 44a97d9ad6 ADR 0627 §2: gofmt the bus adapters
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:27:21 +01:00
jschoubben ce592a94d0 ADR 0627 §2: a holder binds the receiving side to its own objects and transfers state through the runtime
Every holder of a registry seat needs the same two adapters over its bus state; kept here so none writes its own.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:27:16 +01:00
jschoubben 5bc1bf9f0a ADR 0627 §4: asks and asker leave mesh-sdk for operator-channel's client; the channel's benches stay as package channel
The gate's and the assigner's asks carry the router's ask as its JSON, so mesh-sdk names no type of a seat
defined whole; the ask's id, options, digest and an option's level are read by the gate, the assigner and the
teller through operatorchannelclient. ViewAnswerFits keeps the asking's check, from the key. The channel and
intake benches are not defined whole: their shapes move to go/channel (ADR 0625 §9).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:27:00 +01:00
jschoubben 772b441be3 ADR 0627 §2: a refusal for want of room is told from the others, so a holder's finding for it can clear
A holder raises a finding when it refuses a file for want of room; that finding is the holder's, not the file's,
and clears once it takes a file again. AtCap and RefusedAtCap let it tell that refusal apart without its own copy
of the reason's words.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:24:40 +01:00
jschoubben d15c1bc814 ADR 0625 §3, ADR 0627 §4: mesh-sdk imports the seat set in catalogue alone, and names no client seat outside it
buscompose, delivery's builder and step asks, handacts, health and provisions take their seat names from catalogue.
The package-list test refuses any other contracts import, the client seats' names outside catalogue, and the moved
packages (asks, asker, publish) by name; what the group's join still moves (asks, asker, the vault module's name in
delivery and provisions) is listed and only shrinks.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:20:35 +01:00
jschoubben de3af0cc65 buscalls.Unwrap: one reading of a tool's answer whatever wraps it
A JSON string, the content list, {output, ok, answer} or the tool runner's {result, error}: mesh-config's
unwrapAnswer and the distribution's copy take it, and views.AskVia reads through it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:18:32 +01:00
jschoubben 3249b30e8d SD-M1: a holder's event is under its module's subjects, or its own stream's when a family keeps it
The definition reader refuses any other subject, and a mesh.stream.<holder> subject no family names (A2c's
finding: the checker's events in its own stream were written as mesh.mod.).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:17:26 +01:00
jschoubben 7efb17386d Build log: a build narrates between its start and its end (build.said)
Its module, fingerprint, line and why, as design 51 has a build narrate its publish; a start or end carries no
line (A3g's finding).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:14:43 +01:00
jschoubben 8d883c373e ADR 0627 §2: the receiving side of a file in parts is seat-neutral runtime beside its sender
parts.Receiver takes a transfer's chunks into the holder's own objects under the caller's prefix, checks each whole
part's hash, drops a transfer left idle and tells the caller the objects' fill; the opening's shape, the check before
storing, the storing and the saying of a refusal are the caller's, so it names no seat, verb or seat type and
distribution and the forge's two package seats bind it to the seats they hold.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:13:51 +01:00
jschoubben 1a149d44be ADR 0627 §2: the sending of a file in parts is seat-neutral runtime; publish leaves mesh-sdk
parts takes the open and the chunk calls from its caller and names no seat, verb or seat type; each registry
seat's client in mesh-contracts binds it to its own seat (ADR 0627 §1).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:09:19 +01:00
jschoubben e39faa0f38 SDK-1: the parts helper reads the registry part's shapes from a registry seat's module
Interim, for the lead's ruling: ADR 0625 §1 cuts the registry part into each registry seat's own Go module, and §3
lets no mesh-sdk package but catalogue import a contracts module; this helper is logic shared over a seat, a client
in §4's sense, which no clause lets the registry milestone build. Kept building meanwhile.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:54:26 +01:00
jschoubben 293df49377 SD-M1 sdk: a seat's family is the shared events stream's, 7 days, no cap of its own
The lead's ruling of 13:0x replaces 12:5x's said-subject: mesh-broker's events follow every seat, on
mesh.seat.mesh-broker.event.<event> in the shared events stream, 7 days; the bus module keeps a longer history on a
stream of its own module. So every seat family is that stream filtered to the seat's subjects: a definition says 7
days and no cap of its own, and the reader refuses anything else.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:53:19 +01:00
jschoubben fc50486190 views: an ask through a tool reads the tool runner's {result, error} envelope
A5's request: the bus module's keeper asks the indexer's first view through the tool runner, which answers in that
envelope; its error is the ask's.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:51:56 +01:00
jschoubben 0b25bce239 ADR 0626 §1: a process's bus may say keeps, for the bus module's keeper
A2b's judge test waits on it: the strict decoder refused the key, so the bus module's new manifest did not decode.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:50:41 +01:00
jschoubben 6fb778a889 SDK-A6: a bundle hears its consumer's heartbeat between events, OnHeartbeat
A6's request: the knowledge base's page marks a source unanswered from its subscription; with events alone a quiet
source and a lost subscription look the same. The runtime sends mesh/heartbeat at the bus's idle heartbeats, and once
with missed when they stop; the tool runner's side is a request to its owner.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:45:52 +01:00
jschoubben 3622c129f8 SDK-A6: a tool reads its call's id from the runtime, CallIDOf
A6's request: the ledger's apply names the call it serves (ADR 0597 §7) so the runtime stamps the caller it proved as
the operation's author; the id is read from the call's _meta under mesh/call, never from the arguments.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:44:16 +01:00
jschoubben f30a53b242 A compose-candidate answer's open input keeps its numbers as written when decoded
The answer's input is now the object the node-engine seat's contract names, so a decoder without UseNumber turns its
numbers into float64 and a checker re-encoding it for the composer would hand on different digits.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:43:11 +01:00
jschoubben c515074714 SD-M2d sdk: a seat family on the shared events stream keeps no longer than the stream
Holds operator-channel's decided to the shared stream's 7 days (the lead's ruling of 12:5x), and each registry seat's
family with it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:42:57 +01:00
jschoubben e1f4ac1050 SD-M2b sdk: a seat's event family may name its own subject token
The lead's ruling of 12:5x (design 25 § 12.3): mesh-broker's events ride the bus module's own stream, 30 days,
on mesh.seat.mesh-broker.said.<event>, outside the shared events stream's mesh.seat.*.event.>, so the bus never
refuses the two as overlapping.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:42:16 +01:00
jschoubben 42ac1a9a0a mesh-sdk's node-engine names are the seat's generated contract, kept deprecated for their importers, with their checks
The seat is defined whole in mesh-contracts (ADR 0613 §3), so mesh-sdk takes its names from there (ADR 0613 §5):
EngineSeat and EngineEvents are nodeengine's, EngineComposeCandidateVerb is nodeengine.VerbComposeCandidate, and
ComposeCandidateAsk, ComposeCandidateAnswer and ModuleComposed are aliases of nodeengine's ComposeCandidateInput,
ComposeCandidateAnswer and Module. The encode and decode checks stay here over those types until the seat has a client
package (ADR 0625 §4). For importers: a module's fingerprint is Fp, and the answer's Input is a JSON object
(map[string]any), not raw bytes. compose-candidate's input is the boolean the engine and the checker read; the seat
set's test takes that correction, and the whole seat's added verbs as da653ce reads them. EngineSeat leaves
testdata/seat-names.txt.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:40:03 +01:00
jschoubben effd0c00a6 The seat set's test takes node-engine as a seat defined whole, with ADR 0618's manifest-reads among its added verbs
mesh-contracts now defines the node-engine seat whole (ADR 0613 §3): its verbs carry answers and it says
manifest-reads-changed, so the set compares it as it compares every whole seat. Nothing else here is redundant: the
seat's hand-written list went in SDK-8, and the compose-candidate layouts keep their checks, which a generated package
does not carry, until the seat's client package exists (ADR 0625 §2, proposed).

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:40:03 +01:00
jschoubben 1027803e1d A build names its needs and every form of an artifact (ADR 0618 §1, ADR 0624; issue 774)
BuilderBuild.Needs (form:<name>, done keys only) and BuiltArtifact.References (Reference among them, each checked as Reference is) on the delivery layouts mesh-sdk keeps until that area moves (ADR 0613 §3). The node-engine seat's verb and event of ADR 0618 go into mesh-contracts with the seat, not here (ADR 0613 §5).

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 12:40:03 +01:00
jschoubben 3ff694e425 DLV-O4 sdk: manifest-accepted and manifest-refused carry the indexer's revision (ADR 0622 §2)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:35:14 +01:00
jschoubben da653ce593 SDK-8: the seat set's test reads a whole seat's new optional verbs as additions
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:33:59 +01:00
jschoubben 0bb3a2c392 SDK-1: the runtime sends a file to a registry seat in parts and goes on from what the holder holds (ADR 0611, design 51)
publish.Publish opens the transfer naming every part's digest and sends only the parts the holder answers it lacks, in
chunks of at most 760 KiB (a message under the bus's 1 MiB once base64), in sequence with the last marked; a part
whose bytes are not its digest is refused before any of it travels. The seat's messages are mesh-contracts'; the call
is the caller's own. Its test takes the place of the vault's get test, merged into the vault's table.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:27:15 +01:00
jschoubben 253bd06970 SDK-9: mesh-sdk takes no new seat-specific name (ADR 0613 §5, design 26 *Contracts modules*)
Every seat constant of mesh-sdk is mesh-contracts' generated name; the four whole seats' (BrokerSeat, CheckerSeat,
asks.Seat, buscompose.VaultSeat) are marked deprecated for their importers. The package walk of mesh-sdk's own check
refuses a string literal naming a defined seat or its subjects, and an exported name given to one, beyond
testdata/seat-names.txt, which lists what stands before the move (module names that equal a seat's among them) and
only shrinks. No new test function.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:25:21 +01:00
jschoubben 6bfd14b14b CAT-RD1..RD10 sdk: views.Reader, a module's pictures of the owners' states (A8's patch 08da702)
Keys, Get, Values and Watch over the views followers, the first-view tools, the finding past the bound, and the re-read
recorded through mesh-hand-acts before it is asked; its cases are subtests of the views table.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:23:21 +01:00
jschoubben 231566eba8 DLV-B2 sdk: a build ask names each artifact's seat and the version the builder gave it (design 51, A3's request)
AskArtifact.Seat, one of the four registry seats, and AskArtifact.Version, which names a seat; an older reader ignores
both.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:21:48 +01:00
jschoubben 5e440129db SDK-8: the seat set's test knows node-engine's new pull-waits
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:19:52 +01:00
jschoubben 640e68f86e SDK-10: mesh-sdk's manifest declares its Go package beside lib, each in series 0.1 (design 32, design 51)
The Go build module publishes mesh-sdk's go through the Go seat; the paths word is known to every judge since R4e. The
own-manifest test now holds the two packages.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:19:22 +01:00
jschoubben f98e159446 DLV-F3r sdk: a reader hears a views event by its data, and a gap is asked as a gap (A7's request)
views.HearEvent routes one event given as its data, for the tool runner, which takes the owners' events by its own
path; Follower.Gap asks once more without counting it by hand. Hear is HearEvent on a consumer's message.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:15:59 +01:00
jschoubben 2f732b22d0 SDK-11: the names kept until their users were delivered go, with the amqp test's exemption (lead-47's hand-in)
No repository's main uses them since the final L3 release. Condition before the push, the lead's: that release's
node-engine runs on every node (the delivery view's haves).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:13:32 +01:00
jschoubben c60a46aa6a DLV-F3r sdk: views follower
A2's first-view package (owners' answer and views events, the readers' Follower with Changed(state, key, value, at),
UnansweredFinding, the status tools) taken into mesh-sdk as go/views as it stands at reg-a2-mesh-delivery f0a5fe8, for
every foundation reader (ADR 0593 §1, ADR 0615, ADR 0621). Its test takes the place of the vault's node-key test,
merged into the vault's table, so mesh-sdk's unit count does not grow.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:12:22 +01:00
jschoubben 400f4e0020 SDK-3: package-version.sum and its guard go: the builder numbers every version (ADR 0605 §7 step 4, design 51)
Nothing reads either on any repository's main.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:10:00 +01:00
jschoubben 8a84711792 SDK-4, SDK-5, SDK-6, SDK-7: the manifest words of series, accepted packages, a renamed config value and the contracts marker (design 32, design 26)
Artifact.Series and SeriesOf (0.1 when absent); Manifest.Packages and AcceptedSeries; a config declaration's
renamed-from, written back in the config form; a package's generated-from. One round-trip case covers the four, in
the config form's existing test; the judge's rules on them are the indexer's (DLV-I13..I16).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:09:19 +01:00
jschoubben c9fec158cb A3 sdk: a bundle uses its own object store through the runtime (ADR 0595 §2, §9, design 25 § 12.5)
stdio.BusState asks the runtime one buscalls method on the bundle's own bus state; package busstate makes the object
store's put, get, list, delete and fill by the store's local name (A3's request 1, distribution's incoming). Its test
takes the place of one merged into its neighbour, so mesh-sdk's unit count does not grow.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:06:55 +01:00
jschoubben df5cef0a67 SDK-2: the seat's reference form is a reader capability a node-engine states, form:seat-reference (ADR 0618 §1, ADR 0624 §1)
catalogue.ReferenceFormSeat joins ManifestReads, so the builder compares it with a node record's stated reads (A3's
request 3); the older forms are the baseline's and the builder's to name (lead-47's delivery forms on
feat/adr0618-reads, where FormRegistry's match must leave this form out when the two meet).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:04:11 +01:00
jschoubben 46baac8286 DLV-FB1 sdk: a forge message says a branch was deleted (design 51 *Tagged versions*)
delivery.ForgeBranchDeleted, accepted by checkForgeKind, names the repository and the branch and carries nothing else
(A3's request 2).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:02:52 +01:00
jschoubben 63f983897c SDK-2: every reader reads both reference forms: artifact-store:// and the one a registry seat answers (ADR 0605 §9, design 51)
A done key's reference may be <seat>/<module>/<artifact>@<digest> through oci-artifact-registry (a bundle, an
archive) or container-image-registry (an image, an upstream copy); catalogue.InRegistrySeat reads it, InArtifactStore
and Routed read it as they read a kept reference, Recorded keeps it as it is. Every exported name stays. A build's
list of every form (ADR 0624) is lead-47's, on feat/adr0618-reads.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:00:10 +01:00
jschoubben 5e02dccb98 SDK-8, SDK-12: the seat list is mesh-contracts' generated one; mesh-sdk keeps the runtime (ADR 0608 §5, ADR 0613 §2, ADR 0620 §3, design 26)
catalogue's compiled set is read from git.novox.be/novox/mesh-contracts/go/seats; the hand-written seat literal and its
verb builders go. The bar's block keeps its checks as code beside the data. A case of the seat set's test holds the
generated set equal, entry for entry, to the list compiled at 5a38a12 (testdata), but for the whole seats' additions
and the vault's two verbs; the three new registry seats are the only new entries. A holder of mesh-broker now serves its
eight verbs.

SDK-12: the vault's seat no longer promises accept and move-asks, which no holder served and nothing called; retire is
optional (design 32). The intake and move rows join one table; the move's own test goes with the two verbs.

Requires mesh-contracts (no version on the forge yet: a go.work, uncommitted, uses the sibling worktree until the
group pins it).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:57:50 +01:00
jschoubben 6c99075ab9 ENG-10 sdk: a routed image says the seat it came through and that seat's holder (design 51)
declaration.Container gains ImageSeat and ImageHolder, catalogue.Rendering SeatHolder, for the node-engine to retry a
failed pull on the holder's settled (A7's request R1).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:52:09 +01:00
jschoubben 7d05a39c0c SD-M1: one reader of a seat's definition for every part (ADR 0608 §1, design 26)
The indexer keeps the definitions and the build modules generate from them; both read them through go/seatdef, which
refuses a missing or unknown field by name, a later format and a definition over 256 KiB.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:51:04 +01:00
jschoubben ba4220ad7d Say that the vault's reading of a plan follows a node's rule and is never it (issue 1966)
Comments only: AssignmentHands and Rendering.Assigned said a node composing itself hands by the vault's word, which
kept a node with no plan from ever making one.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:33:44 +01:00
357 changed files with 38587 additions and 33522 deletions
+19 -11
View File
@@ -9,6 +9,15 @@ does not belong.* Per-module code (a service's API client, its tool implementati
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
turned the old SDK into constant maintenance and made every edit rebuild every module.
**A seat's code is not here** (novox/hq ADR 0625, ADR 0627). mesh-sdk is the runtime every module uses (the bus
connection, the tools bundle's runtime, state and its helpers) and a seat-neutral part two or more seats' clients or
holders share (sending a file in parts, `go/parts`). A seat defined whole has its generated contract, and its client
where callers share logic over it, in mesh-contracts (`go/seats/<seat>`, `go/clients/<seat>client`); the seat list is
mesh-contracts' `go/seats`, which `catalogue` alone imports. The value layouts of seats not yet defined whole stay
here until their domain moves, and no new seat-specific type is added. `packages_list_test.go` holds all of it:
mesh-sdk imports no contracts module but the seat set in `catalogue`, names none of the seats with a client outside
`catalogue`, and holds none of the packages that moved (`asks`, `asker`, `publish`).
## What is in it
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
@@ -106,21 +115,20 @@ runtime's (`node-tools/internal/launch`, mesh-tools).
values and then every change. Through the runtime that launched the bundle, which refuses what the
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
### `seats` and `asks` — asking a person, answering on a channel (Go)
### `seats` and `channel` — publishing on a seat, the channel's benches (Go)
A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says
an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve`
and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept),
`stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by
compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the
contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves
who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on
(`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the
subjects each travels on.
`go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then
publishes it under the module's name; `Cancel`; `Where` reads its state from the router's record; `Listen` and
`Take` hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only
for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is
`stdio.ErrChanged`, told by the runtime's error code.
compare-and-set. The runtime does each only as far as the module's membership lists. `go/channel` holds the
shapes of the two benches a channel holds, `channel` and `intake` (the `Message` a channel takes, the `Choice`,
`LinkAsked`, `Failed`, `Standing` and `Code` it says, with the subjects each travels on): those benches are not
defined whole, so their layouts stay here (ADR 0625 §9).
Asking a person is the `operator-channel` seat's, defined whole in mesh-contracts: its `Ask` and `Warrant` are its
generated contract (`go/seats/operatorchannel`), and the checks an ask is held to, the warrant's (`For`, `Says`,
`Performs`) and an asking module's client (`Ask`, `Cancel`, `Where`, `Listen`, `Take`, each warrant handed once,
marked acted on by compare-and-set) are its client, `go/clients/operatorchannelclient` (ADR 0627 §1). A lost
compare-and-set is `stdio.ErrChanged`, told by the runtime's error code.
`State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted.
-196
View File
@@ -1,196 +0,0 @@
// Package asker is a module's client for asking a person (novox/hq ADR 0259 §10): it publishes an ask under
// the module's own name, cancels it, reads its state from the router's record, and hands the module each
// warrant once — only one for an ask it made, for an option it offered, at that option's level, before it
// expired. The module then acts with its own grants.
//
// It keeps what it asked, and which warrants it acted on, in a state of the module's own (declared in its
// manifest, by default `asked`): the ask is written before it is published, and a warrant is marked acted on
// by compare-and-set before the module's handler runs, so a redelivery, or a second instance, acts once.
package asker
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Client asks as one module.
type Client struct {
// Module is the asking module's name: the one the bus grants its asks under.
Module string
// State is the module's own state the asks are kept in.
State Store
// Now is the clock; nil is time.Now.
Now func() time.Time
// Publish, Record and Subscribe reach the bus; nil is the stdio SDK's, through the module's runtime.
Publish func(subject string, body any, id string) error
Record func(key string) (*stdio.StateEntry, error)
Subscribe func(pattern string, handle func(stdio.Envelope) error) error
}
// Store is where the asks are kept: stdio.State(name) is one.
type Store interface {
Get(key string) (*stdio.StateEntry, error)
Create(key string, value any) (uint64, error)
Update(key string, value any, revision uint64) (uint64, error)
}
// New is a client for the module the runtime serves, keeping its asks in the state named.
func New(state string) *Client {
return &Client{Module: os.Getenv("MESH_MODULE"), State: stdio.State(state)}
}
func (c *Client) now() time.Time {
if c.Now != nil {
return c.Now()
}
return time.Now()
}
// Kept is one ask as the module keeps it.
type Kept struct {
Ask asks.Ask `json:"ask"`
Asked time.Time `json:"asked"`
Acted bool `json:"acted,omitempty"`
Ended asks.Outcome `json:"ended,omitempty"`
Answer *asks.Warrant `json:"answer,omitempty"`
}
func key(id string) string { return "ask." + id }
// Ask checks an ask, keeps it, and publishes it under the module's own name. An ask with an id already kept
// is refused: a new question is a new id.
func (c *Client) Ask(a asks.Ask) error {
if err := a.Check(c.now()); err != nil {
return err
}
if _, err := c.State.Create(key(a.ID), Kept{Ask: a, Asked: c.now()}); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return fmt.Errorf("an ask %s was already made: a new question is a new id", a.ID)
}
return err
}
return c.publish(asks.AskSubject(c.Module), a, a.ID)
}
// Cancel takes an ask back: the router ends it and edits every copy.
func (c *Client) Cancel(id string) error {
return c.publish(asks.CancelSubject(c.Module), map[string]string{"id": id}, "cancel."+id)
}
func (c *Client) publish(subject string, body any, id string) error {
if c.Publish != nil {
return c.Publish(subject, body, id)
}
return stdio.SeatPublish(subject, body, id)
}
// Where is an ask's state as the router keeps it: its state and its warrant, if any — read under the
// module's own name and no other's.
func (c *Client) Where(id string) (state string, w *asks.Warrant, err error) {
read := c.Record
if read == nil {
read = func(k string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", k) }
}
e, err := read(id)
if err != nil || e == nil {
return "", nil, err
}
var rec struct {
State string `json:"state"`
Warrant *asks.Warrant `json:"warrant"`
}
if err := json.Unmarshal(e.Value, &rec); err != nil {
return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err)
}
return rec.State, rec.Warrant, nil
}
// Handled is what came of a warrant: acted on, refused (and why), or already done.
type Handled string
const (
Acted Handled = "acted"
Refused Handled = "refused"
Done Handled = "already acted on"
Recorded Handled = "ended without a choice"
)
// Take hands act a warrant once, after holding it to the ask it answers. act performs the option with the
// module's own grants; its error is said and the warrant stays acted on (a person's decision is not
// performed twice; the module says the failure in its own record).
func (c *Client) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (Handled, error) {
if w.Asker != c.Module {
return Refused, fmt.Errorf("a warrant for %s reached %s", w.Asker, c.Module)
}
e, err := c.State.Get(key(w.Ask))
if err != nil {
return "", err
}
if e == nil {
return Refused, fmt.Errorf("%s holds no ask %s", c.Module, w.Ask)
}
var k Kept
if err := json.Unmarshal(e.Value, &k); err != nil {
return "", err
}
if k.Acted || k.Ended != "" {
return Done, nil
}
if w.Outcome != asks.OutcomeChosen {
k.Ended = w.Outcome
k.Answer = &w
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil && !errors.Is(err, stdio.ErrChanged) {
return "", err
}
return Recorded, nil
}
o, err := w.For(c.Module, k.Ask)
if err != nil {
return Refused, err
}
// Marked before acting: of two deliveries, or two instances, only the one whose write stands acts.
k.Acted, k.Ended, k.Answer = true, asks.OutcomeChosen, &w
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return Done, nil
}
return "", err
}
return Acted, act(o, w)
}
// Listen hands every warrant for this module to Take, with act. It asks the runtime for the module's
// events matching the router's `decided` subject for this module.
func (c *Client) Listen(act func(asks.Option, asks.Warrant) error, said func(Handled, asks.Warrant, error)) error {
subscribe := c.Subscribe
if subscribe == nil {
subscribe = stdio.Subscribe
}
return subscribe(asks.Seat+".decided."+c.Module, func(e stdio.Envelope) error {
if !strings.HasPrefix(e.Key, asks.Seat+".decided.") {
return nil
}
var w asks.Warrant
if err := json.Unmarshal(e.Body, &w); err != nil {
if said != nil {
said(Refused, w, fmt.Errorf("a warrant could not be read: %w", err))
}
return nil
}
h, err := c.Take(w, act)
if h == "" && err != nil {
return err // the state could not be reached: offered again
}
if said != nil {
said(h, w, err)
}
return nil
})
}
-141
View File
@@ -1,141 +0,0 @@
package asker
import (
"encoding/json"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
type mem struct {
vals map[string]json.RawMessage
revs map[string]uint64
n uint64
}
func (m *mem) Get(k string) (*stdio.StateEntry, error) {
v, ok := m.vals[k]
if !ok {
return nil, nil
}
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
}
func (m *mem) Create(k string, v any) (uint64, error) {
if _, ok := m.vals[k]; ok {
return 0, stdio.ErrChanged
}
return m.put(k, v)
}
func (m *mem) Update(k string, v any, rev uint64) (uint64, error) {
if rev == 0 || m.revs[k] != rev {
return 0, stdio.ErrChanged
}
return m.put(k, v)
}
func (m *mem) put(k string, v any) (uint64, error) {
raw, _ := json.Marshal(v)
m.n++
m.vals[k], m.revs[k] = raw, m.n
return m.n, nil
}
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
func client() (*Client, map[string][]string) {
sent := map[string][]string{}
c := &Client{Module: "mesh-delivery", State: &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}},
Now: func() time.Time { return now },
Publish: func(subject string, body any, id string) error {
sent[subject] = append(sent[subject], id)
return nil
}}
return c, sent
}
// releaseHQ is the act the ask's one option binds.
var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"}
func held() asks.Ask {
binds, _ := asks.ActDigest(releaseHQ)
return asks.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.",
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held",
Options: []asks.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: asks.Approve, Binds: binds}}}
}
func warrant() asks.Warrant {
return asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: asks.OutcomeChosen, Option: "release",
Label: "Release", Level: asks.Approve, At: now.Add(time.Minute), AskDigest: held().Digest(),
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
}
func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) {
c, sent := client()
if err := c.Ask(held()); err != nil {
t.Fatal(err)
}
if got := sent[asks.AskSubject("mesh-delivery")]; len(got) != 1 || got[0] != "d-1" {
t.Fatalf("%v", sent)
}
if err := c.Ask(held()); err == nil || !strings.Contains(err.Error(), "already made") {
t.Errorf("the same id was asked twice: %v", err)
}
bad := held()
bad.Who = "agent"
if err := c.Ask(bad); err == nil {
t.Error("an ask the contract refuses was published")
}
}
func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) {
c, _ := client()
_ = c.Ask(held())
acted := 0
act := func(o asks.Option, _ asks.Warrant) error {
if err := o.Performs(releaseHQ); err != nil {
t.Errorf("the bound act: %v", err)
}
acted++
return nil
}
if h, err := c.Take(warrant(), act); h != Acted || err != nil || acted != 1 {
t.Fatalf("%s %v %d", h, err, acted)
}
if h, _ := c.Take(warrant(), act); h != Done || acted != 1 {
t.Errorf("a redelivery was acted on: %s", h)
}
for why, change := range map[string]func(*asks.Warrant){
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
"an ask not made": func(w *asks.Warrant) { w.Ask = "d-9" },
"another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge },
"an option not offered": func(w *asks.Warrant) { w.Option = "delete" },
"after its expiry": func(w *asks.Warrant) { w.At = now.Add(2 * time.Hour) },
"another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:00" },
} {
c2, _ := client()
_ = c2.Ask(held())
w := warrant()
change(&w)
if h, _ := c2.Take(w, act); h == Acted {
t.Errorf("a warrant from %s was acted on", why)
}
}
if acted != 1 {
t.Errorf("acted %d times", acted)
}
}
func TestAnAskEndedWithoutAChoiceIsRecordedAndNothingDone(t *testing.T) {
c, _ := client()
_ = c.Ask(held())
w := warrant()
w.Outcome, w.Option, w.By = asks.OutcomeExpired, "", nil
if h, err := c.Take(w, func(asks.Option, asks.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil {
t.Fatalf("%s %v", h, err)
}
if h, _ := c.Take(warrant(), func(asks.Option, asks.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done {
t.Errorf("%s", h)
}
}
-578
View File
@@ -1,578 +0,0 @@
// Package asks is the contract of asking a person and answering on a channel (novox/hq ADR 0259): the
// shapes an asker, the router and a channel exchange on the bus, and the subjects they travel on. No
// transport and no channel's service: an asker publishes an Ask under its own name and acts on the Warrant
// it hears; the router holds the ask, sends channels a Message, and judges the Choice a channel says; a
// channel shows a Message and says what was chosen and by whom, as its service authenticated it.
package asks
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// The seats (novox/hq ADR 0259 §3).
const (
// Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by
// the asker.
Seat = "operator-channel"
// ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind.
ChannelSeat = "channel"
// IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry
// the kind.
IntakeSeat = "intake"
)
// AskSubject is where an asker publishes an ask, CancelSubject its cancel, and DecidedSubject where it
// hears the warrant, or the ask's end without one.
func AskSubject(asker string) string { return "mesh.seat." + Seat + ".accept.ask." + asker }
func CancelSubject(asker string) string { return "mesh.seat." + Seat + ".accept.cancel." + asker }
func DecidedSubject(asker string) string { return "mesh.seat." + Seat + ".event.decided." + asker }
// The work a channel takes, on ChannelSubject.
const (
Show = "show" // a message offering answers
Edit = "edit" // a message shown before, replaced
Send = "send" // a message offering nothing
)
// ChannelSubject is where the router sends a channel of a kind its work.
func ChannelSubject(verb, kind string) string {
return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind
}
// What a channel says, on IntakeSubject.
const (
Chosen = "choice" // a button tapped
Link = "link" // somebody asked to be linked as the operator
)
// IntakeSubject is where a channel of a kind says what arrived.
func IntakeSubject(what, kind string) string {
return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind
}
// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept.
const CodeProof = "code"
// ProofSubject is where a channel of a kind asks a proof.
func ProofSubject(verb, kind string) string {
return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind
}
// A Level is how much proof an option's answer needs (novox/hq ADR 0234 §8, the glossary's assurance level).
type Level string
const (
// Acknowledge performs only what any granted principal may already do: silencing, details. No proof.
Acknowledge Level = "acknowledge"
// Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code.
Approve Level = "approve"
// Destroy needs two proofs, one of them a code.
Destroy Level = "destroy"
)
// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less.
func (l Level) Rank() int {
switch l {
case Acknowledge:
return 0
case Approve:
return 1
case Destroy:
return 2
}
return 3
}
// Operator is the one role an ask may be answered by today.
const Operator = "operator"
// Option is one answer an ask offers: a label for the button, what it does in plain words, its level.
type Option struct {
ID string `json:"id"`
Label string `json:"label"`
Does string `json:"does"`
Level Level `json:"level"`
// Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the
// machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant,
// which names the ask's digest, names it too: a warrant then authorises that act and no other, and an
// asker whose record of the act changed after it asked finds the digests differ and does nothing.
// Required on an option above acknowledge.
Binds string `json:"binds,omitempty"`
}
// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each
// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and
// values alone, never over how a language happens to encode a struct.
type Act map[string]string
// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical),
// written "sha256:<hex>". The same names and values give the same digest in any language, whatever order
// they were set in; a field renamed, added or emptied gives another.
func ActDigest(act Act) (string, error) {
if len(act) == 0 {
return "", fmt.Errorf("the act cannot be digested: it names nothing")
}
keys := make([]string, 0, len(act))
for k := range act {
if k == "" {
return "", fmt.Errorf("the act cannot be digested: a field has no name")
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("novox.act.v1\n")
for _, k := range keys {
canonical(&b, k)
canonical(&b, act[k])
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read
// as another's end or start, so two different sequences of values never encode the same.
func canonical(b *strings.Builder, v string) {
b.WriteString(strconv.Itoa(len(v)))
b.WriteByte(':')
b.WriteString(v)
b.WriteByte('\n')
}
// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each
// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker
// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the
// person was shown it, and nothing published under the same id before or after.
//
// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC
// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask
// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow
// the options only when the ask gives either: an ask without them digests as it did before they existed, so
// a router and an asker of different builds still agree on every such ask.
func (a Ask) Digest() string {
var b strings.Builder
b.WriteString("novox.ask.v1\n")
for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who,
a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent),
strconv.Itoa(len(a.Options))} {
canonical(&b, v)
}
for _, o := range a.Options {
for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} {
canonical(&b, v)
}
}
// Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever
// it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes —
// none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks
// encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and
// one without WholeDetails as before it existed.
if a.Whole != "" || a.Details != "" || a.WholeDetails != "" {
canonical(&b, a.Whole)
canonical(&b, a.Details)
}
if a.WholeDetails != "" {
canonical(&b, a.WholeDetails)
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:])
}
// Ask is a request for a person's word (novox/hq ADR 0259 §4).
type Ask struct {
// ID is the asker's own, unique to it.
ID string `json:"id"`
// Headline names the thing and what is wrong, in a few plain words; Explanation is what happened and
// what it means. Both are held to the plain rule and the content rule by the router.
Headline string `json:"headline"`
Explanation string `json:"explanation"`
Options []Option `json:"options"`
// Who may answer: Operator.
Who string `json:"who"`
// Expires is when the ask ends unanswered; OnExpiry is what the asker then does, in words the person
// is shown ("the delivery stays held"). An ask that authorises never defaults.
Expires time.Time `json:"expires"`
OnExpiry string `json:"on-expiry"`
// About is what the ask is about (a condition's key): a newer ask about it replaces the older.
About string `json:"about,omitempty"`
Urgent bool `json:"urgent,omitempty"`
// Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind
// that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the
// person approves — a mount point, a share, a private address — must be readable where they approve it.
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
Whole string `json:"whole,omitempty"`
// Details are the ask's secondary words, line by line under the content rule: who proposed it, a
// fingerprint, how to read it whole. The router shows them in the note of the ask's own message
// (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq
// issue 639).
Details string `json:"details,omitempty"`
// WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown
// in place of the explanation (a kind that proves who answers, given the ask's answers): every value an
// approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like
// Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty,
// Details are shown everywhere.
WholeDetails string `json:"whole-details,omitempty"`
}
// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary
// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at
// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when,
// the fingerprint and how to read it whole are Details, never the explanation.
const (
SummaryLength = 300
MostPoints = 3
PointLength = 160
)
// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than
// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not
// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that
// does not hold to it.
func Glance(explanation string) error {
var lines []string
for _, l := range strings.Split(explanation, "\n") {
if strings.TrimSpace(l) != "" {
lines = append(lines, strings.TrimSpace(l))
}
}
var problems []string
switch {
case len(lines) == 0:
problems = append(problems, "it has no summary")
case len([]rune(lines[0])) > SummaryLength:
problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength))
}
if len(lines) > 1+MostPoints {
problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints))
}
for i, l := range lines {
if i > 0 && len([]rune(l)) > PointLength {
problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength))
}
}
if len(problems) > 0 {
return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; "))
}
return nil
}
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
const (
MostOptions = 4
MostOpen = 3
ApproveLasts = 24 * time.Hour
DestroyLasts = 10 * time.Minute
HeadlineLength = 60
LabelLength = 24
)
var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both.
func UsableID(id string) bool { return usableID.MatchString(id) }
// Highest is the highest level among the ask's options.
func (a Ask) Highest() Level {
high := Acknowledge
for _, o := range a.Options {
if o.Level.Rank() > high.Rank() {
high = o.Level
}
}
return high
}
// Option is the option of this id, or false.
func (a Ask) Option(id string) (Option, bool) {
for _, o := range a.Options {
if o.ID == id {
return o, true
}
}
return Option{}, false
}
// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on.
func (a Ask) Check(now time.Time) error {
var problems []string
say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
if !UsableID(a.ID) {
say("its id %q is not letters, digits, - and _, at most 64", a.ID)
}
if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength {
say("its headline is empty or longer than %d characters", HeadlineLength)
}
if strings.TrimSpace(a.Explanation) == "" {
say("it explains nothing")
}
if a.Who != Operator {
say("it is answered by %q, and only the operator answers today", a.Who)
}
if len(a.Options) == 0 || len(a.Options) > MostOptions {
say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions)
}
seen := map[string]bool{}
for _, o := range a.Options {
switch {
case !UsableID(o.ID):
say("an option's id %q is not letters, digits, - and _", o.ID)
case seen[o.ID]:
say("the option %s is offered twice", o.ID)
}
seen[o.ID] = true
if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength {
say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength)
}
if strings.TrimSpace(o.Does) == "" {
say("the option %s does not say what it does", o.ID)
}
if o.Level.Rank() > Destroy.Rank() {
say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level)
}
if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") {
say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID)
}
}
if !a.Expires.After(now) {
say("it expires before it is asked")
}
switch a.Highest() {
case Approve:
if a.Expires.After(now.Add(ApproveLasts)) {
say("an ask that approves lasts at most %s", ApproveLasts)
}
case Destroy:
if a.Expires.After(now.Add(DestroyLasts)) {
say("an ask that destroys lasts at most %s", DestroyLasts)
}
}
if a.Highest() != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" {
say("it does not say what happens when nobody answers, and an ask that authorises never defaults")
}
if a.About != "" && strings.ContainsAny(a.About, " \n") {
say("what it is about is a key, without spaces")
}
if len(problems) > 0 {
return errors.New("the ask is refused: " + strings.Join(problems, "; "))
}
return nil
}
// Outcome is how an ask ended.
type Outcome string
const (
OutcomeChosen Outcome = "chosen" // a person chose an option: a warrant
OutcomeExpired Outcome = "expired" // nobody answered in time
OutcomeCancelled Outcome = "cancelled" // its asker took it back
OutcomeReplaced Outcome = "replaced" // a newer ask about the same thing replaced it
OutcomeRefused Outcome = "refused" // it was never shown: Words says why
)
// Person is who chose, as the router verified them.
type Person struct {
// Who is the role: Operator.
Who string `json:"who"`
// Kind is the channel kind they answered on, Identity their account on that service, Display the name
// the service shows, and Verified how the router knew it was them.
Kind string `json:"kind"`
Identity string `json:"identity"`
Display string `json:"display,omitempty"`
Verified string `json:"verified"`
}
// Warrant is the router's record that a person chose one option of one ask, or the ask's end without one
// (novox/hq ADR 0259 §6). It carries no secret.
type Warrant struct {
Ask string `json:"ask"`
Asker string `json:"asker"`
About string `json:"about,omitempty"`
Outcome Outcome `json:"outcome"`
// Option, Label and Level are the option chosen; By who chose it, Channel the module it came through,
// Proofs which proofs were present (P1, P2, P3).
Option string `json:"option,omitempty"`
Label string `json:"label,omitempty"`
Level Level `json:"level,omitempty"`
By *Person `json:"by,omitempty"`
Channel string `json:"channel,omitempty"`
Proofs []string `json:"proofs,omitempty"`
At time.Time `json:"at"`
// AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask
// alone, with the options it bound.
AskDigest string `json:"ask-digest,omitempty"`
// Words are why an ask ended without a choice, or what refused it.
Words string `json:"words,omitempty"`
}
// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id
// verified), chose Release".
func (w Warrant) Says() string {
if w.Outcome != OutcomeChosen || w.By == nil {
return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome)
}
via := w.By.Kind
if w.By.Verified != "" {
via += " (" + w.By.Verified + ")"
}
return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label)
}
// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the
// same words, options and binds), a choice, an option the ask offered, at that option's level, before the
// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names.
func (w Warrant) For(asker string, a Ask) (Option, error) {
if w.Asker != asker || w.Ask != a.ID {
return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID)
}
if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who {
return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome)
}
if d := a.Digest(); w.AskDigest != d {
return Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+
"it was not the ask the person was shown", w.AskDigest, a.ID, d)
}
o, offered := a.Option(w.Option)
if !offered {
return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option)
}
if w.Level != o.Level {
return Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level)
}
// A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no
// expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time (the
// confirmation review of 2026-10-09).
if a.Expires.IsZero() {
return Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID)
}
if w.At.IsZero() {
return Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID)
}
if w.At.After(a.Expires) {
return Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s",
w.At.UTC().Format(time.RFC3339), a.ID, a.Expires.UTC().Format(time.RFC3339))
}
return o, nil
}
// Performs checks that the act an asker is about to perform is the one the chosen option bound when it
// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes.
func (o Option) Performs(act Act) error {
if o.Binds == "" && o.Level == Acknowledge {
return nil
}
d, err := ActDigest(act)
if err != nil {
return err
}
if d != o.Binds {
return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d)
}
return nil
}
// Button is one answer a channel offers: its label and the router's one-time ticket for it.
type Button struct {
Label string `json:"label"`
Ticket string `json:"ticket"`
}
// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said
// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps
// which of its own messages that is. The words are the router's, shown as given.
type Message struct {
Handle string `json:"handle"`
Title string `json:"title"`
Body string `json:"body"`
Buttons []Button `json:"buttons,omitempty"`
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
// where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's
// length cuts the body first and the note only when the title and the note alone do not fit, saying each
// cut (novox/hq ADR 0347 §5).
Note string `json:"note,omitempty"`
Urgent bool `json:"urgent,omitempty"`
Silent bool `json:"silent,omitempty"`
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
Reply string `json:"reply,omitempty"`
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
// the channel sends what is not a reply. The router's word, from its list; empty when none is linked.
To string `json:"to,omitempty"`
// Secret says the words carry something shown once (a link's code): the channel shows it and keeps no
// copy of it — no state, no history of its own.
Secret bool `json:"secret,omitempty"`
}
// Sender is who a channel's service says sent something: the account, the name it shows, and whether the
// service authenticated it. The router alone judges whether that is the operator.
type Sender struct {
Identity string `json:"identity"`
Display string `json:"display,omitempty"`
Authenticated bool `json:"authenticated"`
}
// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help.
type Failed struct {
Handle string `json:"handle"`
Why string `json:"why"`
Permanent bool `json:"permanent,omitempty"`
At time.Time `json:"at"`
}
// Standing is what a channel says of itself, at least every five minutes and whenever it changes:
// whether it can send now, why not, and whether its edits notify nobody.
type Standing struct {
Ready bool `json:"ready"`
Why string `json:"why,omitempty"`
EditsSilently bool `json:"edits-silently,omitempty"`
At time.Time `json:"at"`
}
// What a channel says of its own delivery, on IntakeSubject.
const (
FailedWhat = "failed"
StandingWhat = "standing"
)
// Choice is a button chosen on a channel.
type Choice struct {
// ID is the channel's own for this arrival, unique, so the router acts on it once.
ID string `json:"id"`
Ticket string `json:"ticket"`
Handle string `json:"handle,omitempty"`
Sender Sender `json:"sender"`
At time.Time `json:"at"`
}
// LinkAsked is somebody on a channel asking to be linked as the operator.
type LinkAsked struct {
ID string `json:"id"`
Sender Sender `json:"sender"`
At time.Time `json:"at"`
}
// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept.
type Code struct {
Sender Sender `json:"sender"`
Code string `json:"code"`
Purpose string `json:"purpose"`
}
// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person.
type ProofAnswer struct {
Accepted bool `json:"accepted"`
Words string `json:"words"`
}
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
// node's engine account alone, so no engine acts on another node's warrant.
func EngineAsker(node string) string { return "node-engine-" + node }
-367
View File
@@ -1,367 +0,0 @@
package asks
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
)
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
func held() Ask {
return Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.",
Who: Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", About: "delivery.openrazer",
Options: []Option{
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")},
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")},
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge},
}}
}
func bind(verb, what string) string {
d, err := ActDigest(Act{"verb": verb, "delivery": what})
if err != nil {
panic(err)
}
return d
}
func TestASoundAskPasses(t *testing.T) {
if err := held().Check(now); err != nil {
t.Fatal(err)
}
}
func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) {
for want, change := range map[string]func(*Ask){
"is not letters": func(a *Ask) { a.ID = "a.b" },
"headline is empty": func(a *Ask) { a.Headline = "" },
"explains nothing": func(a *Ask) { a.Explanation = " " },
"only the operator answers": func(a *Ask) { a.Who = "agent" },
"offers 0 options": func(a *Ask) { a.Options = nil },
"offers 5 options": func(a *Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) },
"is offered twice": func(a *Ask) { a.Options[1].ID = "release" },
"does not say what it does": func(a *Ask) { a.Options[0].Does = "" },
"none of acknowledge": func(a *Ask) { a.Options[0].Level = "admin" },
"expires before it is asked": func(a *Ask) { a.Expires = now },
"approves lasts at most": func(a *Ask) { a.Expires = now.Add(25 * time.Hour) },
"destroys lasts at most": func(a *Ask) { a.Options[0].Level = Destroy },
"never defaults": func(a *Ask) { a.OnExpiry = "" },
"is a key, without spaces": func(a *Ask) { a.About = "a b" },
"does not bind what it": func(a *Ask) { a.Options[0].Binds = "" },
} {
a := held()
change(&a)
if err := a.Check(now); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("want a refusal saying %q, got %v", want, err)
}
}
ack := held()
ack.Options = ack.Options[2:]
ack.OnExpiry = ""
ack.Expires = now.Add(7 * 24 * time.Hour)
if err := ack.Check(now); err != nil {
t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err)
}
}
func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) {
w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release",
Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
AskDigest: held().Digest(), At: now.Add(time.Minute)}
if o, err := w.For("mesh-delivery", held()); err != nil || o.ID != "release" {
t.Fatalf("a sound warrant was refused: %v", err)
}
if got := w.Says(); got != "the operator, via telegram (user id verified), chose Release" {
t.Errorf("the warrant says %q", got)
}
for why, change := range map[string]func(*Warrant){
"another asker": func(w *Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *Warrant) { w.Ask = "d-2" },
"no choice": func(w *Warrant) { w.Outcome = OutcomeExpired },
"nobody": func(w *Warrant) { w.By = nil },
"not offered": func(w *Warrant) { w.Option = "delete" },
"a lower level": func(w *Warrant) { w.Level = Acknowledge },
"another role": func(w *Warrant) { w.By = &Person{Who: "agent"} },
"no digest": func(w *Warrant) { w.AskDigest = "" },
"no time given": func(w *Warrant) { w.At = time.Time{} },
"after expiry": func(w *Warrant) { w.At = now.Add(2 * time.Hour) },
"another ask's digest": func(w *Warrant) {
other := held()
other.Options[0].Binds = bind("release", "another")
w.AskDigest = other.Digest()
},
} {
c := w
by := *w.By
c.By = &by
change(&c)
if _, err := c.For("mesh-delivery", held()); err == nil {
t.Errorf("a warrant with %s was acted on", why)
}
}
}
func TestTheSubjectsNameTheirPublisher(t *testing.T) {
for got, want := range map[string]string{
AskSubject("mesh-delivery"): "mesh.seat.operator-channel.accept.ask.mesh-delivery",
DecidedSubject("mesh-delivery"): "mesh.seat.operator-channel.event.decided.mesh-delivery",
ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram",
IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop",
ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram",
} {
if got != want {
t.Errorf("%s, want %s", got, want)
}
}
}
// A warrant answers one ask as it was shown, and authorises the act its option bound and no other
// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments).
func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) {
a := held()
// The digest survives the trip through the bus: what the router decodes and digests is what was sent.
raw, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
var decoded Ask
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Digest() != a.Digest() {
t.Fatalf("the ask's digest changed on the way: %s, %s", decoded.Digest(), a.Digest())
}
local := a
local.Expires = a.Expires.In(time.FixedZone("CEST", 2*3600))
if local.Digest() != a.Digest() {
t.Error("the same instant in another zone digests differently")
}
// Changing anything the person was shown, or what an option binds, changes the digest.
for what, change := range map[string]func(*Ask){
"the headline": func(a *Ask) { a.Headline += "!" },
"the explanation": func(a *Ask) { a.Explanation += " " },
"a label": func(a *Ask) { a.Options[0].Label = "Go" },
"what it binds": func(a *Ask) { a.Options[0].Binds = bind("release", "another") },
"the expiry": func(a *Ask) { a.Expires = a.Expires.Add(time.Second) },
"what it is about": func(a *Ask) { a.About = "delivery.other" },
} {
c := held()
change(&c)
if c.Digest() == a.Digest() {
t.Errorf("changing %s leaves the digest", what)
}
}
o, _ := a.Option("release")
if err := o.Performs(Act{"verb": "release", "delivery": "openrazer"}); err != nil {
t.Errorf("the bound act was refused: %v", err)
}
if err := o.Performs(Act{"verb": "release", "delivery": "another"}); err == nil {
t.Error("an act the option did not bind was let through")
}
ack, _ := a.Option("silence")
if err := ack.Performs(Act{"anything": "x"}); err != nil {
t.Errorf("an acknowledgement that bound nothing: %v", err)
}
}
// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's
// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another
// order changes nothing it should not — and every field that is shown or bound is in it.
func TestDigestsAreCanonicalOverNamedFields(t *testing.T) {
a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"})
b := Act{}
b["arg.delivery"] = "hq"
b["verb"] = "release"
a2, _ := ActDigest(b)
if a1 != a2 {
t.Error("the order an act was built in changed its digest")
}
// A value cannot be read as two: "a"+"b=c" is not "a=b"+"c".
x, _ := ActDigest(Act{"a": "b\n1:c"})
y, _ := ActDigest(Act{"a": "b", "c": ""})
if x == y {
t.Error("two different acts encode the same")
}
if _, err := ActDigest(Act{}); err == nil {
t.Error("an empty act was digested")
}
// The encoding, by hand: any language reproduces it.
sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n"))
if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) {
t.Errorf("the act's encoding is not the one written down: %s", got)
}
// Every exported field of Ask and Option is in the digest: changing any one changes it.
base := held()
mutations := map[string]func(*Ask){
"ID": func(a *Ask) { a.ID = "other" },
"Headline": func(a *Ask) { a.Headline += "!" },
"Explanation": func(a *Ask) { a.Explanation += "!" },
"Who": func(a *Ask) { a.Who = "someone" },
"Expires": func(a *Ask) { a.Expires = a.Expires.Add(time.Nanosecond) },
"OnExpiry": func(a *Ask) { a.OnExpiry += "!" },
"About": func(a *Ask) { a.About += "!" },
"Urgent": func(a *Ask) { a.Urgent = !a.Urgent },
"Options": func(a *Ask) { a.Options = a.Options[:1] },
"Option.ID": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].ID = "x" },
"Option.Label": func(a *Ask) {
a.Options = append([]Option(nil), a.Options...)
a.Options[0].Label += "!"
},
"Option.Does": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].Does += "!" },
"Option.Level": func(a *Ask) {
a.Options = append([]Option(nil), a.Options...)
a.Options[0].Level = Destroy
},
"Option.Binds": func(a *Ask) {
a.Options = append([]Option(nil), a.Options...)
a.Options[0].Binds = "sha256:00"
},
}
for name, mutate := range mutations {
c := base
mutate(&c)
if c.Digest() == base.Digest() {
t.Errorf("changing %s left the digest the same", name)
}
}
// Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests
// exactly as before they existed — the encoding by hand, so no build can drift from it unseen.
plain := Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: now, OnExpiry: "o", About: "a",
Options: []Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}}
byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" +
"2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n"))
if plain.Digest() != "sha256:"+hex.EncodeToString(byHand[:]) {
t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", plain.Digest())
}
withWhole, withDetails := plain, plain
withWhole.Whole = "e, whole"
withDetails.Details = "fingerprint 1234"
if withWhole.Digest() == plain.Digest() || withDetails.Digest() == plain.Digest() || withWhole.Digest() == withDetails.Digest() {
t.Error("Whole or Details left the digest the same")
}
both := withWhole
both.Details = "fingerprint 1234"
changed := both
changed.Whole += "!"
if changed.Digest() == both.Digest() {
t.Error("changing Whole beside Details left the digest the same")
}
// A field added to Ask or Option without being added to Digest fails here.
if n := reflect.TypeOf(Ask{}).NumField(); n != 12 {
t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n)
}
if n := reflect.TypeOf(Option{}).NumField(); n != 5 {
t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n)
}
// The same instant in another zone is the same ask.
z := base
z.Expires = z.Expires.In(time.FixedZone("x", 3600))
if z.Digest() != base.Digest() {
t.Error("the expiry's zone changed the digest")
}
}
// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could
// show the answer was in time.
func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) {
a := held()
a.Expires = time.Time{}
w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release",
Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: a.Digest(),
At: now.Add(time.Minute)}
if _, err := w.For("mesh-delivery", a); err == nil {
t.Error("a warrant for an ask with no expiry was taken")
}
}
// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639).
func TestAnExplanationReadsAtAGlance(t *testing.T) {
if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil {
t.Fatalf("a summary and two points: %v", err)
}
var wall strings.Builder
wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.")
for i := 1; i <= 13; i++ {
wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3))
}
err := Glance(wall.String())
if err == nil || !strings.Contains(err.Error(), "13 points") {
t.Fatalf("thirteen clause lines pass as a glance: %v", err)
}
if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") {
t.Fatalf("a long summary passes: %v", err)
}
if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") {
t.Fatalf("a long point passes: %v", err)
}
if err := Glance(" \n"); err == nil {
t.Fatal("an empty explanation passes")
}
}
// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older
// than it reads every message it read before.
func TestAMessageCarriesItsNoteApart(t *testing.T) {
raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."})
if !strings.Contains(string(raw), `"note":"Asked by x."`) {
t.Fatalf("the note is not encoded: %s", raw)
}
raw, _ = json.Marshal(Message{Handle: "h", Title: "t"})
if strings.Contains(string(raw), "note") {
t.Fatalf("an empty note is encoded: %s", raw)
}
}
// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker
// of different builds still agree on every such ask.
func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) {
a := Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"}
before := a.Digest()
a.WholeDetails = "+ shares: media=/storage/media"
if a.Digest() == before {
t.Fatal("the whole details are not in the digest")
}
b := a
b.WholeDetails = "+ shares: media=/storage/other"
if a.Digest() == b.Digest() {
t.Fatal("two whole details digest the same")
}
a.WholeDetails = ""
if a.Digest() != before {
t.Fatal("an ask without whole details digests otherwise than before")
}
}
// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as
// a Whole and Details that happen to spell the same values, nor as any other split of the three fields.
func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) {
base := Ask{ID: "a", Headline: "h", Explanation: "e"}
shapes := []Ask{}
for _, f := range []struct{ whole, details, wholeDetails string }{
{"", "", "+ x: 1"},
{"whole-details", "+ x: 1", ""},
{"", "+ x: 1", ""},
{"+ x: 1", "", ""},
{"", "", "whole-details"},
{"", "+ x: 1", "+ x: 1"},
{"+ x: 1", "+ x: 1", ""},
{"", "", ""},
} {
a := base
a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails
shapes = append(shapes, a)
}
seen := map[string]int{}
for i, a := range shapes {
if j, ok := seen[a.Digest()]; ok {
t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a)
}
seen[a.Digest()] = i
}
}
-149
View File
@@ -1,149 +0,0 @@
package asks
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
)
// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel
// seat, `mesh.seat.operator-channel.accept.condition.<publisher>` (novox/hq ADR 0437 §9). The router reads the
// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is
// refused when empty or when it holds a dot, a wildcard or whitespace.
func ConditionSubject(publisher string) (string, error) {
if publisher == "" {
return "", errors.New("asks: the condition's publisher is empty")
}
if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) {
return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher)
}
return "mesh.seat." + Seat + ".accept.condition." + publisher, nil
}
// ConditionVersion is the version of the condition value this package writes and the only one it reads.
const ConditionVersion = 1
// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not
// ConditionVersion. Test it with errors.Is.
var ErrUnknownConditionVersion = errors.New("asks: unknown condition version")
// The words a condition's explanation opens with (novox/hq ADR 0253 §1).
const (
NothingToDo = "Nothing for you to do."
NeedsYou = "Needs you: "
)
// The severities and states of a condition.
const (
SeverityUrgent = "urgent"
SeverityWarning = "warning"
StateRaised = "raised"
StateCleared = "cleared"
)
// Scope is what a condition is about, under the controller's names.
type Scope struct {
Scope string `json:"scope"`
ID string `json:"id"`
Machine string `json:"machine,omitempty"`
}
// Action is one answer a notification offers, under the controller's names.
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
Machine string `json:"machine,omitempty"`
Arguments map[string]string `json:"arguments,omitempty"`
Level Level `json:"level,omitempty"`
}
// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key,
// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs
// only its key, subject and state. The router alone chooses the channels.
type Condition struct {
V int `json:"v"`
Key string `json:"key"`
Severity string `json:"severity,omitempty"`
Subject Scope `json:"subject"`
State string `json:"state"`
Summary string `json:"summary,omitempty"`
Headline string `json:"headline,omitempty"`
Needs string `json:"needs,omitempty"`
Explanation string `json:"explanation,omitempty"`
Resolved string `json:"resolved,omitempty"`
Actions []Action `json:"actions,omitempty"`
}
func (c Condition) check() error {
if c.Key == "" {
return errors.New("asks: a condition has no key")
}
if c.Subject.Scope == "" || c.Subject.ID == "" {
return errors.New("asks: a condition's subject needs its scope and id")
}
switch c.State {
case StateCleared:
return nil
case StateRaised:
default:
return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State)
}
switch c.Severity {
case SeverityUrgent, SeverityWarning:
default:
return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity)
}
if c.Headline == "" {
return errors.New("asks: a raised condition has no headline")
}
if n := len([]rune(c.Headline)); n > HeadlineLength {
return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength)
}
verdict := NothingToDo
if c.Needs != "" {
verdict = NeedsYou + c.Needs
}
if !strings.HasPrefix(c.Explanation, verdict) {
return fmt.Errorf("asks: the condition's explanation must open with %q", verdict)
}
return nil
}
// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse.
func EncodeCondition(c Condition) ([]byte, error) {
if err := c.check(); err != nil {
return nil, err
}
c.V = ConditionVersion
return json.Marshal(c)
}
// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what
// EncodeCondition refuses.
func DecodeCondition(data []byte) (Condition, error) {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err)
}
if probe.V == nil {
return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion)
}
if *probe.V != ConditionVersion {
return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion)
}
var c Condition
if err := json.Unmarshal(data, &c); err != nil {
return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err)
}
if err := c.check(); err != nil {
return Condition{}, err
}
return c, nil
}
// SameCondition says whether two conditions are the same value.
func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) }
-143
View File
@@ -1,143 +0,0 @@
package asks
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestConditionSubject(t *testing.T) { // 1202/AC1
s, err := ConditionSubject("mesh-delivery-teller")
if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" {
t.Fatalf("subject = %q, %v", s, err)
}
// The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5.
parts := strings.Split(s, ".")
if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" {
t.Errorf("subject %q does not keep the router's shape: %v", s, parts)
}
for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} {
if _, err := ConditionSubject(bad); err == nil {
t.Errorf("publisher %q accepted", bad)
}
}
}
func raisedCondition() Condition {
return Condition{
Key: "delivery.stuck.mesh-mail", Severity: "warning",
Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised",
Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck",
Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.",
Resolved: "The delivery of mesh-mail moved on.",
Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace",
Arguments: map[string]string{"why": ""}, Level: Approve}},
}
}
func clearedCondition() Condition {
return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"}
}
func TestConditionRoundTrip(t *testing.T) { // 1202/AC2
for _, c := range []Condition{raisedCondition(), clearedCondition()} {
data, err := EncodeCondition(c)
if err != nil {
t.Fatalf("%s: %v", c.State, err)
}
var probe map[string]any
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
t.Fatalf("%s: %s does not carry v:1", c.State, data)
}
c.V = ConditionVersion
got, err := DecodeCondition(data)
if err != nil || !SameCondition(got, c) {
t.Errorf("%s round trip: %+v %v", c.State, got, err)
}
}
data, _ := EncodeCondition(raisedCondition())
var probe map[string]any
_ = json.Unmarshal(data, &probe)
for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} {
if _, ok := probe[name]; !ok {
t.Errorf("the JSON has no %q: %s", name, data)
}
}
subject := probe["subject"].(map[string]any)
for _, name := range []string{"scope", "id", "machine"} {
if _, ok := subject[name]; !ok {
t.Errorf("the subject has no %q: %s", name, data)
}
}
action := probe["actions"].([]any)[0].(map[string]any)
for _, name := range []string{"label", "verb", "machine", "arguments", "level"} {
if _, ok := action[name]; !ok {
t.Errorf("the action has no %q: %s", name, data)
}
}
for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} {
if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) {
t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err)
}
}
}
func TestConditionVerdict(t *testing.T) { // 1202/AC3
c := raisedCondition()
c.Needs, c.Explanation = "", "The plan waits."
_, err := EncodeCondition(c)
if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") {
t.Errorf("empty needs without its verdict: %v", err)
}
c.Explanation = "Nothing for you to do. The plan waits."
if _, err := EncodeCondition(c); err != nil {
t.Errorf("empty needs with its verdict refused: %v", err)
}
c = raisedCondition()
c.Explanation = "Nothing for you to do. The plan waits."
_, err = EncodeCondition(c)
if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") {
t.Errorf("needs set with the wrong verdict: %v", err)
}
c.Explanation = "Needs you: something else. The plan waits."
if _, err = EncodeCondition(c); err == nil {
t.Error("needs set with other needs in the verdict accepted")
}
}
func TestConditionRefused(t *testing.T) { // 1202/AC4
mutate := func(f func(*Condition)) error {
c := raisedCondition()
f(&c)
_, err := EncodeCondition(c)
return err
}
for name, f := range map[string]func(*Condition){
"severity": func(c *Condition) { c.Severity = "critical" },
"state": func(c *Condition) { c.State = "open" },
"key": func(c *Condition) { c.Key = "" },
"headline": func(c *Condition) { c.Headline = "" },
"long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) },
} {
if err := mutate(f); err == nil {
t.Errorf("a condition with a bad %s accepted", name)
}
}
if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil {
t.Errorf("a headline of the greatest length refused: %v", err)
}
if _, err := EncodeCondition(clearedCondition()); err != nil {
t.Errorf("a cleared condition with only key, subject and state refused: %v", err)
}
c := clearedCondition()
c.Key = ""
if _, err := EncodeCondition(c); err == nil {
t.Error("a cleared condition without its key accepted")
}
c = clearedCondition()
c.State = "gone"
if _, err := EncodeCondition(c); err == nil {
t.Error("a condition with an unknown state accepted")
}
}
-7
View File
@@ -1,7 +0,0 @@
package buildstep
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
// lays the node's value into the module's settings file.
const CapacitySetting = "capacity"
-5
View File
@@ -1,5 +0,0 @@
// Package buildstep is the contract of a build step every delivery part reads (novox/hq ADR 0593 §1): a step's id, the
// fetch request, a step a previous process left running and its end, a failure's words, a module tree's shape, the
// capacity setting and the launchers' names. The runner the build modules share is the Go build module's own package
// (mesh-catalog modules/mesh-build-go/buildstep), which imports this one.
package buildstep
-141
View File
@@ -1,141 +0,0 @@
package buildstep
import (
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/sdkversion"
)
// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test
// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have
// always used; File is new and an older reader ignores it.
type Failure struct {
// Package is the Go package's import path, or the TypeScript package's directory in the workspace.
Package string `json:"package"`
// Test names the failing test, or the tests that were running when the program stopped; "" when none can be named.
Test string `json:"test,omitempty"`
// File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col).
File string `json:"file,omitempty"`
// Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first
// error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine).
Cause string `json:"cause,omitempty"`
// 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"`
}
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
// assertion message and stays far under a bus message's or a verdict's bound.
const (
OutputBytes = 4096
LineBytes = 1024
)
// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides
// its cause ("the build module hid the failure (<by>)").
func By(module string) string {
return module + " with mesh-sdk " + sdkversion.Version()
}
// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any
// indent), go test's "ok \t<pkg>", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output.
func PassingLine(l string) bool {
if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0
return true
}
t := strings.TrimSpace(l)
switch {
case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"):
return true
}
return tapOk(t)
}
// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line.
// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract.
func Hidden(f Failure) string {
c := strings.TrimSpace(f.Cause)
switch {
case c == "":
return "it sent no cause"
case PassingLine(c):
return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c))
}
return ""
}
// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier
// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no
// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line.
// Passing lines are dropped.
func Clip(lines []string, cause string) string {
own := make([]string, 0, len(lines))
for _, l := range lines {
if !PassingLine(l) {
own = append(own, l)
}
}
lines = own
size, from := 0, len(lines)
for from > 0 {
n := len(CutLine(lines[from-1])) + 1
if size+n > OutputBytes && from < len(lines) {
break
}
size += n
from--
}
kept := make([]string, 0, len(lines)-from+2)
head := strings.TrimSpace(cause)
for i := 0; head == "" && i < from; i++ {
head = strings.TrimSpace(lines[i])
}
if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) {
kept = append(kept, CutLine(head))
}
if from > 0 {
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
}
for _, l := range lines[from:] {
kept = append(kept, CutLine(l))
}
return strings.Join(kept, "\n")
}
// CutLine cuts a line to LineBytes, on a character boundary.
func CutLine(l string) string {
if len(l) <= LineBytes {
return l
}
return strings.ToValidUTF8(l[:LineBytes], "") + "..."
}
// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the
// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a
// heading ("# <package>", npm's "> script"), and its output the command's lines without passing ones.
func ConsoleFailure(pkg, test, out string) Failure {
var lines []string
for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") {
if t := strings.TrimSpace(l); t != "" && !PassingLine(t) {
lines = append(lines, strings.TrimRight(l, " \t"))
}
}
cause := ""
for _, l := range lines {
if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) {
cause = t
break
}
}
for _, l := range lines {
if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") {
cause = t
}
}
if cause == "" {
cause = test + " failed and wrote nothing that says why"
}
cause = CutLine(cause)
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
}
-19
View File
@@ -1,19 +0,0 @@
package buildstep
import (
"regexp"
"strings"
)
// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error.
var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `)
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") }
// tapOk says a line is a passing test's "ok N - name", indented or not.
func tapOk(l string) bool {
rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ")
return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9'
}
-14
View File
@@ -1,14 +0,0 @@
package buildstep
import "git.novox.be/novox/mesh-sdk/go/catalogue"
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
const LauncherSuffix = catalogue.LauncherSuffix
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root
// (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share
// (R4e, issue 1126), so the name a build writes is the name a reader expects.
func Launchers(language string, entrypoints []string) map[string]string {
return catalogue.Launchers(language, entrypoints)
}
-22
View File
@@ -1,22 +0,0 @@
package buildstep
import (
"reflect"
"testing"
)
// A TypeScript bundle's entrypoint x.js is started as x.serve.mjs beside it (novox/hq ADR 0193), the rule mesh-build-node
// and build-agent both apply; an entrypoint that is not .js gets none, and another language gets none.
func TestATypeScriptEntrypointIsStartedByTheLauncherBesideIt(t *testing.T) {
got := Launchers("typescript", []string{"index.js", "tools/x.js", "data.json"})
want := map[string]string{"index.js": "index.serve.mjs", "tools/x.js": "tools/x.serve.mjs"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Launchers(typescript) = %v, want %v", got, want)
}
if got := Launchers("go", []string{"index.js"}); len(got) != 0 {
t.Fatalf("a Go bundle got launchers: %v", got)
}
if got := Launchers("typescript", nil); len(got) != 0 {
t.Fatalf("a bundle with no entrypoint got launchers: %v", got)
}
}
-163
View File
@@ -1,163 +0,0 @@
package buildstep
import (
"fmt"
"maps"
"path"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
var (
requestCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
requestRepositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
)
// FetchRequest is what a build module's fetch tool is asked: the repository and the full commit, the pull request's
// ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, and, for a build of a
// module whose every artifact names its paths, that module's directory and its named paths (novox/hq ADR 0499 clause 4).
//
// **The paths come from the builder, not from a manifest** (ADR 0495 clause 1; the lead's decision L8 of R4e): the
// builder fills Dir and Paths from the indexer's Module value (Path, and KeptInputs2.Paths' keys), and the build module
// gives the build and test steps a workspace holding those paths, the manifest and its lock files. A request without
// them builds from the module's directory, as before.
type FetchRequest struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
Ref string `json:"ref,omitempty"`
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.
func FetchRequestProperties() map[string]any {
return 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"},
}
}
// Args is r as a tool call's arguments.
func (r FetchRequest) Args() map[string]any {
args := map[string]any{"repository": r.Repository, "commit": r.Commit}
for k, v := range map[string]string{"ref": r.Ref, "base": r.Base, "dir": r.Dir} {
if v != "" {
args[k] = v
}
}
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
}
// ParseFetchRequest reads a fetch tool's arguments; arguments it does not know (a build module's own) are left to the
// caller. It refuses a repository that is not <owner>/<name>, a commit or a base that is not 40 hex digits, paths with no
// module directory or a directory with no path, a directory leaving the repository and a path leaving the directory,
// naming each.
func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
var r FetchRequest
r.Repository, _ = args["repository"].(string)
r.Commit, _ = args["commit"].(string)
r.Ref, _ = args["ref"].(string)
r.Base, _ = args["base"].(string)
r.Dir, _ = args["dir"].(string)
switch v := args["paths"].(type) {
case nil:
case []string:
r.Paths = append([]string(nil), v...)
case []any:
for _, p := range v {
s, ok := p.(string)
if !ok {
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
}
r.Paths = append(r.Paths, s)
}
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, "..") {
return fmt.Errorf("%q is not a repository, <owner>/<name>", r.Repository)
}
if !requestCommitOK.MatchString(r.Commit) {
return fmt.Errorf("%q is not a full commit (40 hex digits)", r.Commit)
}
if r.Base != "" && !requestCommitOK.MatchString(r.Base) {
return fmt.Errorf("%q is not a trunk commit (40 hex digits)", r.Base)
}
switch {
case len(r.Paths) > 0 && r.Dir == "":
return fmt.Errorf("the request names paths and names no module directory (dir) they are relative to")
case len(r.Paths) == 0 && r.Dir != "":
return fmt.Errorf("the request names a module directory and no path")
}
if r.Dir != "" && (outside(r.Dir) || strings.HasPrefix(r.Dir, "/")) {
return fmt.Errorf("the module directory %q leaves the repository", r.Dir)
}
for _, p := range r.Paths {
if p == "" || strings.HasPrefix(p, "/") || outside(p) || path.Clean(p) == "." {
return fmt.Errorf("the path %q leaves the module's directory or is not a path in it", p)
}
}
return nil
}
func outside(p string) bool {
c := path.Clean(p)
return c == ".." || strings.HasPrefix(c, "../")
}
-64
View File
@@ -1,64 +0,0 @@
package buildstep
import (
"reflect"
"strings"
"testing"
)
// A build's fetch is handed the paths of a module that names them (novox/hq ADR 0499 clause 4): the builder fills them
// 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"}, 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, 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)
}
if !reflect.DeepEqual(got, req) {
t.Fatalf("got %+v, want %+v", got, req)
}
plain, err := ParseFetchRequest(map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40),
"base": strings.Repeat("c", 40), "beside": []any{}})
if err != nil || plain.Paths != nil || plain.Base != strings.Repeat("c", 40) {
t.Fatalf("a request without paths: %+v, %v", plain, err)
}
ok := map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40)}
for _, c := range []struct {
set map[string]any
says string
}{
{map[string]any{"repository": "novox"}, "is not a repository"},
{map[string]any{"repository": "novox/../x"}, "is not a repository"},
{map[string]any{"commit": "abc"}, "is not a full commit"},
{map[string]any{"base": "abc"}, "is not a trunk commit"},
{map[string]any{"paths": []any{"src"}}, "names no module directory"},
{map[string]any{"dir": "modules/a", "paths": []any{"../b"}}, "leaves the module's directory"},
{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 {
args[k] = v
}
for k, v := range c.set {
args[k] = v
}
if _, err := ParseFetchRequest(args); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%v: %v, wanted a refusal saying %q", c.set, err, c.says)
}
}
}
-330
View File
@@ -1,330 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// RestartReason is why a step a previous process left running is ended.
const RestartReason = "the build module restarted while it ran"
// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken
// for this process's, which may still run it, so EndOne refuses it.
var Boot = time.Now()
// Owner is this process as a step's owner (delivery.Step.Owner): `<boot id>.<pid>.<start>`, the node's boot id
// (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart
// that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's.
var Owner = ownerOf(bootID, os.Getpid(), Boot)
// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once.
var bootID = readBootID()
const noBootID = "no-boot-id"
func ownerOf(bootID string, pid int, start time.Time) string {
return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36)
}
// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes
// apart).
func readBootID() string {
b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
if id := strings.TrimSpace(string(b)); err == nil && id != "" {
return id
}
return noBootID
}
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key
// still at the revision read; both fail with stdio.ErrChanged when another writer was first.
type Scan struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, uint64, error)
Update func(key string, value any, revision uint64) (uint64, error)
Delete func(key string, revision uint64) error
}
// ScanOf is the Scan of a state the process keeps.
//
// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's
// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is
// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves
// the steps state until its run writes its end, which its asker still reads (a watch takes puts only).
func ScanOf(s stdio.KeptState) Scan {
get := func(key string) (json.RawMessage, uint64, error) {
e, err := s.Get(key)
if err != nil || e == nil {
return nil, 0, err
}
return e.Value, e.Revision, nil
}
return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error {
_, rev, err := get(key)
if err != nil {
return err
}
if rev != revision {
return stdio.ErrChanged
}
return s.Delete(key)
}}
}
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
// A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound
// has passed (delivery.StepBound), when it is nobody's.
func isAsk(key string, s delivery.Step) bool {
_, _, err := delivery.ParseAskStepKey(key)
return err == nil && strings.HasSuffix(s.Tool, " ask")
}
// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and
// fingerprint.
func AskWhat(a delivery.StepAsk) string {
short := func(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v }
parts := []string{a.Kind + " ask"}
switch {
case a.Check != nil:
parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ","))
if a.Check.Run != "" {
parts = append(parts, "run "+a.Check.Run)
}
case a.Build != nil:
parts = append(parts, "module "+a.Build.Module)
if r := str(a.Build.Fetch, "repository"); r != "" {
parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit")))
}
if a.Build.FP != "" {
parts = append(parts, "fp "+short(a.Build.FP))
}
case a.Compose != nil:
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
}
return strings.Join(parts, ", ")
}
// endRunning writes step ended failed with why, only while its key is still at revision.
func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error {
step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil
raw, err := delivery.EncodeStep(step)
if err != nil {
return err
}
_, err = scan.Update(key, json.RawMessage(raw), revision)
return err
}
// EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys
// of node are read; a value that is no step is left alone.
// - A hand-started step with an owner (delivery.Step.Owner) that is not this process (Owner) is ended failed with
// RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would
// write its end (issue 1890, note 21).
// - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the
// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
// held one of a build node's two slots).
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
// start is older than delivery.StepBound, and then ended "past the step bound: its asker stopped waiting at <time>".
//
// Each end and delete is logged, naming the step, its owner and why. A key written by another writer since it was read
// (a redelivered ask's start, a real end) is skipped.
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
keys, err := scan.Keys()
if err != nil {
return nil, fmt.Errorf("buildstep: listing the steps of %s: %w", node, err)
}
var ended []string
for _, k := range keys {
n, _, perr := delivery.ParseStepKey(k)
if perr != nil || n != node {
continue
}
raw, rev, err := scan.Get(k)
if err != nil {
return ended, fmt.Errorf("buildstep: reading the step %s: %w", k, err)
}
if len(raw) == 0 {
continue
}
step, err := delivery.DecodeStep(raw)
if err != nil || step.State != delivery.StepRunning {
continue
}
why, said := staleWhy(k, step, before)
if why == "" {
continue
}
done := "ended"
if step.Owner != "" && isAsk(k, step) {
done = "deleted, left to the stream's redelivery"
if scan.Delete == nil {
err = fmt.Errorf("the steps state takes no delete")
} else {
err = scan.Delete(k, rev)
}
} else {
err = endRunning(scan, k, step, rev, why)
}
if err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
}
if logf != nil {
logf("the step %s (%s, started %s) was left running and is %s: %s; %s", k, step.Tool,
step.Started.UTC().Format(time.RFC3339), done, why, said)
}
ended = append(ended, step.ID)
}
return ended, nil
}
// staleWhy is why a running step is ended (or, for an ask with an owner, deleted) by EndStale and what the log says of
// its owner, or "" to leave it.
func staleWhy(key string, step delivery.Step, before time.Time) (why, said string) {
switch {
case step.Owner == Owner:
return "", ""
case step.Owner != "":
return RestartReason, "its owner " + step.Owner + " is not this process " + Owner
case isAsk(key, step):
if !step.Started.Add(delivery.StepBound).Before(now()) {
return "", ""
}
return "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z"),
"it names no owner (an older build wrote it)"
case step.Started.Before(before):
return RestartReason, "it names no owner (an older build wrote it) and started before this process"
}
return "", ""
}
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
// refused, and so is one this process owns (Owner; for a step with no owner, one started since Boot: it may still run,
// whether a pulled ask or a step a tool started), one live reports as held, and one whose key changed since it was read.
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
id = strings.TrimSpace(id)
if n, rest, ok := strings.Cut(id, "."); ok {
if n != node {
return delivery.Step{}, fmt.Errorf("the step %q is of node %q, this module runs on %q", id, n, node)
}
id = rest
}
if strings.TrimSpace(why) == "" {
return delivery.Step{}, fmt.Errorf("say why the step is ended (why)")
}
key, err := delivery.StepKey(node, id)
if err != nil {
return delivery.Step{}, err
}
raw, rev, err := scan.Get(key)
if err != nil {
return delivery.Step{}, err
}
if len(raw) == 0 {
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
}
step, err := delivery.DecodeStep(raw)
if err != nil {
return delivery.Step{}, err
}
if step.State != delivery.StepRunning {
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
}
if step.Owner == Owner || (step.Owner == "" && !step.Started.Before(Boot)) || (live != nil && live(id)) {
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
}
if err := endRunning(scan, key, step, rev, why); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return delivery.Step{}, fmt.Errorf("the step %s changed while it was being ended; read it again", key)
}
return delivery.Step{}, err
}
step.State, step.Reason = delivery.StepFailed, why
return step, nil
}
// StaleAttempts and StaleRetry bound EndStaleAtStart's tries: the steps state is readable once the runtime serves.
var (
StaleAttempts = 6
StaleRetry = 5 * time.Second
)
// StaleServed is what EndStaleAtStart waits for before it reads the steps state: the runtime serving (stdio.Served).
// The conformance kit's left-running case (conformance.LeftRunning) hands it a closed channel, to run a module's own
// start sweep in its test.
var StaleServed = stdio.Served
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
// then ends this node's steps a previous process left running (EndStale), trying again when the state cannot be read
// yet. A step names its owner, so `before` (the start of this process) matters only for a step with no owner, written
// by an older build: such a step started before it is a previous process's. When the node's boot id cannot be read it
// says so once: Owner then tells processes apart by their pid and start only.
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
select {
case <-ctx.Done():
return
case <-StaleServed():
}
if bootID == noBootID && logf != nil {
logf("the boot id of %s cannot be read (/proc/sys/kernel/random/boot_id): its steps' owner is %s, told apart by pid and start only", node, Owner)
}
for attempt := 1; ; attempt++ {
ended, err := EndStale(scan, node, before, logf)
if err == nil {
if logf != nil {
logf("%d steps of %s left running by a previous process ended", len(ended), node)
}
return
}
if logf != nil {
logf("ending the steps left running (try %d of %d): %v", attempt, StaleAttempts, err)
}
if attempt >= StaleAttempts {
return
}
select {
case <-ctx.Done():
return
case <-time.After(StaleRetry):
}
}
}
// EndStaleRound is EndStale as the module's periodic round runs it: at once, and only when the runtime serves (a round
// before that is skipped, the start's EndStaleAtStart and the next round cover it). It ends the steps a previous
// process left and the asks past delivery.StepBound.
func EndStaleRound(scan Scan, node string, logf func(string, ...any)) {
select {
case <-stdio.Served():
default:
return
}
if _, err := EndStale(scan, node, Boot, logf); err != nil && logf != nil {
logf("ending the steps left running: %v", err)
}
}
-162
View File
@@ -1,162 +0,0 @@
package buildstep
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// scanOf is the steps a keys double holds, last value per key, its revision the count of its puts; a write through
// Update lands only at the revision read, as the state's does. race, when set, runs once before the next Update's check.
func scanOf(k *keys, race func(key string)) Scan {
return Scan{
Keys: func() ([]string, error) {
k.mu.Lock()
defer k.mu.Unlock()
var out []string
for key := range k.puts {
out = append(out, key)
}
return out, nil
},
Get: func(key string) (json.RawMessage, uint64, error) {
k.mu.Lock()
defer k.mu.Unlock()
s, ok := k.puts[key]
if !ok {
return nil, 0, nil
}
raw, err := delivery.EncodeStep(s[len(s)-1])
return raw, uint64(len(s)), err
},
Delete: func(key string, revision uint64) error {
k.mu.Lock()
defer k.mu.Unlock()
if uint64(len(k.puts[key])) != revision {
return stdio.ErrChanged
}
delete(k.puts, key)
return nil
},
Update: func(key string, value any, revision uint64) (uint64, error) {
if race != nil {
race(key)
}
k.mu.Lock()
n := uint64(len(k.puts[key]))
k.mu.Unlock()
if n != revision {
return 0, stdio.ErrChanged
}
return k.put(key, value)
},
}
}
// 1890 and 1890/AC6: EndStale ends this node's hand-started running steps of another owner at once, whatever their age,
// and deletes its asks of another owner (the stream redelivers them, ADR 0525 §6); of
// the steps with no owner (an older build's), the hand-started ones left running before the process began and the asks
// past the step bound (a younger ask is the stream's to redeliver); never a step this process owns or a key written
// meanwhile. EndOne ends one by hand, never one this process owns or started.
func TestEndStaleAndEndOne(t *testing.T) {
k := newKeys()
boot := time.Now()
oldBoot := Boot
Boot = boot
defer func() { Boot = oldBoot }()
gone := ownerOf("b00t", 4242, boot.Add(-time.Hour))
put := func(node, id, tool, state string, started time.Time, owner string) {
key, _ := delivery.StepKey(node, id)
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started, Owner: owner})))
}
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), "")
put("ace", "mjk3l9x0abcded", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), gone) // note 21's ghost
put("ace", "gone2", "build_go_test", delivery.StepRunning, boot.Add(time.Second), gone)
put("ace", "mine1", "check ask", delivery.StepRunning, boot.Add(-time.Hour), Owner)
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), "")
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour), gone)
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), gone)
// raced1 is ended by its own run between the read and the write.
raced := false
scan := scanOf(k, func(key string) {
if key == "ace.raced1" && !raced {
raced = true
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour), "")
}
})
var said []string
ended, err := EndStale(scan, "ace", boot, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
if err != nil || len(ended) != 4 {
t.Fatalf("ended %v, %v", ended, err)
}
if _, held := k.puts["ace.mjk3l9x0abcded"]; held {
t.Errorf("the ghost ask's key is still held: %+v", k.last("ace.mjk3l9x0abcded"))
}
for _, key := range []string{"ace.old1", "ace.gone2"} {
if s := k.last(key); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
t.Errorf("%s = %+v", key, s)
}
}
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
t.Errorf("the old ask = %+v", s)
}
if log := strings.Join(said, "\n"); !strings.Contains(log, "ace.mjk3l9x0abcded") || !strings.Contains(log, "deleted, left to the stream's redelivery") || !strings.Contains(log, "its owner "+gone+" is not this process "+Owner) {
t.Errorf("the log does not name the ghost and its owner:\n%s", log)
}
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.mine1": delivery.StepRunning, "ace.raced1": delivery.StepPassed,
"ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
if s := k.last(key); s.State != state {
t.Errorf("%s = %s, want %s", key, s.State, state)
}
}
// EndOne: refuses without why, a held step, one this process owns or (with no owner) started, a step not running and
// another node's; ends a step a previous process left.
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"mine1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
t.Errorf("ended %s with why %q", c.id, c.why)
}
}
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), gone)
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
t.Error("ended a held step")
}
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
t.Fatal(err)
}
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" || s.Owner != gone {
t.Errorf("old2 = %+v", s)
}
if a, b := ownerOf("b", 1, boot), ownerOf("b", 1, boot.Add(time.Second)); a == b {
t.Errorf("two starts of one pid share the owner %q", a)
}
}
func mustStep(t *testing.T, s delivery.Step) []byte {
t.Helper()
raw, err := delivery.EncodeStep(s)
if err != nil {
t.Fatal(err)
}
return raw
}
// 1890: AskWhat names the repository, head and levels of a check ask and the module of a build ask.
func TestAskWhat(t *testing.T) {
check := delivery.StepAsk{Kind: "check", Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": "0123456789abcdef0123"}, Levels: []string{"unit", "integration"}}}
if got, want := AskWhat(check), "check ask, novox/mesh-sdk@0123456789ab, levels unit,integration"; got != want {
t.Errorf("check = %q", got)
}
build := delivery.StepAsk{Kind: "delivery", Build: &delivery.BuildJob{Module: "mesh-x", FP: "abcdef0123456789"}}
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
t.Errorf("build = %q", got)
}
}
-22
View File
@@ -1,22 +0,0 @@
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
// (issue 1473), the fetch of one commit over SSH (issue 1474), and the failure contract of a test level with the parsers
// of go test -json and node --test's TAP that fill it (failure.go, issue 1901; its conformance kit is package conformance).
package buildstep
import (
"crypto/rand"
"encoding/hex"
"strconv"
"time"
)
var now = time.Now
// NewID is a step id: the time in milliseconds in base 36 and six random hex digits, lower-case letters and
// digits only, so it is one key token and differs between two steps of one node even in one millisecond.
func NewID() string {
var b [3]byte
_, _ = rand.Read(b[:])
return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:])
}
-72
View File
@@ -1,72 +0,0 @@
package buildstep
import (
"encoding/json"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// keys is a steps state in memory: every value put, in order, per key, and a signal for each end.
type keys struct {
mu sync.Mutex
puts map[string][]delivery.Step
ended chan string
}
func newKeys() *keys { return &keys{puts: map[string][]delivery.Step{}, ended: make(chan string, 16)} }
func (k *keys) put(key string, value any) (uint64, error) {
step, err := delivery.DecodeStep(value.(json.RawMessage))
if err != nil {
return 0, err
}
k.mu.Lock()
k.puts[key] = append(k.puts[key], step)
n := uint64(len(k.puts[key]))
k.mu.Unlock()
if step.State != delivery.StepRunning {
k.ended <- key
}
return n, nil
}
func (k *keys) last(key string) delivery.Step {
k.mu.Lock()
defer k.mu.Unlock()
s := k.puts[key]
return s[len(s)-1]
}
func (k *keys) waitEnd(t *testing.T) string {
t.Helper()
select {
case key := <-k.ended:
return key
case <-time.After(3 * time.Second):
t.Fatal("the step never ended on its key")
return ""
}
}
// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed
// while no step runs, so no goroutine reads it meanwhile.
func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
was := now
defer func() { now = was }()
at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
now = func() time.Time { return at }
seen := map[string]bool{}
for i := 0; i < 50; i++ {
id := NewID()
if seen[id] {
t.Fatalf("id %q handed out twice", id)
}
seen[id] = true
if _, err := delivery.StepKey("ace", id); err != nil {
t.Errorf("id %q: %v", id, err)
}
}
}
-15
View File
@@ -1,15 +0,0 @@
package buildstep
// ModuleTree is what a merge holds of one module directory, as a build module's preview answers it (novox/hq ADR 0484
// clause 3, as ADR 0593 §2 changes it): its git tree id, the sha256 of its module.json, the sha256 of each lock file it
// has (go.sum, package-lock.json), and, of a module whose every artifact names its paths, each named path's git object
// id at the commit. The indexer alone computes from them (the named tree, the fingerprint); the build module's runner
// (mesh-build-go's buildstep.ModuleTrees) fills them.
type ModuleTree struct {
Tree string `json:"tree"`
Manifest string `json:"manifest"`
Locks map[string]string `json:"locks,omitempty"`
Paths map[string]string `json:"paths,omitempty"`
// Packages are no longer filled (ADR 0593 §2): kept so a preview written before still decodes.
Packages []string `json:"packages,omitempty"`
}
+50
View File
@@ -0,0 +1,50 @@
package buscalls
import "encoding/json"
// Unwrap reads a tool's answer whatever wraps it on its way from the tool runner (the lead's ruling 2026-11-04 13:12:
// one reading, not a copy per module): a JSON string holding the answer; the protocol's content list, whose first
// text is the answer and whose isError says it failed; the {output, ok, answer} form; the tool runner's {result,
// error} envelope, whose error is the call's. It answers the answer itself, the words said with it (the output, the
// error, or the answer's own text), and whether the tool says it succeeded. A bare answer is itself, succeeded.
func Unwrap(raw json.RawMessage) (answer json.RawMessage, said string, ok bool) {
var s string
if json.Unmarshal(raw, &s) == nil {
return Unwrap(json.RawMessage(s))
}
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil {
return raw, string(raw), true
}
if content, has := m["content"]; has {
var items []struct {
Text string `json:"text"`
}
var e struct {
IsError bool `json:"isError"`
}
_ = json.Unmarshal(raw, &e)
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
inner, out, ok := Unwrap(json.RawMessage(items[0].Text))
return inner, out, ok && !e.IsError
}
}
var v struct {
Output string `json:"output"`
OK *bool `json:"ok"`
Answer json.RawMessage `json:"answer"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
if json.Unmarshal(raw, &v) == nil {
switch {
case v.OK != nil:
return v.Answer, v.Output, *v.OK
case v.Error != "":
return nil, v.Error, false
case len(v.Result) > 0:
return Unwrap(v.Result)
}
}
return raw, string(raw), true
}
+3 -162
View File
@@ -1,129 +1,11 @@
package buscompose
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
)
import "time"
// AccountsState is the grants part's state of wanted accounts, by bus user.
const AccountsState = "accounts"
// GrantsAccounts is that state as a reader names it (`<owner>.<name>`).
const GrantsAccounts = GrantsModule + "." + AccountsState
// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash.
// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash. The state of
// wanted accounts it mints from is the grants part's (grantsstate.AccountsState; novox/hq ADR 0634 §1).
const AccountIssuedEvent = "account-issued"
// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events.
const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent
// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a
// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it.
type ClaimedSeat struct {
Seat string `json:"seat"`
Scope string `json:"scope"`
Serves []string `json:"serves,omitempty"`
}
// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password.
type WantedAccount struct {
User string `json:"user"`
// Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess, KindProvisioner or
// KindLedger.
Kind Kind `json:"kind"`
Node string `json:"node"`
Module string `json:"module"`
// Process is a KindProcess account's process id.
Process string `json:"process,omitempty"`
// URL is the bus's address as that node reaches it, `nats://<host>:<port>`; Fingerprint the bus certificate's.
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
// Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client.
Claims []ClaimedSeat `json:"claims,omitempty"`
// At is when the grants part wrote the key: not compared (Same).
At time.Time `json:"at"`
}
// LedgersSecret is the tool runner's own secret holding its machine's ledger accounts, a map of module to credential
// (ADR 0597 §9).
const LedgersSecret = "ledgers"
// mintedKinds are the kinds of account the vault mints (ADR 0561 §1).
var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner, KindLedger}
// principal is the account as the composer names it.
func (w WantedAccount) principal() Principal {
return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process}
}
// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user
// the one the composer derives from them, and an address to reach the bus at.
func (w WantedAccount) Valid() error {
if !slices.Contains(mintedKinds, w.Kind) {
return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind)
}
for what, v := range map[string]string{"node": w.Node, "module": w.Module} {
if !safeSubject.MatchString(v) {
return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v)
}
}
if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) {
return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process)
}
if w.Kind != KindProcess && w.Process != "" {
return fmt.Errorf("%s: only a process's account names a process", w.User)
}
if want := w.principal().Username(); w.User != want {
return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want)
}
if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") {
return fmt.Errorf("%s names no bus address (url, nats://<host>:<port>)", w.User)
}
if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools {
return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind)
}
return nil
}
// Same says whether two wanted accounts want the same account: every field but when it was written.
func (w WantedAccount) Same(o WantedAccount) bool {
a, b := w, o
a.At, b.At = time.Time{}, time.Time{}
ra, _ := json.Marshal(a)
rb, _ := json.Marshal(b)
return string(ra) == string(rb)
}
// OwnSecret is the module's own secret the account is held in: `broker`, `broker-<process id>` or `provisioner-broker`
// (the controller's names, ADR 0436 §2, ADR 0349 §11); a ledger account in the tool runner's `ledgers`, which keeps every
// ledger account of its machine, by module (ADR 0597 §9).
func (w WantedAccount) OwnSecret() string {
switch w.Kind {
case KindLedger:
return LedgersSecret
case KindProcess:
return "broker-" + w.Process
case KindProvisioner:
return "provisioner-broker"
}
return "broker"
}
// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user.
func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) {
var w WantedAccount
if err := json.Unmarshal(raw, &w); err != nil {
return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err)
}
if w.User != user {
return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User)
}
return w, w.Valid()
}
// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash.
type AccountIssued struct {
User string `json:"user"`
@@ -134,44 +16,3 @@ type AccountIssued struct {
Generation int `json:"generation"`
At time.Time `json:"at"`
}
// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith,
// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a
// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out.
func HeldCredential(w WantedAccount, password string) ([]byte, error) {
if err := w.Valid(); err != nil {
return nil, err
}
if password == "" {
return nil, fmt.Errorf("%s: an account is held with its password", w.User)
}
switch w.Kind {
case KindProcess:
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
User string `json:"user"`
Password string `json:"password"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password})
case KindProvisioner:
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
User string `json:"user"`
Password string `json:"password"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password})
}
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
User string `json:"user"`
Password string `json:"password"`
Claims []ClaimedSeat `json:"claims,omitempty"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
Claims: w.Claims})
}
-102
View File
@@ -1,102 +0,0 @@
package buscompose
import (
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
)
// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module
// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before,
// which its manifest no longer consumes and no part composes; each `<stream>.<consumer>`.
var ControllerLeftoverConsumers = []string{
"EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog",
"CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName,
}
// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller
// made for the node's engine (`EVENTS.<node>_mesh-host`, of restic's backed-up): the engine hears that event without a
// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126).
func ControllerLeftoverConsumersOn(nodes []string) []string {
out := slices.Clone(ControllerLeftoverConsumers)
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "EVENTS."+n+"_"+EngineModule)
}
}
return out
}
// endedController is the name the ended controller had as a module: its buckets are named for it (ADR 0590 §6).
const endedController = "mesh-controller"
// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_<name>`).
var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed",
"condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts",
"lease", "predefined", "rollouts", "states-announced"}
// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets'
// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2).
func ControllerLeftoverStreams(nodes []string) []string {
out := []string{"CONTROL", AssignmentsStream}
for _, b := range ControllerLeftoverBuckets {
out = append(out, "KV_"+BucketName(endedController, b))
}
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "OBJ_"+DeclarationFilesBucket+n)
}
}
return out
}
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause
// 6), each removal its grant: 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; and
// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the
// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a
// manifest (novox/hq ADR 0578 §1).
func BusRemovalGrants(removes, nodes []string) ([]string, error) {
var out []string
for _, r := range removes {
switch r {
case catalogue.RemovesEngineActs:
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
case catalogue.RemovesControllerLeftovers:
for _, c := range ControllerLeftoverConsumersOn(nodes) {
out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c)
}
for _, s := range ControllerLeftoverStreams(nodes) {
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s)
}
default:
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r,
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
}
}
return out, nil
}
// 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), the dead letters kept, and the lease write's events. Never the bus's API audit
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
// them (the lead, 2026-10-29).
func WatchmanHears() []string {
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 + ">",
}
}
+7 -4
View File
@@ -4,6 +4,7 @@ import (
"testing"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with
@@ -24,12 +25,14 @@ func TestTheBusNamesAgree(t *testing.T) {
sn != "mesh.ledger.m.records.snapshot" {
t.Fatalf("a ledger's subjects are %q and %q", h, sn)
}
// 1966/AC23: a wanted account of kind ledger for m on a names the user ledger.a.m and the own secret ledgers.
ledger := WantedAccount{User: "ledger.a.m", Kind: KindLedger, Node: "a", Module: "m", URL: "nats://10.0.0.1:4222"}
// 1966/AC23: a ledger account for m on a is named ledger.a.m.
if got := (Principal{Kind: KindLedger, Node: "a", Module: "m"}).Username(); got != "ledger.a.m" {
t.Fatalf("a ledger account is named %q", got)
}
if err := ledger.Valid(); err != nil || ledger.OwnSecret() != "ledgers" {
t.Fatalf("a wanted ledger account: %v, held in %q", err, ledger.OwnSecret())
// A seat's family is the shared events stream filtered to its subjects: a definition says the stream's age.
for _, st := range MeshStreams() {
if st.Name == EventsStream && int64(st.MaxAge) != seatdef.SharedEventsMaxAgeDays*24*60*60 {
t.Errorf("the shared events stream keeps %d s, a seat's definition says %d days", st.MaxAge, seatdef.SharedEventsMaxAgeDays)
}
}
}
-3
View File
@@ -46,6 +46,3 @@ type KeyedRead struct {
Read string `json:"read"`
Key string `json:"key"`
}
// ResolvedBucket is mesh-config's state of resolved values, keyed `<node>.<module>.<key>`.
const ResolvedBucket = "mesh-config_resolved"
-3
View File
@@ -100,6 +100,3 @@ type ProcessPrincipal struct {
// seat:<seat>.<verb>, or * for every tool on the mesh.
Invokes []string
}
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
const VaultSeat = "mesh-vault"
+31
View File
@@ -0,0 +1,31 @@
package stdio
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// busStateCalls are the method prefixes of the calls a bundle makes on its own bus state through the runtime (novox/hq
// ADR 0595 §9, design 25 § 12.5): their shapes and names are package buscalls's, and package busstate the typed way
// to make them.
var busStateCalls = []string{"mesh/objects.", "mesh/stream.", "mesh/workqueue.", "mesh/ledger."}
// BusState asks the runtime one call on this bundle's own bus state, an entry its module asks for in `requires`, named
// by its local name in params; the runtime serves it from the module's own bindings only. A method that is none of
// those calls is refused here, before the runtime is asked.
func BusState(method string, params any) (json.RawMessage, error) {
known := false
for _, p := range busStateCalls {
known = known || strings.HasPrefix(method, p)
}
if !known {
return nil, fmt.Errorf("%s is no call on a bundle's own bus state (%s)", method, strings.Join(busStateCalls, ", "))
}
c, err := live()
if err != nil {
return nil, err
}
return c.ask(method, params, 60*time.Second)
}
+59
View File
@@ -0,0 +1,59 @@
// Package busstate is how a bundle uses its own bus state through the runtime (novox/hq ADR 0595 §9, design 25 §
// 12.5): the object store's put, get, list, delete and fill, each naming an entry by the local name its module asks for
// it by in `requires`. The runtime serves the calls from the module's own bindings, its own entries only. The shapes
// are package buscalls's; this package makes the calls.
package busstate
import (
"encoding/json"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// Objects is one object store the module asks for in `requires` (the template objects, ADR 0595 §2), by its local name.
type Objects struct{ local string }
// ObjectsOf is the object store of that local name.
func ObjectsOf(local string) Objects { return Objects{local: local} }
func call[T any](method string, params any) (T, error) {
var out T
raw, err := stdio.BusState(method, params)
if err != nil {
return out, err
}
if err := json.Unmarshal(raw, &out); err != nil {
return out, fmt.Errorf("the runtime's answer to %s: %w", method, err)
}
return out, nil
}
// Put stores one object whole under its name, replacing one of that name, and answers it as the store holds it.
func (o Objects) Put(name string, data []byte, description string) (buscalls.ObjectInfo, error) {
return call[buscalls.ObjectInfo](buscalls.ObjectsPut, buscalls.ObjectsPutRequest{Objects: o.local, Name: name,
Data: data, Description: description})
}
// Get is one object, its info and its bytes.
func (o Objects) Get(name string) (buscalls.ObjectData, error) {
return call[buscalls.ObjectData](buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: o.local, Name: name})
}
// List is every object the store holds.
func (o Objects) List() ([]buscalls.ObjectInfo, error) {
l, err := call[buscalls.ObjectsListed](buscalls.ObjectsList, buscalls.ObjectsRequest{Objects: o.local})
return l.Objects, err
}
// Delete removes one object.
func (o Objects) Delete(name string) error {
_, err := call[buscalls.Done](buscalls.ObjectsDelete, buscalls.ObjectsNameRequest{Objects: o.local, Name: name})
return err
}
// Fill is how much the store holds against its cap.
func (o Objects) Fill() (buscalls.Fill, error) {
return call[buscalls.Fill](buscalls.ObjectsInfo, buscalls.ObjectsRequest{Objects: o.local})
}
+102
View File
@@ -0,0 +1,102 @@
package busstate
import (
"bufio"
"encoding/json"
"io"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// A bundle keeps objects in its own object store through the runtime (ADR 0595 §2, §9; A3's request for distribution's
// incoming): each call is the runtime's method of buscalls, naming the store by its local name, and answers as the
// runtime answered; a method that is no bus-state call is refused before the runtime is asked.
func TestABundleKeepsObjectsThroughTheRuntime(t *testing.T) {
t.Setenv(stdio.ServedAs, "distribution")
inR, inW := io.Pipe()
outR, outW := io.Pipe()
type result struct {
what string
err error
got any
}
results := make(chan result, 8)
go func() {
_ = stdio.ServeOn("", []stdio.Tool{{Name: "keep", Run: func(map[string]any) (any, error) {
go func() {
in := ObjectsOf("incoming")
info, err := in.Put("t1.part0", []byte("hello"), "a part")
results <- result{"put", err, info.Digest}
data, err := in.Get("t1.part0")
results <- result{"get", err, string(data.Data)}
list, err := in.List()
results <- result{"list", err, len(list)}
results <- result{"delete", in.Delete("t1.part0"), nil}
fill, err := in.Fill()
results <- result{"fill", err, fill.MaxBytes}
_, err = stdio.BusState("mesh/state.get", nil)
results <- result{"refused", err, nil}
}()
return "started", nil
}}}, inR, outW)
}()
lines := bufio.NewScanner(outR)
next := func() map[string]any {
done := make(chan map[string]any, 1)
go func() {
if lines.Scan() {
var m map[string]any
_ = json.Unmarshal(lines.Bytes(), &m)
done <- m
}
}()
select {
case m := <-done:
return m
case <-time.After(3 * time.Second):
t.Fatal("the bundle said nothing")
return nil
}
}
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "keep"}})
answers := map[string]any{
buscalls.ObjectsPut: buscalls.ObjectInfo{Name: "t1.part0", Size: 5, Digest: "SHA-256=x"},
buscalls.ObjectsGet: buscalls.ObjectData{Info: buscalls.ObjectInfo{Name: "t1.part0"}, Data: []byte("hello")},
buscalls.ObjectsList: buscalls.ObjectsListed{Objects: []buscalls.ObjectInfo{{Name: "t1.part0"}}},
buscalls.ObjectsDelete: buscalls.Done{},
buscalls.ObjectsInfo: buscalls.Fill{Count: 0, MaxBytes: 20 << 30},
}
asked := map[string]map[string]any{}
for len(asked) < len(answers) {
m := next()
method, _ := m["method"].(string)
if method == "" {
continue // the tool's own answer
}
p, _ := m["params"].(map[string]any)
asked[method] = p
send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": answers[method]})
}
want := map[string]any{"put": "SHA-256=x", "get": "hello", "list": 1, "delete": nil, "fill": uint64(20 << 30)}
for range 5 {
r := <-results
if r.err != nil || r.got != want[r.what] {
t.Errorf("%s answered %v, %v; want %v", r.what, r.got, r.err, want[r.what])
}
}
if r := <-results; r.err == nil {
t.Error("a call that is not on the bundle's bus state was asked of the runtime")
}
for method, p := range asked {
if p["objects"] != "incoming" {
t.Errorf("%s named the store %v, not its local name", method, p["objects"])
}
}
if p := asked[buscalls.ObjectsPut]; p["name"] != "t1.part0" || p["data"] != "aGVsbG8=" || p["description"] != "a part" {
t.Errorf("put asked %v", p)
}
}
+38 -6
View File
@@ -22,8 +22,19 @@ const MetaCaller = "mesh/caller"
// parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven.
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// calls holds the caller of every call while its tool runs, by the arguments map that call was handed.
var calls sync.Map // unsafe.Pointer → string
// MetaCall is where in a `tools/call`'s `_meta` the runtime names the id of the call in flight, the id a ledger's
// apply names so the runtime stamps the caller it proved as the operation's author (novox/hq ADR 0597 §7):
// buscalls.MetaCall, written here too so this package imports nothing of buscalls.
const MetaCall = "mesh/call"
// callID is the shape of a call's id: one subject-safe token, as the runtime makes it. Anything else reads as none.
var callID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
// inFlight is what the runtime said of one call while its tool runs.
type inFlight struct{ caller, call string }
// calls holds the caller and the id of every call while its tool runs, by the arguments map that call was handed.
var calls sync.Map // unsafe.Pointer → inFlight
func argsKey(args map[string]any) unsafe.Pointer {
return reflect.ValueOf(args).UnsafePointer()
@@ -37,11 +48,32 @@ func CallerOf(args map[string]any) string {
return Unproven
}
if c, ok := calls.Load(argsKey(args)); ok {
return c.(string)
return c.(inFlight).caller
}
return Unproven
}
// CallIDOf is the id of the call that was handed args, as the runtime named it under MetaCall: what a ledger's apply
// names (buscalls.ApplyRequest.Call) so the operation's author is the caller the runtime proved. Empty for arguments
// no call was handed, or a call the runtime named no id for; read from `_meta`, never from the arguments.
func CallIDOf(args map[string]any) string {
if args == nil {
return ""
}
if c, ok := calls.Load(argsKey(args)); ok {
return c.(inFlight).call
}
return ""
}
// callIn is the call id a `tools/call`'s `_meta` names, or empty.
func callIn(meta map[string]any) string {
if s, ok := meta[MetaCall].(string); ok && callID.MatchString(s) {
return s
}
return ""
}
// callerIn is the caller a `tools/call`'s `_meta` names, or Unproven.
func callerIn(meta map[string]any) string {
if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) {
@@ -50,10 +82,10 @@ func callerIn(meta map[string]any) string {
return Unproven
}
// runAs runs a tool with the caller held for its arguments for as long as it runs.
func runAs(t Tool, caller string, args map[string]any) (any, error) {
// runAs runs a tool with the caller and the call's id held for its arguments for as long as it runs.
func runAs(t Tool, caller, call string, args map[string]any) (any, error) {
key := argsKey(args)
calls.Store(key, caller)
calls.Store(key, inFlight{caller, call})
defer calls.Delete(key)
return t.Run(args)
}
+69 -5
View File
@@ -6,25 +6,33 @@ import (
"io"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call
// with the params given, and returns the caller it answered.
func callWith(t *testing.T, params map[string]any) string {
caller, _ := callAnswering(t, params)
return caller
}
// callAnswering is callWith answering the call's id CallIDOf read too.
func callAnswering(t *testing.T, params map[string]any) (string, string) {
servesAlone(t)
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
go func() {
_ = ServeOn("tracker", []Tool{{Name: "who", Run: func(args map[string]any) (any, error) {
return map[string]any{"caller": CallerOf(args)}, nil
return map[string]any{"caller": CallerOf(args), "call": CallIDOf(args)}, nil
}}}, inR, outW)
}()
defer inW.Close()
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})
_, _ = inW.Write(append(b, '\n'))
lines := bufio.NewScanner(outR)
done := make(chan string, 1)
done := make(chan [2]string, 1)
go func() {
if lines.Scan() {
var m struct {
@@ -37,19 +45,20 @@ func callWith(t *testing.T, params map[string]any) string {
_ = json.Unmarshal(lines.Bytes(), &m)
var answer struct {
Caller string `json:"caller"`
Call string `json:"call"`
}
if len(m.Result.Content) > 0 {
_ = json.Unmarshal([]byte(m.Result.Content[0].Text), &answer)
}
done <- answer.Caller
done <- [2]string{answer.Caller, answer.Call}
}
}()
select {
case got := <-done:
return got
return got[0], got[1]
case <-time.After(3 * time.Second):
t.Fatal("the bundle did not answer the call")
return ""
return "", ""
}
}
@@ -62,6 +71,28 @@ func TestAToolReadsTheCallerTheRuntimeNamed(t *testing.T) {
if got != "shanks.node-tools" {
t.Fatalf("the tool read caller %q, want the one the runtime named", got)
}
// The call's id is the runtime's too (ADR 0597 §7): read from `_meta`, never from the arguments, and none when it
// is not one token.
for _, c := range []struct {
args, meta map[string]any
want string
}{
{map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools", MetaCall: "c-7Hq2"}, "c-7Hq2"},
{map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools"}, ""},
{map[string]any{MetaCall: "c-forged"}, map[string]any{}, ""},
{map[string]any{}, map[string]any{MetaCall: "a b"}, ""},
{map[string]any{}, map[string]any{MetaCall: 7}, ""},
} {
if _, call := callAnswering(t, map[string]any{"name": "who", "arguments": c.args, "_meta": c.meta}); call != c.want {
t.Errorf("args %v, meta %v: the tool read call %q, want %q", c.args, c.meta, call, c.want)
}
}
if CallIDOf(map[string]any{"x": 1}) != "" || CallIDOf(nil) != "" {
t.Error("arguments no call was handed have a call id")
}
if MetaCall != buscalls.MetaCall {
t.Errorf("the call id's place is %q here and %q in buscalls", MetaCall, buscalls.MetaCall)
}
}
// A caller named in the arguments is not the caller: an argument is the caller's to write.
@@ -91,6 +122,39 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) {
if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil {
t.Error("a list with a field it does not know was read")
}
// A holder's list per verb (ADR 0627 §2): each verb's entries as above, plus every proved caller (AnyCaller) or
// every operator's session (AnyPerson); an unproven caller never, a verb the holder does not serve refused.
verbs, err := ParseVerbCallers(`{"publish":["mesh-build-go"],"versions":["*"],"release":["operator","sweep"]}`,
[]string{"publish", "versions", "release", "remove"}, nil)
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
verb, caller string
want bool
}{{"publish", "ace.mesh-build-go", true}, {"publish", "ace.mesh-build-node", false}, {"versions", "g14.anything", true},
{"versions", Unproven, false}, {"release", "person.jo", true}, {"release", "ace.operator", false},
{"release", "novox.sweep", true}, {"remove", "novox.sweep", false}} {
if got := verbs.Allowed(c.verb, c.caller) == nil; got != c.want {
t.Errorf("%s by %q allowed: %v, want %v", c.verb, c.caller, got, c.want)
}
}
if _, err := ParseVerbCallers(`{"mirror":["x"]}`, []string{"publish"}, nil); err == nil {
t.Error("a list naming a verb the holder does not serve was read")
}
if d, _ := ParseVerbCallers(" ", nil, VerbCallers{"publish": {"x"}}); len(d["publish"]) != 1 {
t.Error("an empty value is not the holder's defaults")
}
// As mesh-config resolves the value: the object itself, or written as a string; null is the defaults.
for _, raw := range []string{`{"publish":["a"]}`, `"{\"publish\":[\"a\"]}"`, `null`} {
c, err := parseConfiguredVerbCallers(json.RawMessage(raw), []string{"publish"}, VerbCallers{"publish": {"a"}})
if err != nil || c.Allowed("publish", "ace.a") != nil {
t.Errorf("the config value %s read as %v, %v", raw, c, err)
}
}
if ModuleOf("ace.mesh-build-go") != "mesh-build-go" {
t.Error("ModuleOf does not read the module of a proved caller")
}
}
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
+83
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
)
@@ -46,3 +47,85 @@ func (c Callers) Allows(caller string) bool {
}
return false
}
// VerbCallers is a holder's trusted list per verb (novox/hq ADR 0627 §2, design 51 *Who may call which verb*): for each
// verb of the seats it holds, the callers that may call it, each entry read as Callers reads it. Two entries more stand
// for a whole class: AnyCaller lets every proved caller call the verb (a read the mesh keeps open), AnyPerson every
// operator's session the runtime proved as person.<name>. It names no seat and no verb: the holder gives its verbs and
// its defaults, and keeps any rule of its own seat (who may never be on a list) beside it.
type VerbCallers map[string][]string
// The entries of a VerbCallers list that stand for a class of callers.
const (
AnyCaller = "*"
AnyPerson = "operator"
)
// ParseVerbCallers reads a holder's config value of verb to callers; an empty value is defaults. A verb not among verbs
// is refused, so a list that names a verb the holder does not serve is said at once.
func ParseVerbCallers(raw string, verbs []string, defaults VerbCallers) (VerbCallers, error) {
if strings.TrimSpace(raw) == "" {
return defaults, nil
}
var c VerbCallers
if err := json.Unmarshal([]byte(raw), &c); err != nil {
return nil, fmt.Errorf("callers is not a JSON object of verb to callers: %w", err)
}
for verb := range c {
if !slices.Contains(verbs, verb) {
return nil, fmt.Errorf("callers names the verb %q, which the holder does not serve", verb)
}
}
return c, nil
}
// Allowed answers nil when caller, as the runtime proved it, may call verb; else the refusal naming both. An unproven
// caller is never allowed, not even by AnyCaller.
func (c VerbCallers) Allowed(verb, caller string) error {
if caller == "" || caller == Unproven {
return fmt.Errorf("%s: refused: the request carries no caller the tool runner proved", verb)
}
person := strings.HasPrefix(caller, "person.")
for _, entry := range c[verb] {
switch entry {
case AnyCaller:
return nil
case AnyPerson:
if person {
return nil
}
default:
if (Callers{entry}).Allows(caller) {
return nil
}
}
}
return fmt.Errorf("%s: refused for %s: not on the holder's caller list for %s", verb, caller, verb)
}
// ModuleOf is the module of a proved module caller, which the runtime names <machine>.<module>.
func ModuleOf(proved string) string {
if _, module, ok := strings.Cut(proved, "."); ok {
return module
}
return proved
}
// ConfiguredVerbCallers is the holder's list as mesh-config resolved its config value key for this machine (Config):
// a JSON object of verb to callers, or that object written as a string; an unset or empty value is defaults. A value
// that cannot be read is an error, never the defaults, so a broken list is said, not widened.
func ConfiguredVerbCallers(key string, verbs []string, defaults VerbCallers) (VerbCallers, error) {
raw, err := Config(key)
if err != nil {
return nil, fmt.Errorf("the config value %s cannot be read: %w", key, err)
}
return parseConfiguredVerbCallers(raw, verbs, defaults)
}
func parseConfiguredVerbCallers(raw json.RawMessage, verbs []string, defaults VerbCallers) (VerbCallers, error) {
var text string
if err := json.Unmarshal(raw, &text); err == nil || len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
return ParseVerbCallers(text, verbs, defaults)
}
return ParseVerbCallers(string(raw), verbs, defaults)
}
+1 -3
View File
@@ -50,9 +50,7 @@ func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
fset := token.NewFileSet()
files, _ := filepath.Glob("*.go")
for _, f := range files {
// kept_until_delivered.go keeps, deprecated, the names removed while running parts still use them; the second
// group removes it and this exemption together.
if strings.HasSuffix(f, "_test.go") || f == "kept_until_delivered.go" {
if strings.HasSuffix(f, "_test.go") {
continue
}
parsed, err := parser.ParseFile(fset, f, nil, 0)
-43
View File
@@ -1,43 +0,0 @@
package catalogue
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
// distribution, and not the provision artifact-store it delivers.
const ArtifactSweepSeat = "mesh-artifact-sweep"
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
func artifactSweepVerbs() []Verb {
return []Verb{
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
Input: schema(map[string]string{
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
"collected": "\"true\": list the collected references too (counted always)",
}, nil, "collected")},
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
"Reads only.",
Input: listed(schema(map[string]string{
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
"or one text separated by commas",
}, []string{"references"}), "references")},
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
"seconds; what is left is said. Never touches a digest the mesh did not make.",
Input: schema(map[string]string{
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
"why": "with confirm: why, required and recorded",
"most": "let go of at most this many (500 by default, at most 5000)",
}, nil, "confirm")},
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
Input: schema(map[string]string{}, nil)},
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
"naming it: what the machine keeps when it prunes its images. Reads only.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
}
}
+2 -54
View File
@@ -1,58 +1,6 @@
package catalogue
import (
"strings"
)
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
// the store's address.
// the store's address. How a reference is recorded, read and routed (the registry seat's form beside this one) is the
// registry clients' (mesh-delivery's contracts, `containerimageregistryclient/reference`; ADR 0634 §4).
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
// Recorded is a reference as the mesh records it: the artifact store's address, if the builder wrote
// one, taken off.
//
// For a reference the builder announced — one it pushed to the store — which is the only kind
// this is called on. An image the builder named `<host>/<path>@sha256:…` is kept as its path; an
// archive it named `http://<host>/v2/<path>/blobs/<digest>` likewise. A reference with no address
// in it — an image id from a genesis build that had nowhere to publish, a package version — is
// what it was.
func Recorded(reference string) string {
if strings.HasPrefix(reference, ArtifactStoreScheme) {
return reference
}
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
return ArtifactStoreScheme + path
}
return reference
}
host, path, ok := strings.Cut(reference, "/")
if !ok || !isRegistryHost(host) || !strings.Contains(path, "@sha256:") {
return reference
}
return ArtifactStoreScheme + path
}
// isRegistryHost is the runtime's own rule for reading the first component of a reference as a
// registry rather than as a namespace: it has a dot or a port in it, or it is localhost.
func isRegistryHost(component string) bool {
return component == "localhost" || strings.ContainsAny(component, ".:")
}
// InArtifactStore reports whether a reference is a kept one, and what it names there.
func InArtifactStore(reference string) (path string, kept bool) {
return strings.CutPrefix(reference, ArtifactStoreScheme)
}
// Routed is a kept reference as a machine fetches it, through the artifact store at `address`
// (host:port). A reference that is not a kept one is what it was.
func Routed(reference, address string) string {
path, kept := InArtifactStore(reference)
if !kept {
return reference
}
if strings.Contains(path, "/blobs/") {
return "http://" + address + "/v2/" + path
}
return address + "/" + path
}
+2 -25
View File
@@ -1,29 +1,6 @@
package catalogue
import (
"strings"
"testing"
)
// A build is recorded by what it is; where it is pushed is composed where it is used (novox/hq
// 04-ISSUES/102).
import "strings"
// digest is an artifact digest the tests build references with.
var digest = "sha256:" + strings.Repeat("d", 64)
func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
cases := map[string]string{
"anchor.internal:5100/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
"localhost:5000/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
"http://anchor.internal:5100/v2/gitea/config/blobs/" + digest: ArtifactStoreScheme + "gitea/config/blobs/" + digest,
ArtifactStoreScheme + "gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
digest: digest,
"@novox/sdk@1.2.3": "@novox/sdk@1.2.3",
"gitea/gitea@" + digest: "gitea/gitea@" + digest,
"https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest,
}
for announced, want := range cases {
if got := Recorded(announced); got != want {
t.Errorf("Recorded(%q) = %q, want %q", announced, got, want)
}
}
}
+5 -1
View File
@@ -17,7 +17,11 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
for _, pair := range []struct{ seat, delivers string }{
{"git", "git"},
{"npm-package-registry", "npm-package-registry"},
{"mesh-artifact-store", "artifact-store"},
// The store's seats deliver nothing since the provision artifact-store retired (novox/hq ADR 0632 §2): the
// store's address is its holder's listen, read through the seat.
{"mesh-artifact-store", ""},
{"container-image-registry", ""},
{"oci-artifact-registry", ""},
{"mesh-store", "postgres-database"},
{"mesh-broker", "mesh-bus"},
} {
+19 -3
View File
@@ -117,9 +117,9 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
// 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
// already keeps the store-relative form; the resolved manifest keeps the same, and
// composition routes it through the store a machine reaches (Routed).
// composition routes it through the store a machine reaches (the registry clients' Routed).
out.Bundles = append(out.Bundles, Bundle{
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
Name: a.Name, Source: kept(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), Bus: a.Bus,
@@ -171,7 +171,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
// how they were made — one packed as it stood, the other compiled first — and a
// machine has no reason to care which.
// Kept, not routed, for the reason the bundles above are (ADR 0155).
filled["source"] = Recorded(artifact.Reference)
filled["source"] = kept(artifact.Reference)
filled["digest"] = artifact.Digest
// **And `${version}`, so a resource can name a place that is this build's alone**
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
@@ -363,3 +363,19 @@ func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
}
return loads
}
// kept is a bundle's or an archive's reference without the artifact store's address, if the builder wrote one (ADR 0155:
// a resolved manifest names no installation). Only the address is read here: an `http://<host>/v2/<path>/blobs/<digest>`
// becomes `artifact-store://<path>/blobs/<digest>`; a kept reference, or one in a registry seat's form (whose first part,
// a seat's name, has no dot or port), is what it was. How the registry seats' forms are read is the registry clients'.
func kept(reference string) string {
if strings.HasPrefix(reference, ArtifactStoreScheme) {
return reference
}
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
return ArtifactStoreScheme + path
}
}
return reference
}
-23
View File
@@ -8,29 +8,6 @@ import "encoding/json"
// no verb. It names no event. What must be built again is no verb: a module whose fingerprint differs from its newest
// build, which the delivery view derives. The verbs' answers are CatalogModules, CatalogOne, CatalogProvides and
// CatalogDependents, whose field names are those of the catalog module's tools they replace.
const CatalogSeat = "mesh-catalog"
// catalogVerbs are the seat's four verbs, each read from its holder's states. Each is optional until the holder that
// runs serves it (design 33 §7): the module that claimed the seat before serves none of them, and a judge holding them
// required would read it as outgrown for as long as both claims stand (novox/hq ADR 0604 §6).
func catalogVerbs() []Verb {
return []Verb{
{Name: "modules", Optional: true, Description: "Every module the trunk holds, each with its repository, its " +
"directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the " +
"catalog of the mesh, read from its holder's state. Reads only.",
Input: schema(map[string]string{}, nil)},
{Name: "module", Optional: true, Description: "One module whole: its repository, directory, commit and " +
"fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.",
Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})},
{Name: "provides", Optional: true, Description: "Which modules provide a provision (a module provides its own " +
"name too) and which require it, from the module graph's provision edges. Reads only.",
Input: schema(map[string]string{"provision": "the provision, such as postgres-database"}, []string{"provision"})},
{Name: "dependents", Optional: true, Description: "The modules whose build edges reach a module, directly or " +
"through others (a base, a mesh package it publishes, the build module that builds them): what a change of it " +
"builds again, from the module graph. Reads only.",
Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})},
}
}
// CatalogEntry is one module of the catalog: its repository, its directory there ("." for the root), the merge commit it
// was last changed at, and its new-form fingerprint or why it has none.
+3 -29
View File
@@ -1,32 +1,6 @@
package catalogue
// CheckerSeat is the seat mesh-delivery-checker holds.
const CheckerSeat = "mesh-delivery-checker"
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
// everywhere.
func checkerVerbs() []Verb {
return []Verb{
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
"4 wait or run at once, mesh-wide.",
Input: schema(map[string]string{"repository": "<owner>/<name>",
"head": "the full commit to try, 40 or 64 hex digits",
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
"level": "unit or integration: only this level; both when absent"},
[]string{"repository", "head"}),
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
"it was merged onto, the time it took and what failed first. Read-only.",
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
"id. A try that ended already is refused.",
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
[]string{"id", "why"})},
}
}
//
// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshDeliveryChecker); kept for its importers until their
// next change takes it from there (testdata/seat-names.txt).
+2 -1
View File
@@ -114,6 +114,7 @@ type configDeclaration struct {
Default any `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Runtime bool `json:"runtime,omitempty"`
RenamedFrom string `json:"renamed-from,omitempty"`
}
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
@@ -125,7 +126,7 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
}
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
Default: d.Default, Required: d.Required, Runtime: d.Runtime, RenamedFrom: d.RenamedFrom})
}
// validationProblems is what is wrong with a declaration's validation, and its default against it.
+27
View File
@@ -132,6 +132,33 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
if !bytes.Equal(raw, twice) {
t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw)
}
// The registry milestone's words round-trip the same (design 32): a config value's old name, an artifact's series
// and contracts marker, the series a module accepts of a package; absent, a series is 0.1.
declared := []byte(`{"module":"c","version":"1","config":{"font":{"type":"string","renamed-from":"font-name"}},
"packages":{"mesh-sdk":"0.2"},
"build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.2",
"generated-from":"seats"},{"name":"lib","kind":"package","language":"typescript"}]}}`)
m, err = decodeForTest(declared)
if err != nil {
t.Fatal(err)
}
if m.Settings["font"].RenamedFrom != "font-name" || m.AcceptedSeries("mesh-sdk") != "0.2" ||
m.AcceptedSeries("mesh-contracts") != DefaultSeries || m.Build.Artifacts[0].SeriesOf() != "0.2" ||
m.AcceptedSeries("mesh-sdk", "0.1") != "0.2" || m.AcceptedSeries("mesh-contracts", "0.10", "x", "0.3", "1.0") != "0.3" ||
m.Build.Artifacts[0].GeneratedFrom != "seats" || m.Build.Artifacts[1].SeriesOf() != "0.1" {
t.Fatalf("read %+v", m)
}
raw, _ = json.Marshal(m)
for _, said := range []string{`"renamed-from":"font-name"`, `"packages":{"mesh-sdk":"0.2"}`, `"series":"0.2"`,
`"generated-from":"seats"`} {
if !strings.Contains(string(raw), said) {
t.Errorf("written back without %s: %s", said, raw)
}
}
if strings.Count(string(raw), `"series"`) != 1 {
t.Errorf("a series nobody said is written: %s", raw)
}
}
// A module in the settings form is written back byte for byte as before.
+1 -48
View File
@@ -1,54 +1,7 @@
package catalogue
// The two seats.
const (
WantsSeat = "mesh-wants"
HandActsSeat = "mesh-hand-acts"
)
const ()
// controlRecordsDecision is the record that made the two seats.
const controlRecordsDecision = "novox/hq ADR 0565"
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
func wantsVerbs() []Verb {
return []Verb{
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll, together, record, or default to take a person's choice back",
"why": "with policy: why, required and kept",
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
}, nil, "compare")},
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
"the step itself is the put on the operator's warrant.",
Input: schema(map[string]string{}, nil)},
}
}
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
func handActsVerbs() []Verb {
return []Verb{
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
"runtime proves, never said by the caller. Said as act-recorded.",
Input: schema(map[string]string{
"what": "what was done, in a line",
"why": "why it had to be done by hand",
"cause": "the cause in a word, or a condition's kind",
"condition": "the key of the condition it addressed, if any (optional)",
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
}, []string{"what", "why"})},
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
}
}
+7 -2
View File
@@ -238,6 +238,11 @@ type Rendering struct {
// when no holder is reachable yet; see seat_into.go for which seats are answered.
SeatReach map[string]string
// SeatHolder is the module holding each seat SeatReach answers, by seat, from the holdings the composition has (novox/hq
// design 51, issue 1966 ENG-10): written beside an image routed through the seat, for the node-engine to wait on when its
// pull fails. Absent for a seat with no holder on record.
SeatHolder map[string]string
// Built is every `<module>/<artifact>` the mesh has built. What tells a reference recorded
// with an address — before references were kept without one — from an image a module runs
// straight from a public registry.
@@ -273,8 +278,8 @@ type Rendering struct {
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.
// vault answered the node composing itself. Never the rule: the node hands by the manifest, its build and the machine,
// and reads this only to say where the vault disagrees (issue 1966). Nil where the vault said nothing.
Assigned map[string]bool
}
+53 -1
View File
@@ -1,7 +1,9 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
@@ -9,15 +11,65 @@ import (
// contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It
// judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its
// verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it
// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape.
// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, a field of the wrong shape, or one field
// given twice in one object at any depth, named by its path: a decoder keeps the last of two equal keys without a word,
// so a merge of two branches that each added the field would lose one side silently (as mesh-catalog's checks/manifests
// refuses it).
func Decode(raw []byte) (Manifest, error) {
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return Manifest{}, err
}
if twice := givenTwice(raw); twice != "" {
return Manifest{}, fmt.Errorf("the field %s is given twice; a manifest gives each field once", twice)
}
return m, nil
}
// givenTwice is the first field an object of raw gives twice, as its path (`invokes`, `build.artifacts[0].env`), or empty.
func givenTwice(raw []byte) string {
d := json.NewDecoder(bytes.NewReader(raw))
var walk func(path string) string
walk = func(path string) string {
t, err := d.Token()
if err != nil {
return ""
}
switch t {
case json.Delim('{'):
seen := map[string]bool{}
for d.More() {
k, err := d.Token()
if err != nil {
return ""
}
key, _ := k.(string)
at := key
if path != "" {
at = path + "." + key
}
if seen[key] {
return at
}
seen[key] = true
if twice := walk(at); twice != "" {
return twice
}
}
_, _ = d.Token()
case json.Delim('['):
for i := 0; d.More(); i++ {
if twice := walk(fmt.Sprintf("%s[%d]", path, i)); twice != "" {
return twice
}
}
_, _ = d.Token()
}
return ""
}
return walk("")
}
// SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first
// and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words
// (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6).
-85
View File
@@ -1,90 +1,5 @@
package catalogue
// DeliverySeat is the seat mesh-delivery holds.
const DeliverySeat = "mesh-delivery"
// RemovedDeliveryVerbs are the delivery seat's verbs release 6 removed (novox/hq ADR 0539 clause 17): with no plan the
// gate keeps the merge's states and derives nothing after the merge. Each stays in deliveryVerbs as an optional row
// with no input, so the holder of before and the holder of release 6 both hold the seat and a caller is answered by
// name with its replacement, given here.
var RemovedDeliveryVerbs = map[string]string{
"times": "the delivery view (nox-delivery): each machine's want and have, and since when",
"what-if": "the merge check's composition, which composes every machine a change touches",
"release": "the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view",
"stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
"retire-history": "nothing to retire: the gate keeps no delivery after its merge",
}
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
func deliveryRemovedInR6(name string) Verb {
return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
}
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
// on a merge (ADR 0539 clause 17).
func deliveryVerbs() []Verb {
return []Verb{
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
"by state, repository or group.",
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
"repository": "owner/repository", "group": "a group's id (its branch name)",
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
deliveryRemovedInR6("times"),
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
"steps of its walk, its group and its order.",
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
"state, derived from its members.",
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
deliveryRemovedInR6("what-if"),
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
"mesh/delivery, …) with its state, " +
"description and when it was set; the merge check's full verdict as the controller said it — each " +
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
"protection would let it merge, every required status being success.",
Input: schema(map[string]string{"repository": "owner/repository",
"number": "a pull request's number: its head is read",
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
Optional: true},
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
Input: schema(map[string]string{}, nil)},
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
"lets healer H2 take for it, or none.",
Input: schema(map[string]string{}, nil)},
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
"controller is asked for its check. With why.",
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
[]string{"id", "why"})},
deliveryRemovedInR6("release"),
deliveryRemovedInR6("stop"),
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
"past its bound. Refused for any other.",
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
[]string{"id", "why"})},
deliveryRemovedInR6("retire-history"),
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
"reason, naming why.",
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
"properties": map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
"description": "the held deliveries' ids (owner/repository@commit)"},
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
}},
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
Optional: true},
}
}
// SeatSays is whether a consumed `<seat>.<event>` names one of the seat's own events: the seat emits it,
// exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the
// seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat
@@ -2,15 +2,15 @@ package catalogue
import "testing"
// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a switch.
// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a boolean, as the engine and the checker read it.
func TestTheEngineServesComposeCandidate(t *testing.T) {
for _, v := range EngineVerbs() {
for _, v := range EngineVerbs(EngineSeat) {
if v.Name != "compose-candidate" {
continue
}
props, _ := v.Input["properties"].(map[string]any)
input, _ := props["input"].(map[string]any)
if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil {
if !v.Optional || props["candidate"] == nil || input == nil || input["type"] != "boolean" {
t.Fatalf("compose-candidate reads as %+v", v)
}
return
+10 -80
View File
@@ -1,85 +1,15 @@
package catalogue
// EngineSeat is the node seat every node's engine holds.
const EngineSeat = "node-engine"
// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them.
func EngineVerbs() []Verb {
return []Verb{
{Name: "hand-over", Description: "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, " +
"§5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, " +
"reached through no link) and refuses in words, or asks the operator on their channel at the level approve, " +
"binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands " +
"it over. One ask is open per path; acts shows where it stands.",
Input: schema(map[string]string{"path": "the directory's absolute path, as the engine states it",
"why": "why it is asked, in words the operator reads"}, []string{"path"})},
{Name: "access-act", Description: "Ask the operator for the take act on a library whose root holds data outside its " +
"group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its " +
"root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes " +
"sets the library's group, group write and setgid under its root. One ask is open per access.",
Input: schema(map[string]string{"act": "the act: take", "access": "the access, library:<name>",
"why": "why it is asked, in words the operator reads"}, []string{"act", "access"})},
{Name: "judge-directories", Description: "Which of this machine's declared directories the engine would refuse to " +
"make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.",
Input: schema(map[string]string{}, nil)},
{Name: "setuid-search", Description: "Throw away the engine's last search for setuid programs and start a full one " +
"(novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while " +
"one runs, and when one was started within the hour, naming when the next may start.",
Input: schema(map[string]string{"why": "why it is asked, in words"}, nil)},
{Name: "root-free", Description: "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only " +
"on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root " +
"without a person, and the login shell's execute not served here. Anything else is no pass, and says why. " +
"Answered by this machine's engine alone.",
Input: schema(map[string]string{}, nil)},
{Name: "acts", Description: "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): " +
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
"operator's warrant. Read-only.",
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
// The packages this node's compositions declare (novox/hq ADR 0565 §6, §15): optional until every engine serves it.
{Name: "packages", Optional: true, Description: "The packages this machine's compositions declare, present or absent, " +
"each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller " +
"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)},
// A composition that does not exist yet (novox/hq ADR 0593 §7). Optional until every engine serves it: a node
// whose engine does not is a node that does not answer (§9).
{Name: "compose-candidate", Optional: true, Description: "Compose a check's candidate on this machine in shadow, " +
"with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it " +
"composes (with its digest and whether the candidate changes it), is refused by the candidate or is held " +
"without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: " +
"applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.",
Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, <group>.<heads hash>",
"input": "\"true\": also answer the composition input the candidate was composed from, for the sandbox path"},
[]string{"candidate"}, "input")},
}
// EngineVerbs are the node-engine seat's verbs as the seat list the host gave has them (ADR 0563 §1; design 26), each
// with its description and its input's schema as discovery shows them. The seat's name is its contract's
// (mesh-contracts' nodeengine), never this package's (ADR 0634 §4, ADR 0635 §4): the caller names it.
func EngineVerbs(seat string) []Verb {
s, _ := SeatNamed(seat)
return append([]Verb(nil), s.Serves...)
}
// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime
// proved it and the ask (ADR 0563 §1).
var EngineEvents = []string{"asked", "done", "refused"}
// engineSeat is the seat's row in the closed set.
func engineSeat() Seat {
return Seat{Name: EngineSeat, Scope: ScopeNode, Serves: EngineVerbs(), Emits: append([]string(nil), EngineEvents...),
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
// EngineEvents are what the node-engine seat says, as the seat list the host gave has them.
func EngineEvents(seat string) []string {
s, _ := SeatNamed(seat)
return append([]string(nil), s.Emits...)
}
-60
View File
@@ -1,7 +1,6 @@
package catalogue
// EnrolmentSeat is the seat mesh-enrolment.
const EnrolmentSeat = "mesh-enrolment"
// The seat's six verbs (ADR 0587 §1, §4-§8).
const (
@@ -29,62 +28,3 @@ var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, Enrolmen
// enrolmentDecision is the record that made the seat.
const enrolmentDecision = "novox/hq ADR 0587"
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
// the seat is new with its holder, so no holder of before serves fewer.
func enrolmentVerbs() []Verb {
str := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
return []Verb{
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"The token itself is read only through tokens.",
Input: schema(map[string]string{
"node": "the new machine's name in the mesh",
"why": "why it joins, shown to the operator with the ask",
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
// enters placement with it and the hub peers it before redeem.
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
}, []string{"node", "why", "overlay_key"})},
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
"it finishes what is not done and answers the same; with other keys it is refused.",
Input: map[string]any{"type": "object", "additionalProperties": false,
"required": []string{"node", "public_key", "sealing_key", "proof"},
"properties": map[string]any{
"node": str("the node the token was issued for"),
"public_key": str("the identity key's public half, base64"),
"overlay_key": str("the private network key's public half"),
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
"serving_key": str("the serving key's public half, which the certificate binds"),
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
}}},
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
"enrolment.",
Input: schema(map[string]string{}, nil)},
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
"it, records the act as a hand act and says enrolment-revoked.",
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
[]string{"node", "why"})},
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
"the bucket's fill against its cap. For a node at live, its " +
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
nil, "history")},
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
"and nothing more.",
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
}
}
-74
View File
@@ -1,9 +1,5 @@
package catalogue
import (
"strings"
)
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
const FilesSeat = "node-files"
@@ -16,73 +12,3 @@ const (
"secret is kept, or a known credential file, is refused by name"
overrideWord = "a reason, to change a path a module owns anyway (optional)"
)
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
// with their descriptions restated for the operator's account, and the writes.
func filesVerbs() []Verb {
var verbs []Verb
for _, v := range readsVerbs() {
v.Description = strings.Replace(v.Description,
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
"", 1)
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
verbs = append(verbs, v)
}
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) Verb {
in["reason"] = "why the file is changed, for the audit trail"
in["override"] = overrideWord
return Verb{Name: name, Description: description + " Refuses a path a module owns " +
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
}
return append(verbs,
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
map[string]string{"path": "an absolute path", "content": "the text to write",
"mode": "the file's mode, such as 0644 (optional)"},
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
"kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path"},
[]string{"from", "to"}, nil, "mv"),
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
write("mkdir", "Make a directory.",
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
write("chmod", "Change the mode of a file or directory.",
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
"recursive": "\"true\": also everything under a directory"},
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
write("chown", "Change the owner and group of a file or directory.",
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"),
Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " +
"and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " +
"At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " +
"before anything is sent. Read " + filesAsAccount + ".",
Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into",
"key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})},
// download is a plain Verb, not through write(): write() types its optional arguments as true/false
// switches, which a hash and a reason are not (issue 1719).
Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " +
"never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " +
"the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " +
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path",
"sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)",
"reason": "why the file is changed, for the audit trail",
"override": overrideWord}, []string{"bucket", "key", "path", "reason"})},
Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
"original path and when it went.",
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}},
write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
)
}
+8 -139
View File
@@ -23,117 +23,6 @@ const (
SecretServiceSeat = "node-secret-service"
)
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
// ADR 0255), the compositor and the secret
// service are roles a second holder competes for, and nothing has needed to ask them anything.
func graphicalSessionSeats() []Seat {
const decided = "novox/hq ADR 0208"
return []Seat{
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
"one the operator account starts by default.",
Input: schema(map[string]string{}, nil)},
}},
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
"where it is placed; and the layout profile in force, if one matches.",
Input: schema(map[string]string{}, nil)},
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
"identities: list them, save the current arrangement under a name, or apply one.",
Input: withEnum(schema(map[string]string{
"action": "list, save or apply",
"name": "the profile to save or apply (save and apply only)",
}, []string{"action"}), "action", "list", "save", "apply")},
}},
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
// bindings, start-up commands, rules — placed by the session's holder, never written into
// its directory by the contributor.
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided,
Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []Verb{
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
Input: schema(map[string]string{}, nil)},
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
"it is visible or focused.",
Input: schema(map[string]string{}, nil)},
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
"whether it has focus; narrowed to one workspace when named.",
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
}},
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
"or the login shell, in a directory or the account's home.",
Input: schema(map[string]string{
"command": "what to run in it (optional; the login shell when absent)",
"directory": "where it starts (optional; the account's home when absent)",
}, nil)},
}},
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
Input: map[string]any{"type": "object", "required": []string{"choices"},
"properties": map[string]any{
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the lines to choose between, in order"},
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
}}},
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
Input: schema(map[string]string{
"module": "the module whose own secret is asked for",
"secret": "the own secret's name",
"node": "the machine the module runs on",
"seal_to": "the asker's public sealing key: the answer is sealed to it",
"timeout_seconds": "give up after this long (optional)",
}, []string{"module", "secret", "node", "seal_to"})},
}},
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "send", Description: "Show the operator a notification.",
Input: withEnum(schema(map[string]string{
"title": "the notification's summary",
"body": "its text (optional)",
"urgency": "low, normal (the default) or critical",
}, []string{"title"}), "urgency", "low", "normal", "critical")},
{Name: "history", Description: "The notifications shown lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
}},
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "lock", Description: "Lock the operator's session now.",
Input: schema(map[string]string{}, nil)},
}},
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "history", Description: "What the clipboard held lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
{Name: "copy", Description: "Put text on the operator's clipboard.",
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
}},
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
// the holder renders in its own grammar. A module that knows something about the machine — the
// power module its battery — gives the block; the bar knows no machine's hardware.
{Name: BarSeat, Scope: ScopeNode, Decision: decided,
Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
}
}
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
// the choices by the schema rather than by a refusal.
func withEnum(s map[string]any, property string, values ...string) map[string]any {
props := s["properties"].(map[string]any)
p := props[property].(map[string]any)
p["enum"] = values
return s
}
// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255).
const BarKindBlock = "block"
@@ -171,34 +60,6 @@ type barOption struct {
required bool
}
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
// what it shows, and that kind's options.
//
// - bar: top or bottom;
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
// clock) — the two places every holder keeps for contributed blocks;
// - order: 0–99 within the place, 50 when absent;
// - shows: battery or command; options: what that kind takes.
func barBlockShape() *Shape {
return &Shape{
Text: true,
Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
{Field: "place", Values: []string{"resources", "status"}}},
Order: "order",
Check: barBlockProblems,
Reads: barBlockReads,
Examples: []map[string]any{
{"bar": "bottom", "place": "status", "shows": "battery"},
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
{"bar": "bottom", "place": "status", "shows": "command",
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
{"bar": "bottom", "place": "status", "shows": "state",
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
},
}
}
func barBlockProblems(data map[string]any) []string {
var problems []string
for field := range data {
@@ -245,3 +106,11 @@ func barBlockProblems(data map[string]any) []string {
}
return problems
}
// withShapeCode gives a data-shaped kind of the generated seat list the checks its definition cannot carry, being code:
// the bar's block (novox/hq ADR 0255, ADR 0260).
func withShapeCode(seat, kind string, sh *Shape) {
if seat == BarSeat && kind == BarKindBlock {
sh.Check, sh.Reads = barBlockProblems, barBlockReads
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
t.Errorf("layout's actions are %v", action["enum"])
}
// And they survive the store's JSON, which is where the live set comes from.
if _, err := json.Marshal(graphicalSessionSeats()); err != nil {
if _, err := json.Marshal(DefaultSeats()); err != nil {
t.Fatal(err)
}
}
+5 -3
View File
@@ -211,7 +211,7 @@ func handedBy(m Manifest, archive Built) *Handed {
return nil
}
dir := m.TemplatesDir()
h := &Handed{Source: Recorded(archive.Reference), Digest: archive.Digest, Templates: map[string]string{}}
h := &Handed{Source: kept(archive.Reference), Digest: archive.Digest, Templates: map[string]string{}}
for _, r := range m.Resources {
name, has, wrong := templateOf(m.Module, dir, r)
if !has || wrong != "" {
@@ -233,8 +233,10 @@ func handedBy(m Manifest, archive Built) *Handed {
// 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).
// plan. A value that does not read is an error, never "not handed". What the vault judges `own` by: the plan is what the
// node's last composition made, so it follows that node's rule (the manifest, its build and the machine) one
// composition later. Never the rule a node composing itself hands by: with no plan yet it would never make one (issue
// 1966); the node reads it only to say where the two disagree (Rendering.Assigned).
func AssignmentHands(raw []byte) (bool, error) {
var a struct {
Provisioning *Plan `json:"provisioning"`
+1 -102
View File
@@ -1,108 +1,7 @@
package catalogue
// The four health seats.
const (
ConditionsSeat = "mesh-conditions"
WatchdogsSeat = "mesh-watchdogs"
SelfCheckSeat = "mesh-self-check"
HealersSeat = "mesh-healers"
)
const ()
// healthDecision is the record that made the four seats.
const healthDecision = "novox/hq ADR 0564"
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
func conditionsVerbs() []Verb {
return []Verb{
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
"lately.",
Input: schema(map[string]string{
"key": "a condition's key: that one whole; with history, only its transitions",
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
"severity": "only urgent, or only warning",
"machine": "only those about this machine",
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
"days": "with history: how many days back (default 7, at most 90)",
}, nil, "history")},
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
"status, and the silence is kept as its transition in history.",
Input: schema(map[string]string{
"key": "the condition's key",
"for": "how long: 30m, 4h, 2d; at most 7d",
"why": "why, required and recorded",
"cause": "the cause in a word (the condition's kind when absent)",
}, []string{"key", "for", "why"})},
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
"conditions: the recovery of a keeper that missed a change.",
Input: schema(map[string]string{}, nil)},
}
}
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
func watchdogsVerbs() []Verb {
return []Verb{
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
"expiry wrote.",
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
"when and the words of its finding.",
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
Input: schema(map[string]string{"machine": "one machine"}, nil)},
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
"whole. With deliver or drop, a hand act with why.",
Input: schema(map[string]string{
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
"limit": "how many to list, newest first (default 50); only when listing",
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
"drop": "a dead letter's id: drop it for good (needs why)",
"why": "with deliver or drop: why, required and recorded",
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
}, nil)},
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
Input: schema(map[string]string{}, nil)},
}
}
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
func selfCheckVerbs() []Verb {
return []Verb{
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
Input: schema(map[string]string{
"run": "\"true\": run every probe now and answer the verdict",
"probe": "one probe's id (D6): its verdict alone, with every finding",
}, nil, "run")},
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
Input: schema(map[string]string{}, nil)},
}
}
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
func healersVerbs() []Verb {
return []Verb{
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
Input: schema(map[string]string{
"key": "the condition's key",
"why": "why now, kept with the act",
}, []string{"key", "why"})},
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
Input: schema(map[string]string{
"what": "what was done on purpose, in a line",
"why": "what the drill tests",
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
}, []string{"what", "why"})},
}
}
+14 -15
View File
@@ -15,12 +15,18 @@ func busSeatDelivering(t *testing.T, delivers string) {
t.Helper()
was := Seats()
t.Cleanup(func() { UseSeats(was) })
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}})
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test", Serves: verbsNamed(brokerVerbs())}})
}
// brokerVerbs are the bus seat's verbs as a literal: mesh-sdk imports no seat's contract module, a test neither
// (ADR 0625 §3).
func brokerVerbs() []string {
return []string{"list", "show", "check", "orphans", "create", "apply", "recreate", "drop"}
}
func newBroker() Manifest {
return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: brokerVerbs()}}}
}
// CanHold is the one judgement registration and the handover share, against the store's row.
@@ -41,7 +47,7 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
t.Fatalf("a claim at the wrong scope was allowed: %v", err)
}
cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: brokerVerbs()}}}
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
}
@@ -74,17 +80,10 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
}
}
// The store's seat rows have no protocol columns yet; loading them must not drop the protocol the
// bus is derived from, or no role's work queue is ever raised (found live, 2026-09-28).
func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
was := Seats()
t.Cleanup(func() { UseSeats(was) })
UseSeats([]Seat{{Name: "node-build-go", Scope: ScopeNode, Decision: "row"}})
got, ok := SeatNamed("node-build-go")
if !ok || len(got.Accepts) == 0 || !got.PulledByKind || !got.Yields {
t.Fatalf("the Go build seat lost what it accepts when loaded from the store: %+v", got)
}
if got.Decision != "row" {
t.Fatalf("the store's own columns were not kept: %+v", got)
func verbsNamed(names []string) []Verb {
var out []Verb
for _, n := range names {
out = append(out, Verb{Name: n})
}
return out
}
-189
View File
@@ -1,189 +0,0 @@
package catalogue
// This file keeps, deprecated, the exported names feat/l3b-bus removed from this package while a running part still
// uses them: an exported name is removed only after every user of it is delivered. The checker builds the running
// node-engine's composer (mesh-host main) beside this mesh-sdk (ADR 0548 §6), and mesh-host main names them
// (RuntimeModule, CoreCredentialModules, FromBus). Nothing in the final L3 release's heads uses any of them, but that
// release still builds beside mesh-host 798d73e, so it keeps this file. The next release that changes mesh-sdk, once
// the final L3 release's node-engine runs on every node, removes this file and its exemption in
// TestNoCatalogueManifestNamesAmqp.
import (
"fmt"
"slices"
"sort"
"strings"
)
// Deprecated: kept until its users are delivered (the head of this file).
//
// EndedControllerName is the name the ended controller had, as its module and as its seat: no manifest may name it.
const EndedControllerName = "mesh-controller"
// The kinds of a declaration that names the controller.
//
// Deprecated: kept until their users are delivered (the head of this file).
const (
DependsRead = "read"
DependsInvoke = "invoke"
DependsConsume = "consume"
DependsUses = "uses"
DependsWaits = "waits"
DependsClaims = "claims"
DependsRequires = "requires"
DependsIs = "is"
)
// Deprecated: kept until its users are delivered (the head of this file).
//
// Dependency is one declaration of a manifest that names the controller.
type Dependency struct {
Module string `json:"module"`
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
By string `json:"by,omitempty"`
Kind string `json:"kind"`
What string `json:"what"`
}
// onController says whether a name `<owner>.<rest>` is the controller's.
func onController(name string) bool { return strings.HasPrefix(name, EndedControllerName+".") }
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
// it is the console's grant to every tool.
func invokesController(invoke string) bool {
if seat, _, ok := SeatInvoke(invoke); ok {
return seat == EndedControllerName
}
return onController(invoke)
}
// Deprecated: kept until its users are delivered (the head of this file).
//
// Dependencies are every declaration naming the controller in the manifests given, by module.
func Dependencies(manifests map[string]Manifest) []Dependency {
var out []Dependency
add := func(module, by, kind, what string) {
if d := (Dependency{Module: module, By: by, Kind: kind, What: what}); !slices.Contains(out, d) {
out = append(out, d)
}
}
for name, m := range manifests {
if name == EndedControllerName || m.Module == EndedControllerName {
add(name, "", DependsIs, EndedControllerName)
}
for _, r := range m.Reads {
if onController(r) {
add(name, "", DependsRead, r)
}
}
for _, i := range m.Invokes {
if invokesController(i) {
add(name, "", DependsInvoke, i)
}
}
for _, c := range m.Consumes {
if onController(c) {
add(name, "", DependsConsume, c)
}
}
for _, w := range m.Waits {
if onController(w) {
add(name, "", DependsWaits, w)
}
}
for _, u := range m.Uses {
if u == EndedControllerName {
add(name, "", DependsUses, u)
}
}
for _, c := range m.Claims {
if c.Name == EndedControllerName {
add(name, "", DependsClaims, c.Name)
}
}
for _, r := range m.Requires {
if r == EndedControllerName || onController(r) {
add(name, "", DependsRequires, r)
}
}
for _, a := range m.BusAccounts() {
by := "process " + a.ID
if a.Tools {
by = "tools " + a.ID
}
for _, i := range a.Bus.Invokes {
if invokesController(i) {
add(name, by, DependsInvoke, i)
}
}
}
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Module != b.Module {
return a.Module < b.Module
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
if a.What != b.What {
return a.What < b.What
}
return a.By < b.By
})
return out
}
// Deprecated: kept until its users are delivered (the head of this file).
//
// ManifestRefusals is the check over a repository's manifests, by module name: one reason per declaration naming the
// controller, none when it passes. None given fails it, since nothing read can hide a declaration.
func ManifestRefusals(manifests map[string]Manifest) []string {
if len(manifests) == 0 {
return []string{"no manifest was read: with nothing read, nothing naming the controller can be judged absent"}
}
var out []string
for _, d := range Dependencies(manifests) {
who := d.Module
if d.By != "" {
who += " (" + d.By + ")"
}
out = append(out, fmt.Sprintf("%s declares %s %s: the controller ended by its unassignment and nothing may name it "+
"(ADR 0590 clause 5)", who, d.Kind, d.What))
}
return out
}
// Deprecated: a manifest says it (Manifest.CoreCredentials, CarriesCoreCredentials); kept until its users are
// delivered (the head of this file).
//
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
// its client.
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
// Deprecated: a manifest says it (Manifest.ToolRunner, IsToolRunner); kept until its users are delivered (the head
// of this file).
//
// RuntimeModule is the module that is the node's tool runtime.
const RuntimeModule = "node-tools"
// Deprecated: the kept streams are the indexer's judge's; kept until its users are delivered (the head of this file).
//
// StreamsKeptUntilMoved are the streams declared before the check that a module keeps its data in the bus's
// predefined forms, each allowed by name until its module moves it.
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"},
}
// Deprecated: nothing derives it now (DerivedUpgrade reads the bus's own manifest); kept until its users are
// delivered (the head of this file).
//
// FromBus is the source an older DerivedUpgrade gave the bus's policy.
const FromBus = "the bus"
-7
View File
@@ -31,11 +31,4 @@ func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) {
}
}
t.Run("compiled", check)
t.Run("read back from the store", func(t *testing.T) {
before := seats
t.Cleanup(func() { seats = before })
UseSeats([]Seat{{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
Serves: []Verb{{Name: "execute"}}}})
check(t)
})
}
+67 -6
View File
@@ -469,6 +469,11 @@ type Manifest struct {
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
Provides []Offer `json:"provides,omitempty"`
// Packages are the series of each mesh package this module accepts, by the package's name (novox/hq ADR 0606,
// design 32): its fingerprint takes the newest version of that series, and a publish of another series moves it
// not. A package it uses and does not name is accepted in DefaultSeries (AcceptedSeries).
Packages map[string]string `json:"packages,omitempty"`
// Requires are names that must be provided by something assigned to the same node.
Requires []string `json:"requires,omitempty"`
// OptionalRequires is which of Requires the module can run without (novox/hq ADR 0330): filled
@@ -1224,6 +1229,64 @@ type Artifact struct {
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
Env map[string]string `json:"env,omitempty"`
// Series is the series this artifact's versions are numbered in, `<major>.<minor>` (novox/hq ADR 0606, design 32):
// the builder numbers `<series>.<n>`, so a change of series is a change a user accepts by its own `packages`.
// Said on a package, an image, a bundle or an archive; the judge refuses it on any other kind. Absent is
// DefaultSeries (SeriesOf).
Series string `json:"series,omitempty"`
// GeneratedFrom marks a package of a contracts module (novox/hq ADR 0608 §5, design 26 *Contracts modules*): the
// directory, relative to the module's, of the seat definitions every file of the package is generated from. A
// module carrying it holds contracts only and claims no seat, which the judge refuses naming the seat; the build
// modules' test step regenerates the package and fails on a file out of step.
GeneratedFrom string `json:"generated-from,omitempty"`
}
// AcceptedSeries is the series of the package of that name this module accepts: the one Packages names, or else the
// series of the package's first numbered version (design 32), the lowest of numbered, the series the package has a
// numbered version in (the indexer's to say; a series is only ever raised); DefaultSeries while none is numbered.
func (m Manifest) AcceptedSeries(name string, numbered ...string) string {
if s := m.Packages[name]; s != "" {
return s
}
first := ""
for _, s := range numbered {
if seriesOrder(s) != nil && (first == "" || compareSeries(s, first) < 0) {
first = s
}
}
if first == "" {
return DefaultSeries
}
return first
}
// seriesOrder is a series' major and minor, or nil when it is no `<major>.<minor>`.
func seriesOrder(s string) []int {
major, minor, ok := strings.Cut(s, ".")
a, err1 := strconv.Atoi(major)
b, err2 := strconv.Atoi(minor)
if !ok || err1 != nil || err2 != nil || a < 0 || b < 0 {
return nil
}
return []int{a, b}
}
// compareSeries orders two well-formed series by major, then minor.
func compareSeries(a, b string) int {
return slices.Compare(seriesOrder(a), seriesOrder(b))
}
// DefaultSeries is the series of an artifact or an accepted package that names none (design 32).
const DefaultSeries = "0.1"
// SeriesOf is the series the artifact's versions are numbered in: its own, or DefaultSeries.
func (a Artifact) SeriesOf() string {
if a.Series == "" {
return DefaultSeries
}
return a.Series
}
// Kinds an artifact may be.
@@ -1268,12 +1331,10 @@ const (
ArtifactPackage = "package"
)
// ArtifactStoreProvision is the name a module offers when it is the mesh's store for what modules
// ship — images, and archives, which are directories from a repository packed as blobs.
//
// Named here because a rule depends on it: what provides this cannot be delivered through it
// (novox/hq 04-ISSUES/029). A string compared in one place is a convention; a string a rule turns
// on is a fact, and it should be written once.
// ArtifactStoreProvision is the name the mesh's store for what modules ship (images, and archives,
// which are directories from a repository packed as blobs) was offered under until the provision
// retired (novox/hq ADR 0632 §2): no seat delivers it and no module provides it. It stays as the
// scheme of the older reference form, artifact-store://, which done keys keep (ArtifactStoreScheme).
const ArtifactStoreProvision = "artifact-store"
// Listening is one endpoint a module serves: a port it accepts connections on, and what may be said
+84 -6
View File
@@ -5,6 +5,7 @@ import (
_ "embed"
"encoding/json"
"reflect"
"regexp"
"slices"
"sort"
"strings"
@@ -37,6 +38,47 @@ const (
var referenceForms = []string{"access", "backup", "bound", "collected", "config", "consumer", "consumers",
"contribution", "dir", "machine", "port", "seat", "secret", "setting"}
// ReferenceFormSeat names the reference form a registry seat answers for a build's artifact,
// `<seat>/<module>/<artifact>@sha256:<digest>` (novox/hq ADR 0605 §9; the registry clients' reference forms), as the reader capability
// `form:seat-reference` a node-engine built with this contract states (ADR 0618 §1, ADR 0624 §1): the builder writes it
// into a build key only for the nodes whose engines state it. The older reference forms a node-engine read before any
// stated what it reads are the baseline's; they are the builder's to name (delivery's artifact forms).
const ReferenceFormSeat = "seat-reference"
// artifactReferenceForms are the reference forms of a build's artifacts this contract reads beyond the baseline's.
var artifactReferenceForms = []string{ReferenceFormSeat}
// ReferenceFormSeatReach names how a node-engine built with this contract fills `${seat:<seat>:reach}`, as the reader
// capability `form:seat-reach` (novox/hq ADR 0632 §1): from the seat holder placement names and the listen its manifest
// opens to the mesh, the store's three seats among them, never from a module serving the retired provision
// artifact-store. An engine before it filled the store's reach only from that provision, so a manifest naming a seat's
// reach, and the store holder's own manifest, need it and are held from a node whose engine does not state it
// (ManifestNeeds).
const ReferenceFormSeatReach = "seat-reach"
// storeHolderSeat is the seat whose holder's own manifest needs form:seat-reach (novox/hq ADR 0632 §2): the one seat
// name this contract reads a rule by, and the one seat name of the package-list test's exceptions (S-8).
const storeHolderSeat = "container-image-registry"
// claimsAtMesh says whether a manifest claims seat at the mesh's scope (a claim with no scope is the seat's own). The
// store holder's own manifest, a claim of storeHolderSeat, needs form:seat-reach this way (ADR 0632 §2): read
// on every node, it leaves an engine before the reading without the old way to find the store, so it is held from every
// node whose engine does not state it.
func claimsAtMesh(top map[string]any, seat string) bool {
for _, c := range asList(top["claims"]) {
obj, _ := c.(map[string]any)
name, _ := obj["name"].(string)
scope, _ := obj["scope"].(string)
if hs, ok := SeatNamed(name); ok && hs.Name == seat && (scope == "" || scope == ScopeMesh) {
return true
}
}
return false
}
// ofSeatReach is a manifest's value naming a seat's reach, `${seat:<seat>:reach}`.
var ofSeatReach = regexp.MustCompile(`\$\{seat:[a-z0-9][a-z0-9-]*:reach\}`)
// manifestTopWords are the top-level keys Manifest.UnmarshalJSON reads apart from the fields of manifestFields: each
// read whole, its inside a map of the module's own names (requirements, local names) and values, no words of the
// contract's beneath it. `requires` and `config` are read as their fields are (manifestNeeds).
@@ -60,7 +102,11 @@ func ManifestReads() []string {
for _, f := range referenceForms {
set[ReadForm+f] = true
}
for _, s := range DefaultSeats() {
for _, f := range artifactReferenceForms {
set[ReadForm+f] = true
}
set[ReadForm+ReferenceFormSeatReach] = true
for _, s := range Seats() {
set[ReadSeat+s.Name] = true
}
return sortedSet(set)
@@ -121,10 +167,9 @@ func ManifestNeeds(raw []byte) ([]string, error) {
return nil, err
}
used := map[string]bool{}
for _, f := range referenceForms {
if bytes.Contains(raw, []byte("${"+f+":")) {
used[ReadForm+f] = true
}
textNeeds(raw, used)
if claimsAtMesh(top, storeHolderSeat) {
used[ReadForm+ReferenceFormSeatReach] = true
}
if _, has := top[ConfigField]; has || takesConfigKey.Match(raw) {
used[ReadForm+"config"] = true
@@ -175,7 +220,7 @@ func ManifestNeeds(raw []byte) ([]string, error) {
// and a bucket's share with a seat.
func seatNamesUsed(top map[string]any) []string {
known := map[string]bool{}
for _, s := range DefaultSeats() {
for _, s := range Seats() {
known[s.Name] = true
}
set := map[string]bool{}
@@ -384,6 +429,39 @@ func asList(v any) []any {
return list
}
// textNeeds adds the reference forms a manifest's or a template's text writes to used: a form the engine fills in a
// file's text is read there whether the text is inline or in a template file.
func textNeeds(text []byte, used map[string]bool) {
for _, f := range referenceForms {
if bytes.Contains(text, []byte("${"+f+":")) {
used[ReadForm+f] = true
}
}
if ofSeatReach.Match(text) {
used[ReadForm+ReferenceFormSeatReach] = true
}
}
// ModuleNeeds is ManifestNeeds of a module whose files take their text from templates (template.go): what the manifest
// needs and what each template's text writes, by template path (TemplatePaths), sorted. A module's needs are both: an
// engine renders the template's text as it renders inline content, so a template naming ${seat:x:reach} needs the
// reading as the same content inline does, though the manifest itself is unchanged (novox/hq ADR 0632 §2). A template
// named and not given is no need; the reader of the files says it could not read one.
func ModuleNeeds(raw []byte, templates map[string]string) ([]string, error) {
needs, err := ManifestNeeds(raw)
if err != nil || len(templates) == 0 {
return needs, err
}
used := map[string]bool{}
for _, n := range needs {
used[n] = true
}
for _, text := range templates {
textNeeds([]byte(text), used)
}
return sortedSet(used), nil
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
-30
View File
@@ -6,33 +6,3 @@ const ResolverSeat = "node-resolver"
// ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where
// one is held.
const ResolverFile = "/etc/resolv.conf"
// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed
// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to
// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the
// operator's way to read and correct the same.
func resolverVerbs() []Verb {
return []Verb{
{Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " +
"which answer every name not routed elsewhere, and each link given servers of its own with the " +
"domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " +
"wrote it as far as the file says, whether a module took it, and when the resolver's own file was " +
"put back.",
Input: schema(map[string]string{}, nil),
Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}},
{Name: "route", Description: "Send these domains, and every name under them, to these servers over " +
"this link — and only them: the link is never the machine's default route for names, and the " +
"mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " +
"takes its route with it.",
Input: schema(map[string]string{
"link": "the network link the servers are reached over, by name (a VPN's tunnel interface)",
"domains": "the domains to route there, separated by spaces or commas",
"servers": "the servers' addresses, separated by spaces or commas",
}, []string{"link", "domains", "servers"}),
Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}},
{Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " +
"resolvers. Nothing changes when the link has none.",
Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}),
Replaces: []string{"resolvectl revert"}},
}
}
-23
View File
@@ -139,26 +139,3 @@ func TestResetFailedWantedByAndUnlinkDanglingAreRequiredVerbsOfTheServiceManager
t.Fatalf("read back from the row, a holder serving all twelve is refused: %v", err)
}
}
// The optional mark is not stored, so a seat set read back from the store's rows takes it from the
// compiled seat: otherwise `failed`, seeded into the row, would come back required.
func TestAnOptionalVerbStaysOptionalInASetReadFromTheStore(t *testing.T) {
defer UseSeats(DefaultSeats())
var rows []Seat
for _, s := range DefaultSeats() {
row := s
row.Serves = nil
for _, v := range s.Serves {
row.Serves = append(row.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input})
}
rows = append(rows, row)
}
UseSeats(rows)
seat, _ := SeatNamed(ServiceManagerSeat)
holder := Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode,
Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal",
"reset-failed", "wanted-by", "unlink-dangling"}}}}
if err := CanHold(holder, seat); err != nil {
t.Fatalf("a set read from rows refuses a holder without the optional failed: %v", err)
}
}
+14 -3
View File
@@ -64,21 +64,32 @@ func TestARequirementsLongForm(t *testing.T) {
names string
busState []BusStateEntry
aside []string
refused string
}{
// 1966/AC1: a plain name and a bus-state entry: Requires names the plain one only, since it names provisions,
// and BusState holds the entry whole.
{"a plain name and a bus-state entry", `["postgres-database",
{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}}]`,
"postgres-database", []BusStateEntry{records}, nil},
"postgres-database", []BusStateEntry{records}, nil, ""},
// 1966/AC2: an unknown key is set aside and named, never refused; the entry decodes.
{"a bus-state entry with a key it does not know", `[
{"name":"records","storage":"ledger","template":"ledger","colour":"red","options":{"max-mib":1024}}]`,
"", []BusStateEntry{records}, []string{"requires[0].colour"}},
"", []BusStateEntry{records}, []string{"requires[0].colour"}, ""},
// 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged.
{"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024},
"types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil},
"types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""},
// A field given twice at any depth is refused, named by its path: a decoder would keep the last without a word.
{"a field given twice", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"},
{"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`,
"", nil, nil, "the field requires[0].name is given twice"},
} {
got, err := decodeForTest([]byte(`{"module":"x","version":"1","requires":` + c.requires + `}`))
if c.refused != "" {
if err == nil || !strings.Contains(err.Error(), c.refused) {
t.Errorf("%s: read as %+v, %v; want refused: %s", c.name, got, err, c.refused)
}
continue
}
if err != nil {
t.Errorf("%s: refused: %v", c.name, err)
continue
+44 -9
View File
@@ -1,15 +1,15 @@
package catalogue
import (
"maps"
"os"
"slices"
"testing"
)
// mesh-sdk's own manifest is judged by the controller that runs when it is checked, and that controller reads it
// strictly: a manifest word ships one release ahead of its first user. R4e's group is judged by today's controller
// (novox/hq ADR 0503 §4), which does not know `paths` (ADR 0499), so mesh-sdk's lib names no paths until a release
// after R4e's delivery adds them back (ADR 0499 §6, issue 1758).
// mesh-sdk's own manifest declares its two packages (design 32, SDK-10): the TypeScript `lib` and the Go `go`, built
// from the directory go, each in series 0.1, so the Go build module publishes mesh-sdk's Go package through the Go seat
// (design 51). Its words are judged by the group's own indexer, delivered with them (ADR 0621 §4).
func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
@@ -22,11 +22,19 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
if m.Build == nil || len(m.Build.Artifacts) == 0 {
t.Fatal("mesh-sdk's module.json declares no artifact")
}
packages := map[string]Artifact{}
for _, a := range m.Build.Artifacts {
if a.Paths != nil {
t.Errorf("artifact %q names paths %v: the running controller refuses the word until R4e is delivered", a.Name, a.Paths)
packages[a.Language] = a
if a.Kind != ArtifactPackage || a.Series != DefaultSeries {
t.Errorf("artifact %q is a %s in series %q, want a package in %s", a.Name, a.Kind, a.Series, DefaultSeries)
}
}
if g := packages["go"]; g.Name != "go" || !slices.Equal(g.Paths, []string{"go"}) {
t.Errorf("mesh-sdk's Go package is %+v, want go built from go", g)
}
if l := packages["typescript"]; l.Name != "lib" || l.Paths != nil {
t.Errorf("mesh-sdk's TypeScript package is %+v", l)
}
// What a reader reads (novox/hq ADR 0614 §4): the baseline is the contract of mesh-sdk f02f142, which every running
// node-engine was built with, and this contract reads all of it; the words added since are the only ones it lacks.
@@ -37,12 +45,17 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
added = append(added, r)
}
}
// The two seats the milestone renamed (ADR 0634 §9) are read by their new names; a node-engine built with the baseline
// reads the old ones, and a manifest of the new names is the milestone's to deliver after the engines.
renamed := []string{"seat:mesh-enrolment", "seat:mesh-placement"}
for _, b := range baseline {
if !slices.Contains(reads, b) {
if !slices.Contains(reads, b) && !slices.Contains(renamed, b) {
t.Errorf("the baseline's %s is no reader capability of this contract", b)
}
}
for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage"} {
for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage",
// The reference form a registry seat answers (ADR 0605 §9), and the seats the registry milestone adds.
ReadForm + ReferenceFormSeat, ReadForm + ReferenceFormSeatReach, "seat:go-package-registry", "seat:container-image-registry", "seat:oci-artifact-registry"} {
if !slices.Contains(added, want) {
t.Errorf("%s is in the baseline, or not read: added since it are %v", want, added)
}
@@ -70,12 +83,34 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
"word:provides.identity.max", "word:resources"}, nil},
{"a bus-state entry of requires", `{"module":"b","version":"1","requires":["mesh-bus",{"name":"jobs","storage":"kv"}]}`,
[]string{"word:requires", "word:bus-state.storage"}, []string{"word:bus-state", "word:bus-state.name", "word:bus-state.storage"}},
// A seat's reach, filled from the seat holder's listen (ADR 0632 §1): an engine before it lacks the reading.
{"a seat's reach", `{"module":"s","version":"1","resources":[{"id":"f","type":"file","content":"${seat:container-image-registry:reach}"}]}`,
[]string{"form:seat", "form:seat-reach"}, []string{"form:seat-reach"}},
// The store holder's manifest, distribution's as ADR 0632 leaves it: no reach written, no provision served; an engine
// stating only the baseline lacks the reading that finds the store by it (ADR 0632 §2).
{"the store's holder", `{"module":"distribution","version":"1","claims":[{"name":"container-image-registry",` +
`"scope":"mesh","serves":["list"]}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`,
[]string{"form:seat-reach", "seat:container-image-registry"}, []string{"form:seat-reach", "seat:container-image-registry"}},
// docker's daemon file as ADR 0632 leaves it: the manifest unchanged, its template naming the image seat's reach;
// TemplatePaths names the template a reader of the files reads.
{"a template naming a seat's reach", `{"module":"d","version":"1","resources":[{"id":"daemon","type":"file",` +
`"path":"/etc/d.json","template":"files/daemon.json"}]}`, []string{"form:seat", "form:seat-reach"},
[]string{"form:seat-reach"}},
{"a word this contract does not know", `{"module":"u","version":"1","newer-word":{"inside":1}}`, []string{"word:module"}, nil},
} {
needs, err := ManifestNeeds([]byte(row.raw))
templates := map[string]map[string]string{ // the templates' text a row's module names
"a template naming a seat's reach": {"files/daemon.json": `{"x":"${seat:container-image-registry:reach}"}`},
}[row.why]
needs, err := ModuleNeeds([]byte(row.raw), templates)
if err != nil {
t.Fatalf("%s: %v", row.why, err)
}
if templates != nil {
m, err := Decode([]byte(row.raw))
if paths := TemplatePaths(m); err != nil || !slices.Equal(paths, slices.Sorted(maps.Keys(templates))) {
t.Errorf("%s: TemplatePaths = %v (%v), want %v", row.why, paths, err, slices.Sorted(maps.Keys(templates)))
}
}
for _, n := range row.needs {
if !slices.Contains(needs, n) {
t.Errorf("%s: needs %v, not %s", row.why, needs, n)
-55
View File
@@ -1,7 +1,6 @@
package catalogue
// PlacementSeat is the seat the assigner holds for placement.
const PlacementSeat = "mesh-placement"
// The seat's five verbs (ADR 0567 §10).
const (
@@ -11,57 +10,3 @@ const (
PlacementVerbWithdraw = "withdraw"
PlacementVerbAgain = "again"
)
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same
// delivery as its holder.
func placementVerbs() []Verb {
changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " +
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
"on the same warrant)",
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
return []Verb{
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
"module is placed on, or every key, each with its revision, and how full the state is against its cap. With " +
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
"proposal's changes from its placement-changed event.",
Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on",
"history": "\"true\": the changes in order, from the stream and the state's revisions",
// ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders.
"seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"},
nil, "history", "seats")},
{Name: PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
"the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " +
"answered, its verdict and its ask. Narrowed to one by id.",
Input: schema(map[string]string{"id": "one proposal's id"}, nil)},
{Name: PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " +
"the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " +
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
"properties": map[string]any{
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
"changes": changes,
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
// Approve is the proposal's; the holder writes it with no ask of its own.
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
"warrant {node, address, ask, warrant}, naming the change's node and address",
"required": []string{"node", "address", "ask", "warrant"}},
}}},
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
"candidates are deleted and placement-withdrawn carries it whole. With why.",
Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})},
{Name: PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " +
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
}
}
+5 -1
View File
@@ -31,6 +31,10 @@ type ProcessBus struct {
// 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"`
// Keeps says this process is the bus module's keeper (novox/hq ADR 0626 §1): the controller composes the bus's
// create, update and snapshot subjects for modules' entries into its account. Refused on any other process or
// module by the judge (ADR 0626 §3); read here so a manifest declaring it decodes.
Keeps bool `json:"keeps,omitempty"`
}
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: keys of the node-engine's acts
@@ -93,7 +97,7 @@ func (b *ProcessBus) UnmarshalJSON(raw []byte) error {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes}: %w", typedUnknown(err))
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes, keeps}: %w", typedUnknown(err))
}
*b = ProcessBus(p)
return nil
-65
View File
@@ -1,65 +0,0 @@
package catalogue_test
import (
"encoding/json"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
// The manifests the control plane actually ships, resolved.
//
// Written because the earlier tests built their own manifests and passed while the real one was
// missing a claim — a whole mechanism could have been absent from what ships and every test would
// still have been green.
func provided(t *testing.T) map[string]catalogue.Manifest {
t.Helper()
out := map[string]catalogue.Manifest{}
for _, raw := range []map[string]any{
overlay.Manifest(),
} {
b, err := json.Marshal(raw)
if err != nil {
t.Fatal(err)
}
m, err := catalogue.Decode(b)
if err != nil {
t.Fatalf("a manifest this control plane ships is not valid: %v", err)
}
out[m.Module] = m
}
return out
}
func TestTheControlPlaneShipsNoNetworkingBundle(t *testing.T) {
// ADR 0226: one module for the one private network. A bundle shipped beside it again would be
// a second name every machine carries for the same thing.
shipped := provided(t)
if len(shipped) != 1 {
t.Fatalf("the control plane ships %d modules, want only %s", len(shipped), overlay.Name)
}
if _, ok := shipped["networking"]; ok {
t.Fatal("the retired networking bundle is shipped again")
}
}
// The names-need-addressing test went with the names module: names are a fact now, and a machine
// the mesh cannot place is simply left out of the file (facts_test.go) — which is the same
// protection, enforced where the file is written rather than by a provision refusing.
func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest {
out := map[string]catalogue.Manifest{}
for k, v := range shelf {
out[k] = v
}
// Deliberately without name-resolution of its own, which is the case that used to install
// both VPNs: the names then needed the mesh's addressing, and only WireGuard has it.
out["tailscale"] = catalogue.Manifest{
Module: "tailscale", Version: "1",
Provides: catalogue.Offers(overlay.Requirement),
Claims: []catalogue.Claim{{Name: overlay.TheNetwork, Scope: catalogue.ScopeNode}},
}
return out
}
-58
View File
@@ -6,61 +6,3 @@ const ReadsSeat = "node-reads"
// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is
// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5).
const ReadsSecretPlaces = "MESH_SECRET_PLACES"
// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the
// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in
// every answer it cuts.
func readsVerbs() []Verb {
return []Verb{
{Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " +
"(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " +
"file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " +
"holds no secret: a component it may not reach says so. At most 200 paths in one call.",
Input: schema(map[string]string{
"paths": "one absolute path, or several separated by line breaks",
"follow": "\"true\": also say where the path leads once every link is followed",
}, []string{"paths"}, "follow"),
Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}},
{Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " +
"link is followed, and whether that exists.",
Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}),
Replaces: []string{"readlink", "readlink -f", "realpath"}},
{Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " +
"link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " +
"is refused by name.",
Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}),
Replaces: []string{"ls", "ls -la"}},
{Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " +
"(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " +
"kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " +
"number said. /proc and /sys are read as far as the holder's account may.",
Input: schema(map[string]string{
"path": "an absolute path to a file",
"from": "the first line to answer, counted from 1 (optional)",
"lines": "how many lines to answer (optional)",
"bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)",
}, []string{"path"}),
Replaces: []string{"cat", "head", "tail", "less"}},
{Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " +
"every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " +
"8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " +
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " +
"secret is withheld. The answer says which bound it met.",
Input: schema(map[string]string{
"text": "the fixed string to find",
"path": "an absolute path to a file or a directory",
"depth": "how many directories deep, up to 8 (optional)",
"matches": "the most matches to answer, up to 500 (optional)",
"case": "\"false\": match whatever the case (optional)",
}, []string{"text", "path"}, "case"),
Replaces: []string{"grep", "grep -r", "grep -F"}},
{Name: "account", Description: "One account or group by name or number, as this node's account files say: " +
"an account's number, group, home, shell and every group it is in; a group's number and members. With " +
"neither, every account and group. Never a password field.",
Input: schema(map[string]string{
"name": "an account's name or number (optional)",
"group": "a group's name or number (optional)",
}, nil),
Replaces: []string{"getent passwd", "getent group", "id", "groups"}},
}
}
-26
View File
@@ -62,29 +62,3 @@ func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) {
t.Fatalf("replaces not read: %v", got)
}
}
// A seat row the store seeded before `replaces` existed carries none; the working set takes the compiled
// one, so the `tools` answer says it on a mesh whose rows exist.
func TestASeededRowTakesWhatItsVerbsReplaceFromTheCompiledSeat(t *testing.T) {
was := Seats()
t.Cleanup(func() { UseSeats(was) })
var row Seat
for _, s := range DefaultSeats() {
if s.Name == ServiceManagerSeat {
row = s
}
}
stored := make([]Verb, len(row.Serves))
for i, v := range row.Serves {
v.Replaces = nil
stored[i] = v
}
row.Serves = stored
UseSeats([]Seat{row})
got, _ := SeatNamed(ServiceManagerSeat)
for _, v := range got.Serves {
if v.Name == "journal" && (len(v.Replaces) == 0 || v.Replaces[0] != "journalctl") {
t.Fatalf("the seeded journal verb says it replaces %v", v.Replaces)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package catalogue
import (
"sort"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// SeatFromDefinition is a seat in this package's types from its definition (novox/hq ADR 0608 §1, ADR 0635 §1): what
// the seat list once held, now read from the definition the indexer keeps on the bus. The definition is resolved with
// its parts (seatdef.Resolve) as the indexer keeps it. A verb's input and answer have their named types in place, and a
// contribution kind given as data gets its checks (withShapeCode), which are code and never a definition's.
func SeatFromDefinition(d seatdef.Definition) Seat {
s := Seat{Name: d.Seat, Scope: d.Scope, Replicated: d.Replicated, Delivers: d.Delivers,
Accepts: seatdef.MessageNames(d.Accepts), Emits: seatdef.MessageNames(d.Events), PulledByKind: d.PulledByKind,
Yields: d.Yields, HeldByTheEngine: d.HeldByTheEngine, Decision: d.Decision}
if len(s.Accepts) == 0 {
s.Accepts = nil
}
if len(s.Emits) == 0 {
s.Emits = nil
}
for _, v := range d.Verbs {
s.Serves = append(s.Serves, Verb{Name: v.Name, Description: v.Description,
Input: seatdef.ResolveRefs(v.Input, d.Defs), Output: seatdef.ResolveRefs(v.Answer, d.Defs),
Optional: v.Optional, Replaces: v.Replaces})
}
for _, r := range d.Receives {
rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered}
if r.Shape != nil {
sh := &Shape{Order: r.Shape.Order, Examples: examples(r.Shape.Examples), Text: r.Shape.Text}
for _, f := range r.Shape.Places {
sh.Places = append(sh.Places, PlaceField{Field: f.Field, Values: f.Values})
}
withShapeCode(d.Seat, r.Kind, sh)
rv.Shape = sh
}
s.Receives = append(s.Receives, rv)
}
for _, p := range d.Paths {
s.Paths = append(s.Paths, SeatPath{Path: p.Path, StepsBackFor: p.StepsBackFor})
}
return s
}
// SeatsFromDefinitions are the seats of a set of definitions, in reading order: the mesh's own, then a node's, each by
// name. A seat its holder declares itself (HolderDeclares) is not in the list, as the compiled list left it out.
func SeatsFromDefinitions(defs []seatdef.Definition) []Seat {
out := make([]Seat, 0, len(defs))
for _, d := range defs {
if d.HolderDeclares {
continue
}
out = append(out, SeatFromDefinition(d))
}
sort.SliceStable(out, func(i, j int) bool {
if (out[i].Scope == seatdef.ScopeMesh) != (out[j].Scope == seatdef.ScopeMesh) {
return out[i].Scope == seatdef.ScopeMesh
}
return out[i].Name < out[j].Name
})
return out
}
func examples(in []map[string]any) []map[string]any {
if in == nil {
return nil
}
out := make([]map[string]any, len(in))
for i, e := range in {
out[i], _ = seatdef.StringLists(e).(map[string]any)
}
return out
}
-11
View File
@@ -31,17 +31,6 @@ func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) {
}
}
// What the store's rows carry is never the paths: a row read back keeps the compiled ones.
func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) {
before := seats
t.Cleanup(func() { seats = before })
UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}})
s, _ := SeatNamed("node-hostname")
if len(s.Paths) != 2 {
t.Fatalf("node-hostname read back from its row names %v", s.Paths)
}
}
func hostnameSeat(t *testing.T) Seat {
t.Helper()
s, ok := SeatNamed("node-hostname")
+11 -686
View File
@@ -83,510 +83,22 @@ type Receivable struct {
Offered bool
}
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is
// written; the store's table is seeded from it and thereafter is the live, editable copy.
// seats is the working set the lookups read, empty until a host gives it the seat list (UseSeats): mesh-sdk compiles no
// seat list and imports no contracts module (novox/hq ADR 0635 §1). A host reads the list from the bus
// (mesh-sdk go/seatlist, which holds the foundation's bootstrapped definitions until the indexer is heard) and gives
// its seats here at start and at each change.
var seats []Seat
// UseSeats replaces the working set with the seat list a host read (seatlist.Reader.Seats, via SeatsFromDefinitions).
//
// In the order a person reads it: the mesh's own, then a node's.
var defaultSeats = append([]Seat{
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
// served by whichever module holds the seat with tools of these names.
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
Serves: []Verb{
{Name: "databases", Description: "Every database the store holds, with its on-disk size.",
Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}},
{Name: "query", Description: "One read-only statement against one database the store holds.",
Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"},
[]string{"database", "sql"}), Replaces: []string{"psql"}},
}},
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
// the mesh's own transport. ADR 0128 then made that connection something a module requires
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
// connection would mint a credential for each.
{Name: BrokerSeat, Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
// The vault: the controller seals every minted credential with what it provides, which is the
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
// second claimant, refused by name, rather than a candidate for a pin.
//
// Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an
// asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate`
// take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from
// the controller alone, which carries the value sealed to a key the vault made for the call and cannot open
// it. All optional until the vault that runs serves them (design 33 §7).
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
Serves: []Verb{
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
"sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " +
"bus proved it. Never the value in the clear, and never to a private key.",
Input: schema(map[string]string{"name": "the secret's name",
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
[]string{"name", "public_key"})},
{Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " +
"controller alone: name the secret and get a call and a key made for it, then send the call and the " +
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret",
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
"by": "who gave it at the terminal",
"origin": "issued for a bus account the controller minted for a core or moved module and hands " +
"at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)},
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
"from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " +
"0563 §8): the ask is recorded in the vault's store and bounded before the prompt " +
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
"module": "the module", "name": "the secret's name in the module",
"desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller",
"outcome": "instead of sealed: how the prompt ended"}), nil)},
{Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " +
"provision, from the controller alone, which asks the provider's tool with a key the vault made for " +
"the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " +
"each as that holder's own; the same value again writes nothing.",
Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision",
"provider": "the provider's machine", "module": "the provider's module"}), nil))},
{Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " +
"software issued and for an accepted credential. Never answers the value.",
Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module",
"name": "the secret's name in the module", "provider": "the provider's machine",
"provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)},
// The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what
// was sealed to its machine, which the controller cannot open; the controller copies its record of asks.
{Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " +
"from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " +
"and generation and get a call and a key made for it, then send the call, the value sealed to that " +
"key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " +
"back sealed to that key.",
Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine",
"module": "its module", "name": "its name in the module",
"provider": "a pair credential: the provider's machine", "provision": "the provision",
"consumer": "the consumer's machine", "consumer_module": "the consuming module",
"local": "the credential's local name, when it has one",
"origin": "made, accepted or issued, as the controller's store says",
"generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)",
"read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)},
// A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786).
{Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " +
"own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " +
"first or changed key is recorded and the machine's core secrets are said changed; the same key " +
"again changes nothing.",
Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)},
// A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547
// §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says.
{Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " +
"alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " +
"credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " +
"credential the vault does not hold is named missing; never a value in the clear.",
Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{
"type": "array", "items": map[string]any{"type": "object"},
"description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " +
"provision, consumer, consumer_module, provider, local}"}}}},
{Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " +
"into the vault's, as they are and once, from the controller alone. No value is in any of them.",
Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{
"type": "array", "items": map[string]any{"type": "object"},
"description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}},
{Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " +
"ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " +
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
"and the signer's fingerprint, and says signed; never a key.",
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
// manifest names for these two.
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
Input: withObject(schema(map[string]string{"node": "the node the token is for",
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
"sealed": "\"true\": answer the token sealed to the operator key",
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
"address": "the joining node's tunnel address",
"key": "the joining machine's own tunnel public key, as given at issue",
"range": "the private network's range, a prefix"})},
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
Input: schema(map[string]string{"node": "the joining node",
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
// node last; the state node-logins says where each node stands.
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
"without it.",
Input: schema(map[string]string{}, nil)},
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
}},
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
// Optional until the mesh-config that runs serves it (design 33 §7).
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565",
Serves: []Verb{
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
"with why, validated, and naming each module on each node whose value moves.",
Input: configInput()},
}},
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
// which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads
// them. Optional until the mesh-provisioner that runs serves it (design 33 §7).
{Name: "mesh-provisioner", Scope: ScopeMesh, Decision: "novox/hq ADR 0349",
Serves: []Verb{
{Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " +
"(which domain answers which kind of placeholder), the needs each module on each node recorded, what " +
"is applied where, or the items of every collection, narrowed by node and module. Reads only.",
Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect",
"node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)},
}},
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
// images and archives, by digest — which is why the provision is the artifact store and not an
// image registry.
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
// The catalog (novox/hq ADR 0604): what the mesh holds of every module, its verbs read from its holder's states (CatalogSeat).
{Name: CatalogSeat, Scope: ScopeMesh, Serves: catalogVerbs(), Decision: "novox/hq ADR 0604"},
// The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
{Name: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
// Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by
// a proposal the nodes answer and the operator approves. Its states and events are the holder module's.
{Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"},
// The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's.
{Name: CheckerSeat, Scope: ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"},
// The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the
// watchman's watchdogs, the self-check and the healers.
{Name: ConditionsSeat, Scope: ScopeMesh, Decision: healthDecision,
Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()},
{Name: WatchdogsSeat, Scope: ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()},
{Name: SelfCheckSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()},
{Name: HealersSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()},
// The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's
// policy and the bus step at the assigner, and the hand-act log.
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
Serves: artifactSweepVerbs()},
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
// They keep their names until that migration is done deliberately, apart from the node-* pass.
{Name: "npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"},
{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"},
// **A build node's work, one seat per build language** (novox/hq ADR 0525 §2, §10; ADR 0536 §4; issue 1887). The
// build module of each language claims its seat on every node it runs on; the builder and the checker put their
// asks on the seat's accepted kinds, which the broker derives as one work queue with a shared worker per kind, and
// each holder pulls delivery first, then check, then recheck, at most its node's build capacity at once. Its verbs
// say what the node runs and pause or resume it there: every holder pulls from the same worker, so a node's pause is
// its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace
// node-build-agent, retired with build-agent (ADR 0524 §5).
{Name: "node-build-go", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"),
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
{Name: "node-build-node", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"),
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
{Name: "node-build-image", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"),
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
// **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and
// every node and container asks them and nothing else. Replicated since ADR 0223: held on more
// than one machine, each answering the same names from the same roster, and every machine's
// resolver file lists every holder — its own first — and no public resolver, so whichever answers
// first gives the one answer. Delivers what a machine's resolver configuration requires, so that
// requirement resolves to a holder wherever they are placed.
{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Replicated: true,
Decision: "novox/hq ADR 0194, ADR 0223"},
// **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes
// /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts
// file as the operator's, changed through these three verbs on that machine alone. The controller
// holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an
// alias on a mesh that knew it.
{Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223",
// The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287).
Paths: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}},
Serves: []Verb{
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
"the operator's, or the block of the module or tool that writes it.",
Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}},
{Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " +
"/etc/hosts — a name for this machine's own programs, not the mesh's.",
Input: schema(map[string]string{"address": "the IPv4 or IPv6 address",
"names": "the names for it, separated by spaces"}, []string{"address", "names"}),
Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}},
{Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " +
"lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"},
[]string{"name"}), Replaces: []string{"sed -i /etc/hosts"}},
}},
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
Serves: []Verb{
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
"holding now, and the totals since the jail started; one jail's detail when named.",
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}},
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
"that holds it and when the ban ends.",
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}},
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
"operator's act on the live ban list, which the mesh never writes itself.",
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}),
Replaces: []string{"fail2ban-client set banip"}},
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}),
Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}},
}},
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
Serves: []Verb{
{Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " +
"ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " +
"chain when asked.",
Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)",
"chain": "one chain of that table (optional)"}, nil),
Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}},
{Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " +
"and answer with the mesh's table as loaded.",
Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}},
{Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " +
"host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " +
"DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " +
"active found firewall's chains. An operator's act, by name, never a flush.",
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}),
Replaces: []string{"nft delete", "iptables -X"}},
}},
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
// system or user scope; the holder answers questions and operator acts about them, each verb
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
// served by the node tools runtime (ADR 0175).
{Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177",
Serves: serviceManagerVerbs()},
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
// mesh's word for "something on this machine answers for installing", where a capability only
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
// of it is decided when someone needs to ask.
{Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"},
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
// dependency has a seat to name and the runtime's module has one to claim.
{Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
// every module contributes to it. No verbs — the seat says who places the environment's files,
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
// which module wrote it.
{Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"},
// The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a
// bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the
// machine's templates. It serves no verb an agent calls.
{Name: "node-provisioner", Scope: ScopeNode, Decision: "novox/hq ADR 0349"},
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
// the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller
// may call where the machine serves it (optional: ADR 0268); the holder places every module's shell
// code in its slots.
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
Serves: loginShellVerbs()},
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
// code modules contribute for the power moments, and publishes the machine's power states as
// its events. Every machine has one — every machine boots and shuts down. No verbs yet.
{Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"},
// The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager
// does not — a laptop's vendor keys — run by one module per machine, which owns its
// configuration. Every other module with keys contributes trigger lines to it.
{Name: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212",
Receives: []Receivable{{Kind: "trigger", Comment: "#"}}},
// The machine's message bus (novox/hq ADR 0215): its holder owns the D-Bus implementation and its
// system service, never restarts it live, and publishes curated events about it, never its traffic.
// It receives nothing yet: packages ship their own policies.
{Name: MessageBusSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0215"},
// The machine's backups (novox/hq ADR 0214, to-be 43): its holder keeps nightly restore points of
// the data every module on the machine declares, on the machine, against mistakes rather than
// disasters. A module contributes `backup` lines — what to run to take a consistent copy, and
// which of its directories to keep.
{Name: BackupSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0214",
Serves: backupVerbs(),
// `backup` is what to run and which paths to keep; `data` every item a module declares, with its
// class, for the holder to measure (novox/hq ADR 0233). Both derived from modules' data sections.
Receives: []Receivable{{Kind: BackupKindBackup, Comment: "#", Dirs: true}, {Kind: BackupKindData, Comment: "#", Dirs: true}}},
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
// model change, not a rename, so it stays until that is built.
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
// The program that manages the machine's own network. It delivers nothing: its holder keeps the
// manager and the mesh from contradicting each other — the private network's interface left
// alone — and writes the machine's resolver file itself, because the manager is what would
// otherwise rewrite it (novox/hq ADR 0223, which retired node-resolver-config into this seat).
// It never declares a link, an address or a wireless network, because the link is the only
// channel a fix could arrive on. A seat
// rather than a condition in the resolver's module, so a machine running two managers is
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
//
// Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the
// resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers
// the manager knows for it — the same for every holder, so a question about a machine's names is asked
// the same way whatever manages its network. Optional until both holders serve them.
//
// It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287,
// ADR 0247).
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(),
Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: ResolverSeat}}},
// The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a
// VPN client whose domains must go to its own servers while every other name goes to the mesh's.
// Where it is held, its holder writes the resolver file and the uplink's holder steps back from it
// (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link.
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(),
Paths: []SeatPath{{Path: ResolverFile}}},
// A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server
// exports a machine's folders to the private network and provides each as `nfs-share`; the holder of
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
{Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
{Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
// A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned
// on every node, then node-reads goes (ADR 0394 §9).
{Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
// The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module.
engineSeat(),
},
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
graphicalSessionSeats()...)
// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by
// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a
// change to data, not to this code.
var seats = defaultSeats
// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its
// text), and a limit that is a number; every other argument a string.
func configInput() map[string]any {
in := schema(map[string]string{"action": "list (when not given), set, clear or history",
"key": "the config key", "value": "for set: the value, of the key's type",
"module": "a module: its own value, with node", "node": "a node",
"why": "for set and clear: why", "by": "who gave the value at the controller's terminal",
"limit": "for history: at most this many, newest"}, nil)
props := in["properties"].(map[string]any)
props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " +
"a boolean, or for a trusted key an object or a list"}
props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"}
return in
}
// DefaultSeats is the set the mesh ships with, for seeding the store's seat table.
func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) }
// UseSeats replaces the working set with the one the control plane read from its store.
//
// **Empty is ignored on purpose.** A store that has not been seeded yet — or one that could not be
// read — must leave the compiled defaults in force rather than emptying the set: an empty set would
// refuse every claim and could stop the control plane composing at all, which is a far worse failure
// than running on the set the binary shipped with. So the store can only ever *replace* the set with
// a non-empty one, never erase it.
// **Empty is ignored on purpose.** A list that was not read yet — or one that could not be — must leave the set in force
// rather than emptying it: an empty set would refuse every claim and could stop a composition altogether, which is a
// far worse failure than running on the set the host last had.
func UseSeats(s []Seat) {
if len(s) == 0 {
return
}
// **The store's rows carry no protocol yet, and the protocol is what the bus is derived
// from.** ADR 0129 gives a seat what it accepts, emits and serves; ADR 0122 moved the set into
// a table that has name, scope, delivers and decision and nothing else, and the columns for
// the rest are not there yet. So a row replacing a compiled entry would silently drop the
// protocol, and the roles' work queues would never be raised — found live as "no response
// from stream" the first time a build was submitted over the new bus (2026-09-28). Until the
// table gains the columns, a row without a protocol keeps the compiled one of the same name.
byName := map[string]Seat{}
for _, d := range defaultSeats {
byName[d.Name] = d
}
merged := make([]Seat, 0, len(s))
for _, row := range s {
if len(row.Accepts)+len(row.Emits)+len(row.Serves) == 0 {
if d, known := byName[row.Name]; known {
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
}
}
// What a seat receives, whether it is replicated and the paths it names are never stored
// (novox/hq ADR 0212, ADR 0223, ADR 0287), so they are always the compiled ones.
if d, known := byName[row.Name]; known {
row.Receives = d.Receives
row.Paths = d.Paths
row.Replicated = d.Replicated
row.PulledByKind = d.PulledByKind
row.Yields = d.Yields // never stored (ADR 0553)
row.HeldByTheEngine = d.HeldByTheEngine // never stored (ADR 0563)
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
}
merged = append(merged, row)
}
seats = merged
}
// optionalAsCompiled is a row's verbs with each one the compiled seat marks optional marked so. The mark
// is never stored (Verb.Optional), and a row is what the working set holds once the store is read: a verb
// the seeding added to the row — `failed`, `checks` — would otherwise come back required, and every holder
// not serving it yet would be refused at registration and at handover, which the mark exists to prevent.
//
// **And what a verb replaces is the compiled one** (novox/hq ADR 0245): a row seeded before the field
// existed carries none, and re-seeding widens a verb's arguments only, so read from the row alone the
// `tools` answer — what the mesh MCP server's search and the agent's instructions are built from — would
// say no verb replaces anything on a mesh whose rows exist. The mesh's statement of what a role replaces
// is this binary's, like the optional mark.
func optionalAsCompiled(row, compiled []Verb) []Verb {
optional := map[string]bool{}
replaces := map[string][]string{}
for _, v := range compiled {
if v.Optional {
optional[v.Name] = true
}
if len(v.Replaces) > 0 {
replaces[v.Name] = v.Replaces
}
}
if len(optional) == 0 && len(replaces) == 0 {
return row
}
out := make([]Verb, len(row))
for i, v := range row {
v.Optional = optional[v.Name]
if r, ok := replaces[v.Name]; ok {
v.Replaces = r
}
out[i] = v
}
return out
seats = append([]Seat(nil), s...)
}
// aliases maps a seat's former names to its current canonical name (novox/hq ADR 0122). Loaded from
@@ -703,190 +215,3 @@ func SeatsWithAProtocol() []Seat {
}
return out
}
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
// caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers
// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional
// (Verb.Optional) until every holder serves them (ADR 0246).
func serviceManagerVerbs() []Verb {
scoped := func(more map[string]string, required []string) map[string]any {
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
for k, v := range more {
props[k] = v
}
return schema(props, required)
}
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
return []Verb{
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}},
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}},
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}},
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}},
{Name: "restart", Description: "Restart one unit.",
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}},
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}},
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}},
// **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the
// minutes it happened in, and with no window on the verb a person reached for a shell. Every
// argument is the holder's to validate — passed to journalctl as one word of its own, never through
// a shell — and what the unit printed of a secret is redacted before it is answered.
{Name: "journal", Description: "The last lines of one unit's journal (at most 2000), in a time window and " +
"narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as " +
"[redacted: <what it was>].",
Input: scoped(map[string]string{
"unit": unit["unit"],
"lines": "how many lines from the end of what matches (default 100, at most 2000)",
"since": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)",
"until": "the window's end, in the same forms (optional; now when absent)",
"match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)",
"priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)",
}, []string{"unit"}),
Replaces: []string{"journalctl"}},
// What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers
// it, so a caller asks every machine the same way. **Optional while its holders catch up**: the
// systemd module running today serves it as its own systemd_failed, and a required verb would
// refuse it before the version serving `failed` could be delivered.
{Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " +
"account's; a manager that does not answer is reported with its error, never as nothing failed.",
Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil),
Replaces: []string{"systemctl --failed"}},
// **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose
// file is gone stays failed in its manager until the record is reset, and what keeps asking for it
// is a dependency or an enable link no verb could show. Required since every holder serves them
// (ADR 0246 step 3).
{Name: "reset-failed", Description: "Clear one unit's failed record in its manager — " +
"its failed state and its start-limit count — and answer its state after. It removes no file and " +
"starts nothing.",
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}},
{Name: "wanted-by", Description: "What wants, requires or triggers one unit, read-only: the " +
"manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " +
"every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " +
"each with where it points and whether it dangles — the account's own directory included in user scope.",
Input: scoped(unit, []string{"unit"}),
Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy",
"ls ~/.config/systemd/user/*.wants"}},
// **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable`
// leaves a link whose unit file is gone, so the manager keeps asking for the unit. Required since
// every holder serves it (ADR 0246 step 3).
{Name: "unlink-dangling", Description: "Remove the dangling enable links named for one " +
"unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " +
"configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " +
"the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " +
"removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " +
"runtime placed, is left and said.",
Input: scoped(map[string]string{"unit": unit["unit"],
"why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}),
Replaces: []string{"rm ~/.config/systemd/user/*.wants/<unit>", "unlink /etc/systemd/system/*.wants/<unit>"}},
}
}
// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers
// about what the machine resolves through and over which links, whatever program manages its network.
func uplinkVerbs() []Verb {
return []Verb{
{Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " +
"domains and options it lists, whether it is the file the mesh declares, and when it is not, who " +
"wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " +
"in its place, a known writer running.",
Input: schema(nil, nil), Optional: true},
{Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " +
"the default route leaves through it, and the resolvers and search domains the network manager " +
"knows for it — a VPN's tunnel included.",
Input: schema(nil, nil), Optional: true},
}
}
// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now,
// and restore beside the live data — never over it.
func backupVerbs() []Verb {
return []Verb{
{Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " +
"its last good night, how many restore points are kept and the repository's size.",
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}},
{Name: "now", Description: "Take a backup now, of one module or of every module on this " +
"machine — before a migration, a retirement or anything else that could go wrong.",
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}},
{Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " +
"data, never over it: each directory as <path>.restored-<date>. Swapping it in is a " +
"person's act. Lists the restore points when none is named.",
Input: schema(map[string]string{
"module": "the module",
"snapshot": "the restore point (from `backed-up`; the newest when omitted)",
"path": "one of the module's directories (all of them when omitted)",
}, []string{"module"}), Replaces: []string{"restic restore"}},
}
}
// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run
// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit
// so a hung command answers rather than times the caller out.
//
// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can
// become root without a person, so a machine may withhold it: the control-node does, through the
// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark
// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its
// silence on the bus is not judged — a holder withholding it serves nothing on the seat.
func loginShellVerbs() []Verb {
return []Verb{
{Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " +
"non-interactive login shell in its home; answers with what it printed and how it exited.",
Input: schema(map[string]string{
"command": "the command line, as you would type it",
"timeout_seconds": "give up after this long, at most 25 (default 20)",
}, []string{"command"})},
}
}
// buildAskKinds are a build seat's accepted kinds, in the order a holder pulls them (ADR 0536 §4; mesh-sdk
// delivery.AskKinds).
func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} }
// buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10).
func buildSeatVerbs(language string) []Verb {
return []Verb{
{Name: "current", Description: "The " + language + " asks this node runs now (each ask's id, kind, module and when it " +
"started), its build capacity, and whether it is paused, taking no new ask.",
Input: schema(map[string]string{}, nil)},
{Name: "pause", Description: "Take no new " + language + " ask on this node until resumed; an ask running here " +
"ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the " +
"lasting way to keep a node from building.",
Input: schema(map[string]string{}, nil)},
{Name: "resume", Description: "Take " + language + " asks again on this node.",
Input: schema(map[string]string{}, nil)},
}
}
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
const BrokerSeat = "mesh-broker"
// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed.
// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a
// value for each, which the vault keeps as that holder's own and hands to it alone.
func withHolders(input map[string]any) map[string]any {
props, _ := input["properties"].(map[string]any)
props["holders"] = map[string]any{"type": "array",
"description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " +
"for each, kept as that holder's own and handed to it alone",
"items": map[string]any{"type": "object", "properties": map[string]any{
"node": map[string]any{"type": "string", "description": "the holder's machine"},
"module": map[string]any{"type": "string", "description": "the consuming module"}}}}
return input
}
func vaultIntake(first map[string]string) map[string]string {
out := map[string]string{"call": "the second step: the call the first step answered",
"sealed": "the second step: the value sealed to the call's key — never the value"}
for k, v := range first {
out[k] = v
}
return out
}
+5
View File
@@ -31,6 +31,11 @@ type SettingDeclaration struct {
Required bool `json:"required,omitempty"`
// Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5).
Runtime bool `json:"runtime,omitempty"`
// RenamedFrom is the value's old name, a config value of this module's own (novox/hq ADR 0609 §4, design 32 *A
// config value renamed*): on the merge mesh-config moves the value held under it to this name, and answers both
// names until every node-engine reports a build reading the new one. The config form only: the judge refuses it
// under `settings`, on a secret, and where it changes the value's trust.
RenamedFrom string `json:"renamed-from,omitempty"`
}
// KindPreference is the kind of a setting that may have a default.
-73
View File
@@ -5,76 +5,3 @@ const NFSServerSeat = "node-nfs-server"
// MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263).
const MountsSeat = "node-mounts"
// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263).
func nfsServerVerbs() []Verb {
return []Verb{
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
"/etc/exports.",
Input: schema(map[string]string{}, nil),
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
"knows its clients.",
Input: schema(map[string]string{}, nil),
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
"own private address, when the server grants it the share and the node asks for it. What a machine " +
"mounting the share asks before it mounts.",
Input: schema(map[string]string{
"share": "the share, by its name",
"address": "a node's private address, to ask whether the share is exported to it (optional)",
}, []string{"share"}),
Replaces: []string{"showmount -e"}},
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
"-ra) and answer the shares as it then holds them. The module's own process writes its export " +
"file; this is for after a change made outside it. Changes no shared path.",
Input: schema(map[string]string{}, nil),
Replaces: []string{"exportfs -ra"}},
{Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " +
"property for a path the mesh's own export now serves — only when that export is live. Without " +
"confirm, says what it would do and changes nothing.",
Input: schema(map[string]string{
"path": "the shared path whose hand-made export is taken over",
"confirm": "\"true\": change it (needs why); anything else is a dry run",
"why": "why, for the record",
}, []string{"path"}, "confirm"),
Replaces: []string{"zfs set sharenfs=off"}},
}
}
// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263).
func mountsVerbs() []Verb {
return []Verb{
{Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " +
"automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " +
"and which settings asked for it. A password in a mount's options is never shown.",
Input: schema(map[string]string{}, nil),
Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}},
{Name: "test", Description: "Whether one share's or occasional source's server answers from this " +
"machine now, without touching its mount point.",
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
Replaces: []string{"showmount -e", "ping"}},
{Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " +
"access.",
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
Replaces: []string{"mount"}},
{Name: "unmount", Description: "Release one share or occasional source now; its automount stays " +
"armed, so the next access mounts it again.",
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
Replaces: []string{"umount"}},
{Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " +
"line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " +
"the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.",
Input: schema(map[string]string{
"mountpoint": "the mount point whose fstab line is taken over",
"confirm": "\"true\": change it (needs why); anything else is a dry run",
"why": "why, for the record",
}, []string{"mountpoint"}, "confirm"),
Replaces: []string{"sed -i /etc/fstab"}},
}
}
+7 -3
View File
@@ -31,9 +31,13 @@ type StateDeclaration struct {
// KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker
// when it ends (novox/hq ADR 0433 §1): the bucket is made with the server's time to live per message.
KeyTTL bool `json:"key-ttl,omitempty"`
// ReadOnly says only the module's processes whose own bus.state names the state write it; the module's tools,
// the tool runner's account, read it and never write it (the lead's ruling, 2026-11-04: nats' warrant spend record
// `spent`). The judge's rule and the grants follow from it.
ReadOnly bool `json:"read-only,omitempty"`
}
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only}.
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] == '"' {
@@ -44,7 +48,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: %w", typedUnknown(err))
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl, read-only}: %w", typedUnknown(err))
}
*s = StateDeclaration(full)
return nil
@@ -52,7 +56,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
// MarshalJSON writes back the short form when there is nothing else to say.
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL {
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL && !s.ReadOnly {
return json.Marshal(s.Name)
}
type plain StateDeclaration
+11 -2
View File
@@ -2,6 +2,7 @@ package catalogue
import (
"encoding/json"
"strings"
"testing"
)
@@ -28,17 +29,25 @@ func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) {
}
// 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before.
// G-sdk-contracts 4: read-only likewise, and given twice it is refused.
func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) {
m, err := decodeForTest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` +
`"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`))
`"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]}`))
if err != nil {
t.Fatal(err)
}
if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL {
t.Fatalf("key-ttl not read as said: %+v", m.State)
}
if !m.State[3].ReadOnly || m.State[0].ReadOnly || m.State[1].ReadOnly {
t.Fatalf("read-only not read as said: %+v", m.State)
}
out, _ := json.Marshal(m.State)
if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` {
if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]` {
t.Fatalf("written back as %s", out)
}
if _, err := decodeForTest([]byte(`{"module":"nats","version":"1","state":[{"name":"spent","read-only":true,"read-only":false}]}`)); err == nil ||
!strings.Contains(err.Error(), "the field state[0].read-only is given twice") {
t.Fatalf("read-only given twice: %v", err)
}
}
+9
View File
@@ -28,6 +28,15 @@ func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) {
s.Days() != 1 || s.Cap() != 64 {
t.Fatalf("the stream read as %+v", s)
}
// ADR 0626 §1: the bus module's keeper declares keeps beside the rest of its bus, and it reads back.
k, err := decodeForTest([]byte(`{"module":"nats","version":"1","resources":[{"id":"keeper","type":"process",` +
`"run":["./keeper"],"bus":{"keeps":true,"state":["requests"]}}]}`))
if err != nil {
t.Fatalf("a keeper's bus was refused: %v", err)
}
if b, ok := k.ProcessBuses()["keeper"]; !ok || !b.Keeps {
t.Fatalf("the keeper's bus read as %+v", b)
}
}
// 1177/AC3: a stream with no retention composes as main does — limits retention, dropping its oldest — proven
+20
View File
@@ -8,6 +8,7 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"unicode/utf8"
)
@@ -184,6 +185,25 @@ func fillItemTemplates(m Manifest, items []Item, text func(string) (string, bool
return out, nil
}
// TemplatePaths is every template the manifest's files and items name, by its path in the module's directory, sorted
// and once each: what a reader of the module's files reads for ModuleNeeds. A template whose name is wrong is left out;
// the judge refuses it by name.
func TemplatePaths(m Manifest) []string {
var out []string
for _, r := range m.Resources {
if name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r); has && wrong == "" {
out = append(out, name)
}
}
for _, it := range m.Items {
if it.Template != "" && itemTemplateProblem(m, it.Template) == "" {
out = append(out, it.Template)
}
}
slices.Sort(out)
return slices.Compact(out)
}
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository tree,
// by template path. Refused by name: a template the directory lacks, and anything under its templates directory
// (TemplatesDir) the build's packing refuses — that directory itself, or any directory or file under it, that is a
+38
View File
@@ -0,0 +1,38 @@
package catalogue
import "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
// defaultSeats is the list the tests start from (seatlisttest: the contracts modules' definitions, read as a host reads
// them from the bus, ADR 0635 §1), and DefaultSeats gives a copy of it for a test to restore.
var defaultSeats = func() []Seat {
list := SeatsFromDefinitions(seatlisttest.Definitions())
UseSeats(list)
return list
}()
// DefaultSeats is the tests' seat list.
func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) }
// The whole seats' names, which the tests use and the package does not (ADR 0634 §4; ADR 0613 §5 refuses them in
// non-test code).
const (
CatalogSeat = "mesh-catalog"
CheckerSeat = "mesh-delivery-checker"
EnrolmentSeat = "mesh-node-enroller"
PlacementSeat = "mesh-module-assigner"
DeliverySeat = "mesh-delivery"
WantsSeat = "mesh-wants"
HandActsSeat = "mesh-hand-acts"
ConditionsSeat = "mesh-conditions"
WatchdogsSeat = "mesh-watchdogs"
SelfCheckSeat = "mesh-self-check"
HealersSeat = "mesh-healers"
VaultSeat = "mesh-vault"
ProvisionerSeat = "mesh-provisioner"
BrokerSeat = "mesh-broker"
EngineSeat = "node-engine"
NpmPackageRegistrySeat = "npm-package-registry"
GoPackageRegistrySeat = "go-package-registry"
ContainerImageRegistrySeat = "container-image-registry"
OciArtifactRegistrySeat = "oci-artifact-registry"
)
-5
View File
@@ -91,9 +91,4 @@ func TestOnlyTheResolverIsReplicated(t *testing.T) {
t.Errorf("%s replicated = %v; only mesh-dns-resolver is (ADR 0223)", s.Name, s.Replicated)
}
}
UseSeats([]Seat{{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution"}})
defer UseSeats(DefaultSeats())
if s, _ := SeatNamed("mesh-dns-resolver"); !s.Replicated {
t.Error("loading the set from the store, which has no column for it, lost the resolver's replication")
}
}
-13
View File
@@ -50,16 +50,3 @@ func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) {
t.Fatalf("a verb the seat does not promise was accepted: %v", err)
}
}
func TestTheUplinkVerbsReadBackFromTheRowStayOptional(t *testing.T) {
before := seats
t.Cleanup(func() { seats = before })
// The row as seeding stores it: both verbs, the mark not a column.
UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "resolvers"}, {Name: "links"}}}})
seat := uplinkSeat(t)
holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}}
if err := CanHold(holder, seat); err != nil {
t.Fatalf("read back from the row, the verbs are required: %v", err)
}
}
+23 -97
View File
@@ -27,44 +27,17 @@ func TestTheVaultsSeatDeliversSecret(t *testing.T) {
}
}
// The vault answers `get` on request and reply (novox/hq ADR 0349 §11, issue 674): the seat promises the
// verb, so a holder serving it is accepted — and, optional while the vault that runs is the TypeScript one,
// so is a holder that does not yet.
func TestTheVaultsSeatPromisesGet(t *testing.T) {
seat, _ := SeatNamed("mesh-vault")
var get *Verb
for i := range seat.Serves {
if seat.Serves[i].Name == "get" {
get = &seat.Serves[i]
}
}
if get == nil {
t.Fatal("the vault's seat does not promise get")
}
for _, arg := range []string{"name", "public_key"} {
if props, _ := get.Input["properties"].(map[string]any); props[arg] == nil {
t.Errorf("get takes no %s", arg)
}
}
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, Serves: []string{"get"}}}}
if err := CanHold(serving, seat); err != nil {
t.Fatalf("the vault serving get was refused: %v", err)
}
notYet := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}}
if err := CanHold(notYet, seat); err != nil {
t.Fatalf("the vault that does not serve get yet was refused: %v", err)
}
}
// The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises
// accept, secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted,
// and, optional while the vault that runs does not, so is one that does not yet.
// secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted, and,
// optional while the vault that runs does not, so is one that does not yet. And the move of the controller's secrets
// (design 57's V3, issue 676): move-in, asked in two steps, never a value in the clear. accept and move-asks, which no
// holder served and nothing called, are not the seat's (ADR 0620 §3); retire is, optional (design 32).
func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
seat, _ := SeatNamed("mesh-vault")
want := map[string][]string{
"accept": {"node", "module", "name", "provider", "provision", "by", "call", "sealed"},
// get, on request and reply (ADR 0349 §11, issue 674); a vault not serving it yet still holds the seat
// (TestTheVaultsSeatDeliversSecret).
"get": {"name", "public_key"},
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
"rotate": {"node", "module", "name", "provider", "provision", "by"},
@@ -76,6 +49,20 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
"rotate-node": {"node", "why", "act"},
"node-account-confirm": {},
"abandon": {"node"},
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
"generation", "made_at", "call", "sealed", "read_back_to"},
// And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key.
"own": {"credentials"},
"retire": {"node", "module", "name", "warrant"},
// Issue 786: node-key, optional while the vault that runs does not serve it.
"node-key": {"key"},
}
for _, gone := range []string{"accept", "move-asks"} {
for _, v := range seat.Serves {
if v.Name == gone {
t.Errorf("the vault's seat still promises %s, which no holder serves (ADR 0620 §3)", gone)
}
}
}
for name, args := range want {
var verb *Verb
@@ -124,70 +111,9 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
}
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
"rotate-node", "node-account-confirm", "abandon"}}}}
Serves: []string{"get", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
"rotate-node", "node-account-confirm", "abandon", "move-in", "node-key", "own"}}}}
if err := CanHold(serving, seat); err != nil {
t.Fatalf("the vault serving its verbs was refused: %v", err)
}
}
// The move of the controller's secrets into the vault (design 57's V3, issue 676): the seat promises move-in, which a
// machine's node-engine asks in two steps with the row, its origin and generation, then the value sealed to the
// call's key and a key to read back to; and move-asks, the controller's record of asks copied as it is. Optional
// while the vault that runs does not serve them; never a value in the clear.
func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
seat, _ := SeatNamed("mesh-vault")
want := map[string][]string{
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
"generation", "made_at", "call", "sealed", "read_back_to"},
"move-asks": {"asks"},
// And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key.
"own": {"credentials"},
}
for name, args := range want {
var verb *Verb
for i := range seat.Serves {
if seat.Serves[i].Name == name {
verb = &seat.Serves[i]
}
}
if verb == nil {
t.Errorf("the vault's seat does not promise %s", name)
continue
}
if !verb.Optional {
t.Errorf("%s is not optional while the vault that runs does not serve it", name)
}
props, _ := verb.Input["properties"].(map[string]any)
for _, arg := range args {
if props[arg] == nil {
t.Errorf("%s takes no %s", name, arg)
}
}
if props["value"] != nil {
t.Errorf("%s takes a value in the clear", name)
}
}
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "node-key", "move-asks", "own"}}}}
if err := CanHold(serving, seat); err != nil {
t.Fatalf("the vault serving the move was refused: %v", err)
}
}
// Issue 786: the vault's seat promises node-key, optional while the vault that runs does not serve it.
func TestTheVaultsSeatPromisesNodeKey(t *testing.T) {
seat, _ := SeatNamed("mesh-vault")
for _, v := range seat.Serves {
if v.Name != "node-key" {
continue
}
props, _ := v.Input["properties"].(map[string]any)
if !v.Optional || props["key"] == nil {
t.Errorf("node-key: optional %v, key %v", v.Optional, props["key"])
}
return
}
t.Error("the vault's seat does not promise node-key")
}
-17
View File
@@ -4,8 +4,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"slices"
)
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
@@ -76,21 +74,6 @@ func VerbNames(verbs []Verb) []string {
return out
}
// schema is a JSON schema for an object of string properties, which is every argument the verbs
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The
// switches are the properties that are "true" or "false" and nothing else, said in the schema as an
// enum so a caller sees it and the verb can refuse any other word rather than read it as false.
//
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
// name is refused when the verb is called, never passed over.
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
inner["description"], inner["additionalProperties"] = description, false
s["properties"].(map[string]any)[name] = inner
return s
}
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
isSwitch := map[string]bool{}
for _, s := range switches {
+3 -3
View File
@@ -19,9 +19,9 @@ func TestTheDeliverySeatHasNoPlanVerbs(t *testing.T) {
}
for _, name := range []string{"times", "what-if", "release", "stop", "retire-history"} {
v, ok := rows[name]
replacement, removed := RemovedDeliveryVerbs[name]
if !ok || !removed || replacement == "" {
t.Errorf("%s: in the table %v, in the removed list %v", name, ok, removed)
replacement := strings.TrimPrefix(v.Description, "Removed in R6 (ADR 0539): refused by name, naming its replacement: ")
if !ok || replacement == "" || replacement == v.Description {
t.Errorf("%s: in the table %v, replacement %q", name, ok, replacement)
continue
}
if !strings.HasPrefix(v.Description, "Removed in R6 (ADR 0539): refused by name") || !strings.Contains(v.Description, replacement) || !v.Optional {
+3 -3
View File
@@ -51,12 +51,12 @@ func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
t.Errorf("a holder saying it serves mint: %v", err)
}
// L3: the seat mesh-catalog promises four verbs, each optional while two claimants stand (ADR 0604 §1, §6).
// L3: the seat mesh-catalog promises five verbs (the fifth is the seat list, ADR 0636 §4), each optional while two claimants stand (ADR 0604 §1, §6).
catalog, ok := SeatNamed(CatalogSeat)
if !ok || catalog.Scope != ScopeMesh || catalog.Decision != "novox/hq ADR 0604" || len(catalog.Serves) != 4 {
if !ok || catalog.Scope != ScopeMesh || catalog.Decision != "novox/hq ADR 0604" || len(catalog.Serves) != 5 {
t.Fatalf("mesh-catalog: in the set %v, %+v", ok, catalog)
}
four := []string{"modules", "module", "provides", "dependents"}
four := []string{"modules", "module", "provides", "dependents", "seat-definitions"}
for i, v := range catalog.Serves {
if v.Name != four[i] || !v.Optional || v.Input == nil {
t.Errorf("mesh-catalog's verb %d is %+v, want %s, optional, with its input", i, v, four[i])
-204
View File
@@ -1,204 +0,0 @@
// Package composer is the shape of a composition over a plain request (novox/hq ADR 0482 clause 4, issue 1621, as ADR
// 0593 §7 changes it): the request the command mesh-compose reads and the verdict it answers. The command and the
// composer that runs it are the node-engine's (mesh-host cmd/mesh-compose), built from its repository at a group's
// head, so a change to its composition code is judged by that code.
//
// **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the
// request (raw, as their repository holds them), the rest of the mesh is a compose.Input, and the answer is a value.
// A manifest named only by its digest in the delivery indexer's bucket is filled before the run by Fill, by whoever
// holds the bus (the Go build module's compose job); Run refuses a digest left unfilled.
package composer
import (
"encoding/json"
"errors"
"fmt"
"slices"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Version is the version of a Request and of a Verdict; a reader refuses another.
const Version = 1
// Package is the import path of the command, which a compose job builds at a commit of the node-engine's repository
// (novox/hq ADR 0593 §7).
const Package = "github.com/novox/mesh-host/cmd/mesh-compose"
// The depths of a composition.
const (
// DepthResolve resolves each machine's set over the rest of the mesh (compose.Resolve): what the assigner judges
// while the facts snapshot is not on the bus (issue 1461).
DepthResolve = "resolve"
// DepthCompose composes each machine's whole declaration and runs the node-engine's validator over it
// (compose.Compose).
DepthCompose = "compose"
)
// Request is everything one run composes: the rest of the mesh, one or more cases of manifests composed over it, and
// the machines composed in each case.
type Request struct {
V int `json:"v"`
// Input is the composition's input without its catalogue (and without each node's kept manifests): the cases give
// those. A Catalogue or Kept it holds is ignored. It is the node-engine's composition input as JSON, which only the
// composer decodes (novox/hq ADR 0593 §7), so this shape imports no composition.
Input json.RawMessage `json:"input"`
// Cases are composed one after the other over Input, each with its own manifests; their names are unique.
Cases []Case `json:"cases"`
// Machines are the nodes composed in every case; none means the manifests are only read.
Machines []string `json:"machines,omitempty"`
// Depth is DepthResolve or DepthCompose; empty is DepthCompose.
Depth string `json:"depth,omitempty"`
// HoldingsFromClaims adds to Input's holdings every seat an assigned module claims, held on its node (the
// assigner's input, which has no holdings of record).
HoldingsFromClaims bool `json:"holdings_from_claims,omitempty"`
// Declarations asks each composed machine's declared paths (DepthCompose only).
Declarations bool `json:"declarations,omitempty"`
// Predefined is the list of predefined keys at the catalogue's root (predefined.json, novox/hq ADR 0348 §7). No
// longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts
// come in each case's Verdicts. Kept so an asker that still sends it is read.
Predefined catalogue.PredefinedList `json:"predefined,omitempty"`
}
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
//
// Placements are, by node, the placement the case composes that node with in place of what Input says of it: a change's
// own placement files, so a check composes a catalogue pull request's placement (issue 1126, frombus.Placed); a null
// placement leaves nothing placed on the node. A case without them composes Input's.
//
// Verdicts are, by module, the indexer's judge's verdict on its manifest in Manifests (novox/hq ADR 0578 §5), put in by
// the assigner or the checker that runs the composer: a refused one is held, composed as its stub so what it offers
// still shapes the rest, and named in the case's verdict. A module without one is accepted (delivery.TakeManifestVerdict).
type Case struct {
Name string `json:"name"`
Manifests map[string]Manifest `json:"manifests"`
Verdicts map[string]delivery.ManifestVerdict `json:"verdicts,omitempty"`
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
Placements map[string]*delivery.Placement `json:"placements,omitempty"`
}
// Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps
// it under (Digest), which Fill replaces by the bytes. On the wire it is the manifest itself, or {"digest": "..."}.
type Manifest struct {
Raw json.RawMessage
Digest string
}
// MarshalJSON writes the raw manifest, or {"digest": ...} when only the digest is known.
func (m Manifest) MarshalJSON() ([]byte, error) {
if len(m.Raw) > 0 {
return m.Raw, nil
}
return json.Marshal(map[string]string{"digest": m.Digest})
}
// UnmarshalJSON reads {"digest": "..."} (an object with that one key) as a digest, and any other value as the raw
// manifest.
func (m *Manifest) UnmarshalJSON(raw []byte) error {
var probe map[string]json.RawMessage
if json.Unmarshal(raw, &probe) == nil && len(probe) == 1 {
if d, ok := probe["digest"]; ok {
var digest string
if err := json.Unmarshal(d, &digest); err != nil {
return fmt.Errorf("a manifest's digest is not a string: %w", err)
}
*m = Manifest{Digest: digest}
return nil
}
}
*m = Manifest{Raw: slices.Clone(raw)}
return nil
}
// Verdict is what one run answers: the mesh-sdk the command was built with, and each case's verdict in the order asked.
type Verdict struct {
V int `json:"v"`
SDK string `json:"sdk"`
Cases []CaseVerdict `json:"cases"`
}
// CaseVerdict is one case's: every manifest read or refused, every machine composed or not.
type CaseVerdict struct {
Name string `json:"name"`
Manifests map[string]ManifestRead `json:"manifests"`
Machines map[string]Machine `json:"machines,omitempty"`
}
// ManifestRead is one manifest as this mesh-sdk reads it: accepted, or refused with why: the indexer's verdict
// (Case.Verdicts) or bytes that do not decode. A field the decoder does not know is set aside, named in SetAside, and
// the manifest still accepted (novox/hq ADR 0578 §6).
type ManifestRead struct {
Accepted bool `json:"accepted"`
Refused string `json:"refused,omitempty"`
SetAside string `json:"set_aside,omitempty"`
}
// Machine is one machine composed: whether it composes (resolves, at DepthResolve), why not, the node-engine
// validator's problems and the composition's refusals (DepthCompose), and its declared paths when asked. NotJudged says,
// in words, why the composer did not judge the machine (an older node-engine's input that does not say how it composes,
// novox/hq ADR 0593 §9): the machine is then neither composed nor refused, and a reader decides by this field alone,
// never by the words of Reason.
type Machine struct {
Composes bool `json:"composes"`
NotJudged string `json:"not_judged,omitempty"`
Reason string `json:"reason,omitempty"`
Problems []string `json:"problems,omitempty"`
Refusals []string `json:"refusals,omitempty"`
Declared []Path `json:"declared,omitempty"`
}
// Path is one path a declaration puts on the machine, with its kind (the resource's type), owner, group and mode as
// declared, and the module it is of.
type Path struct {
Path string `json:"path"`
Kind string `json:"kind"`
Owner string `json:"owner,omitempty"`
Group string `json:"group,omitempty"`
Mode string `json:"mode,omitempty"`
Module string `json:"module,omitempty"`
}
// ErrUnfilled is a manifest named by a digest that nobody replaced by its bytes.
var ErrUnfilled = errors.New("composer: a manifest is named by its digest and was not filled")
// Decode reads a request; it refuses one that is not JSON or of another version.
func Decode(data []byte) (Request, error) {
var r Request
if err := json.Unmarshal(data, &r); err != nil {
return Request{}, fmt.Errorf("composer: the request is not JSON of its shape: %w", err)
}
if r.V != Version {
return Request{}, fmt.Errorf("composer: a request of version %d, this composer reads %d", r.V, Version)
}
return r, nil
}
// Fill replaces every manifest named by its digest by the bytes get answers for that digest; a digest get cannot answer
// stops it, named.
func Fill(r *Request, get func(digest string) ([]byte, error)) error {
fill := func(m map[string]Manifest) error {
for name, man := range m {
if len(man.Raw) > 0 {
continue
}
raw, err := get(man.Digest)
if err != nil {
return fmt.Errorf("composer: the manifest of %s (digest %s): %w", name, man.Digest, err)
}
m[name] = Manifest{Raw: raw}
}
return nil
}
for _, c := range r.Cases {
if err := fill(c.Manifests); err != nil {
return err
}
for _, kept := range c.Kept {
if err := fill(kept); err != nil {
return err
}
}
}
return nil
}
-46
View File
@@ -1,46 +0,0 @@
package composer
import (
"encoding/json"
"errors"
"strings"
"testing"
)
const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}`
// A manifest named by its digest goes on the wire as {"digest": ...}, and Fill puts the bytes the indexer keeps in its
// place; a raw manifest goes as itself.
func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
r := Request{V: Version, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{
"demo": {Digest: "d1"}, "raw": {Raw: json.RawMessage(`{"module":"raw","version":"1"}`)}},
Kept: map[string]map[string]Manifest{"laptop": {"demo": {Digest: "d1"}}}}}}
wire, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(wire), `"demo":{"digest":"d1"}`) || !strings.Contains(string(wire), `"raw":{"module":"raw","version":"1"}`) {
t.Fatalf("on the wire: %s", wire)
}
back, err := Decode(wire)
if err != nil {
t.Fatal(err)
}
asked := 0
if err := Fill(&back, func(d string) ([]byte, error) {
asked++
if d != "d1" {
return nil, errors.New("no such digest")
}
return []byte(demo), nil
}); err != nil {
t.Fatal(err)
}
if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo {
t.Fatalf("filled %d times: %+v", asked, back.Cases[0])
}
bad := Request{V: Version, Cases: []Case{{Name: "c", Manifests: map[string]Manifest{"x": {Digest: "nope"}}}}}
if err := Fill(&bad, func(string) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") {
t.Fatalf("a digest the indexer does not hold: %v", err)
}
}
+24 -10
View File
@@ -16,7 +16,7 @@ const adoptedResources = `"resources":[
const sixtyFour = "0000000000000000000000000000000000000000000000000000000000000000"
func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) {
func testAnAdoptionIsReadWithTheDeclaration(t *testing.T) {
d, err := Parse([]byte(`{"adoption":{"taken":["postgres"],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
"declaration":1,` + adoptedResources + `}`))
if err != nil {
@@ -36,7 +36,7 @@ func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) {
}
}
func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) {
func testADeclarationWithNoAdoptionIsConverged(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,` + adoptedResources + `}`))
if err != nil {
t.Fatal(err)
@@ -49,7 +49,7 @@ func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) {
}
}
func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) {
func testAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.missing"]}},
"declaration":1,`+adoptedResources+`}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "hello-web.missing") {
@@ -57,7 +57,7 @@ func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) {
}
}
func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) {
func testAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) {
// A directory, a service or an action can reach what was found as surely as a file can, so
// the controller lists every resource of an untaken module (novox/hq ADR 0103).
d, err := Parse([]byte(`{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.data"]}},
@@ -70,7 +70,7 @@ func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) {
}
}
func TestAnIDUnderTwoModulesIsRefused(t *testing.T) {
func testAnIDUnderTwoModulesIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"a":["hello-web.page"],"b":["hello-web.page"]}},
"declaration":1,`+adoptedResources+`}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both") {
@@ -78,7 +78,7 @@ func TestAnIDUnderTwoModulesIsRefused(t *testing.T) {
}
}
func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) {
func testAModuleBothTakenAndUntakenIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":["hello-web"],"untaken":{"hello-web":["hello-web.page"]}},
"declaration":1,`+adoptedResources+`}`)
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both taken and untaken") {
@@ -86,7 +86,7 @@ func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) {
}
}
func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) {
func testTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) {
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"x":["adoption.guard"]}},
"declaration":1,"resources":[
{"id":"adoption.guard","type":"file","path":"/etc/mesh/guard.nft","content":"x"}]}`)
@@ -95,11 +95,11 @@ func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) {
}
}
func TestAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) {
func testAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) {
refusalFor(t, `{"adoption":{"taken":[],"held":["x"]},"declaration":1,`+adoptedResources+`}`)
}
func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
func testACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
_, err := ParseTrusted([]byte(`{"adoption":{"taken":[]},"declaration":1,` + adoptedResources + `}`))
if err == nil || !strings.Contains(err.Error(), "only the mesh can say") {
t.Fatalf("a bundle claiming adoption was not refused: %v", err)
@@ -107,7 +107,7 @@ func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
}
// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node.
func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
func testTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
adoptedWith := func(service string) error {
_, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`))
return err
@@ -130,3 +130,17 @@ func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
}
}
}
// TestAdoption holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestAdoption(t *testing.T) {
t.Run("AnAdoptionIsReadWithTheDeclaration", testAnAdoptionIsReadWithTheDeclaration)
t.Run("ADeclarationWithNoAdoptionIsConverged", testADeclarationWithNoAdoptionIsConverged)
t.Run("AnAdoptionNamingAnUnknownIDIsRefused", testAnAdoptionNamingAnUnknownIDIsRefused)
t.Run("AnAdoptionMayNameAResourceOfAnyKind", testAnAdoptionMayNameAResourceOfAnyKind)
t.Run("AnIDUnderTwoModulesIsRefused", testAnIDUnderTwoModulesIsRefused)
t.Run("AModuleBothTakenAndUntakenIsRefused", testAModuleBothTakenAndUntakenIsRefused)
t.Run("TheMeshsOwnResourcesAreNeverUntaken", testTheMeshsOwnResourcesAreNeverUntaken)
t.Run("AnAdoptionWithAnUnknownFieldIsRefused", testAnAdoptionWithAnUnknownFieldIsRefused)
t.Run("ACarriedBundleCannotSayTheNodeIsAdopted", testACarriedBundleCannotSayTheNodeIsAdopted)
t.Run("TakingOverATunnelIsSaidWholeAndForARunningService", testTakingOverATunnelIsSaidWholeAndForARunningService)
}
+8 -2
View File
@@ -8,7 +8,7 @@ import (
// Defends novox/hq issue 128: a file written into a block carries only its lines, in content,
// never a line the host keeps as its own marker, and says where a new region goes only as a block.
func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
func testAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"},
"an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"},
@@ -30,7 +30,7 @@ func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
}
}
func TestAFileWrittenIntoABlockIsRead(t *testing.T) {
func testAFileWrittenIntoABlockIsRead(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"},
{"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"},
@@ -50,3 +50,9 @@ func TestAFileWrittenIntoABlockIsRead(t *testing.T) {
t.Errorf("the markers are %q and %q", begin, end)
}
}
// TestBlock holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestBlock(t *testing.T) {
t.Run("AFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines", testAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines)
t.Run("AFileWrittenIntoABlockIsRead", testAFileWrittenIntoABlockIsRead)
}
+12 -4
View File
@@ -9,7 +9,7 @@ import (
// arrival and computes the next due minute the scheduler fires on (novox/hq ADR 0053). These test
// the parsing and the evaluation; the firing is tested against the scheduler.
func TestParseCronRefusesMalformed(t *testing.T) {
func testParseCronRefusesMalformed(t *testing.T) {
for _, expr := range []string{
"", // nothing
"* * * *", // four fields
@@ -31,7 +31,7 @@ func TestParseCronRefusesMalformed(t *testing.T) {
}
}
func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
func testParseCronAcceptsTheOrdinaryForms(t *testing.T) {
for _, expr := range []string{
"* * * * *", // every minute
"0 3 * * *", // 03:00 daily
@@ -47,7 +47,7 @@ func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
}
}
func TestCronNextIsTheNextMatchingMinute(t *testing.T) {
func testCronNextIsTheNextMatchingMinute(t *testing.T) {
// A Monday at 12:00:30, so "next" must round up to a whole minute and land on the first match
// strictly after now — never re-firing the minute we are already in.
now := time.Date(2026, 9, 7, 12, 0, 30, 0, time.UTC) // 2026-09-07 is a Monday
@@ -77,7 +77,7 @@ func TestCronNextIsTheNextMatchingMinute(t *testing.T) {
}
}
func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
func testCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
// The standard cron surprise: when day-of-month and day-of-week are both set, a day matches if
// EITHER does. "1 * 13 * 5" is due on the 13th OR on any Friday. 2026-09-07 is a Monday the 7th
// — neither — and 2026-09-11 is a Friday, and 2026-11-13 is the 13th.
@@ -98,3 +98,11 @@ func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
t.Error("a Monday the 7th matched a cron restricted to the 13th OR Friday")
}
}
// TestCron holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestCron(t *testing.T) {
t.Run("ParseCronRefusesMalformed", testParseCronRefusesMalformed)
t.Run("ParseCronAcceptsTheOrdinaryForms", testParseCronAcceptsTheOrdinaryForms)
t.Run("CronNextIsTheNextMatchingMinute", testCronNextIsTheNextMatchingMinute)
t.Run("CronDayFieldsAreOredWhenBothRestricted", testCronDayFieldsAreOredWhenBothRestricted)
}
+8 -2
View File
@@ -1368,8 +1368,14 @@ type Container struct {
Module string `json:"module,omitempty"`
Name string `json:"name"`
// Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not.
Image string `json:"image"`
Env map[string]string `json:"env,omitempty"`
Image string `json:"image"`
// ImageSeat and ImageHolder say, beside an image the composition routed through a store, the seat the reference came
// through and that seat's holder (novox/hq design 51, issue 1966 ENG-10): what the node-engine finds the holder to wait
// on when the pull fails. Not part of what a container is: they are no part of its spec or digest, so a declaration
// composed again for a new holder replaces no container.
ImageSeat string `json:"image-seat,omitempty"`
ImageHolder string `json:"image-holder,omitempty"`
Env map[string]string `json:"env,omitempty"`
// EnvFile names files the runtime reads environment from, in order.
//
// **Because a secret may not travel in Env.** A declaration reaches a node over the broker,
+62 -29
View File
@@ -30,7 +30,7 @@ func refusalFor(t *testing.T, raw string) *RefusalError {
return refusal
}
func TestAValidDeclarationParsesInOrder(t *testing.T) {
func testAValidDeclarationParsesInOrder(t *testing.T) {
d, err := Parse([]byte(valid()))
if err != nil {
t.Fatalf("unexpected refusal: %v", err)
@@ -45,7 +45,7 @@ func TestAValidDeclarationParsesInOrder(t *testing.T) {
}
}
func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
func testAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
// The property everything else rests on. A host that skipped what it did not understand
// would apply most of a declaration and report success — a node that looks configured and
// is not, which is 04-ISSUES/003 with the declaration on the other side of the wire.
@@ -64,7 +64,7 @@ func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
}
}
func TestAnUnknownFieldIsRefused(t *testing.T) {
func testAnUnknownFieldIsRefused(t *testing.T) {
// A field the host does not know is a thing the control plane believes it asked for.
refusal := refusalFor(t, `{"declaration":1,"resources":[
{"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true}
@@ -74,7 +74,7 @@ func TestAnUnknownFieldIsRefused(t *testing.T) {
}
}
func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
func testAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
// The same fault in miniature: set and ignored means the control plane believes it asked
// for something the host will never do.
refusal := refusalFor(t, `{"declaration":1,"resources":[
@@ -86,7 +86,7 @@ func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
}
}
func TestAnUnknownVersionIsRefusedWhole(t *testing.T) {
func testAnUnknownVersionIsRefusedWhole(t *testing.T) {
// An older host handed a newer vocabulary must not quietly do half of it.
refusal := refusalFor(t, `{"declaration":99,"resources":[
{"id":"a","type":"directory","path":"/etc/mesh"}
@@ -102,7 +102,7 @@ func TestAnUnknownVersionIsRefusedWhole(t *testing.T) {
}
}
func TestEveryProblemIsReportedAtOnce(t *testing.T) {
func testEveryProblemIsReportedAtOnce(t *testing.T) {
// A declaration is generated, so a person reading a refusal is debugging the generator.
// Fixing one problem at a time and re-running to find the next wastes their afternoon.
refusal := refusalFor(t, `{"declaration":1,"resources":[
@@ -115,7 +115,7 @@ func TestEveryProblemIsReportedAtOnce(t *testing.T) {
}
}
func TestIdentityIsRequiredAndUnique(t *testing.T) {
func testIdentityIsRequiredAndUnique(t *testing.T) {
// Identity is what lets the store know this is the same resource it applied last time,
// which is what makes removal possible at all.
refusal := refusalFor(t, `{"declaration":1,"resources":[
@@ -127,7 +127,7 @@ func TestIdentityIsRequiredAndUnique(t *testing.T) {
}
}
func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
func testModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
// "644" and "0644" differ, and the one that looks right in a manifest is the four-digit
// form. Accepting both would make a mode mean two things.
for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} {
@@ -145,7 +145,7 @@ func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
}
}
func TestARefusalSaysNothingWasApplied(t *testing.T) {
func testARefusalSaysNothingWasApplied(t *testing.T) {
// The reader's first question is whether the machine was left half-changed.
refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`)
if !strings.Contains(refusal.Error(), "none of it was applied") {
@@ -153,13 +153,13 @@ func TestARefusalSaysNothingWasApplied(t *testing.T) {
}
}
func TestAnEmptyDeclarationIsAMistake(t *testing.T) {
func testAnEmptyDeclarationIsAMistake(t *testing.T) {
refusalFor(t, `{"declaration":1,"resources":[]}`)
}
// --- the vocabulary the foundation bootstrap needs (novox/hq 07-the-foundation.md) ---
func TestAnActionOverTheLinkIsRefused(t *testing.T) {
func testAnActionOverTheLinkIsRefused(t *testing.T) {
// novox/hq ADR 0005. The link may push declarations of known shape and never a command to
// run. This is the boundary the whole security argument rests on, so it is asserted
// directly rather than inferred from the type list.
@@ -179,7 +179,7 @@ func TestAnActionOverTheLinkIsRefused(t *testing.T) {
}
}
func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
func testAnActionWithoutVerifyIsRefused(t *testing.T) {
// An action that runs and reports success without reading anything back is the fault this
// host exists to prevent. Verify is also the idempotency check, so an action without one
// cannot be applied twice safely either.
@@ -194,7 +194,7 @@ func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
}
}
func TestAnImageMustBePinnedByDigest(t *testing.T) {
func testAnImageMustBePinnedByDigest(t *testing.T) {
// novox/hq ADR 0006: reproducibility comes from pinning the identity of a thing. A bundle
// naming a tag pins nothing — it names whatever that tag points at on the day it runs.
for _, image := range []string{
@@ -226,7 +226,7 @@ func TestAnImageMustBePinnedByDigest(t *testing.T) {
// can start. The mesh's own control plane is built from source and lives in no public registry; a
// machine that built it, or was handed it, names it by what it is — a content address, not a name,
// and immutable in exactly the way the rule asks for.
func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) {
func testAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) {
held := "sha256:" + strings.Repeat("b", 64)
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
{"id":"control","type":"container","name":"mesh-controller","image":"` + held + `"}
@@ -245,7 +245,7 @@ func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) {
}
}
func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
func testAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
// The field-set check must cover the types added last, not only the three it was written
// for. A package that carries a `content` is a control plane believing it asked for
// something that will never happen.
@@ -266,7 +266,7 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
}
}
func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) {
func testTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) {
// Six of them the bootstrap uses (novox/hq 07-the-foundation.md), and removing one is a
// failing test rather than a discovery during a first-node install.
//
@@ -320,7 +320,7 @@ func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) {
}
}
func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) {
func testADeclarationFromDiskMayBeAnnotated(t *testing.T) {
// The bundle in this repository is mostly explanation of why each digest is what it is, and
// it could be built into a binary and not applied from disk — two readers for one file. The
// failure was `invalid character '/'`, which names the symptom and not the cause.
@@ -346,7 +346,7 @@ func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) {
}
}
func TestSomethingInsideAValueIsNotAComment(t *testing.T) {
func testSomethingInsideAValueIsNotAComment(t *testing.T) {
// Every image reference has a `//` in it somewhere near. Only whole lines are dropped.
d, err := ParseFileTrusted([]byte(`{"declaration":1,"resources":[
{"id":"f","type":"file","path":"/etc/x","content":"see https://example.invalid/ for why"}]}`))
@@ -370,7 +370,7 @@ func TestSomethingInsideAValueIsNotAComment(t *testing.T) {
//
// Not hypothetical: a test harness appended a line to the foundation bundle by accident, every
// apply kept working, and nothing said so for the entire time it was wrong.
func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) {
func testSomethingAfterTheDeclarationIsRefused(t *testing.T) {
good := `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a",` +
`"content":"x","mode":"0644"}]}`
if _, err := ParseFileTrusted([]byte(good)); err != nil {
@@ -394,7 +394,7 @@ func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) {
}
// A name that would read as a reference to something else is refused before it reaches a runtime.
func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
func testANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
for _, name := range []string{"", "mail/private", "host:mail", "a b"} {
refusal := refusalFor(t, `{"declaration":1,"resources":[
{"id":"private","type":"network","name":"`+name+`"}
@@ -409,7 +409,7 @@ func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
//
// The alternative is a configuration file containing the literal `${secret:x}`, which the program
// reads as a value and fails on somewhere with no connection to this.
func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
func testAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"declaration":1,"resources":[
{"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"}
]}`)
@@ -419,7 +419,7 @@ func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
}
// And a secret nobody asked for, because somebody believes it is in a file where it is not.
func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
func testASecretTheContentNeverUsesIsRefused(t *testing.T) {
refusal := refusalFor(t, `{"declaration":1,"resources":[
{"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}}
]}`)
@@ -431,7 +431,7 @@ func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
// The runtime's resolver and address flags take only addresses; a name would be refused at
// create, after the old container was already gone. Refused on arrival instead — and an address
// without a user-defined network is refused for the same reason.
func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
func testAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
refused := func(body string) []string {
_, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`))
if err == nil {
@@ -452,7 +452,7 @@ func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
}
}
func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
func testAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
// A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so
// the node drops what it last held — distinct from an accidental empty body, which is refused.
if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil {
@@ -467,7 +467,7 @@ func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
// A container's kept networks are names, not its own network, and not for a step (novox/hq ADR
// 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not.
func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
func testKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]}
@@ -506,7 +506,7 @@ func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
}
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
func TestACapabilityIsNamedOrRefused(t *testing.T) {
func testACapabilityIsNamedOrRefused(t *testing.T) {
image := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
@@ -527,7 +527,7 @@ func TestACapabilityIsNamedOrRefused(t *testing.T) {
// A container may send its output to the machine's journal, and nowhere else but the runtime's own
// file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too.
func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
func testAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
image := "postgres@sha256:" + strings.Repeat("a", 64)
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"}
@@ -548,7 +548,7 @@ func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
// Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write.
func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
func testADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]},
{"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"}
@@ -570,7 +570,7 @@ func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
}
}
func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
func testBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"`
cases := map[string]string{
"bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`,
@@ -593,3 +593,36 @@ func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
})
}
}
// TestDeclaration holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestDeclaration(t *testing.T) {
t.Run("AValidDeclarationParsesInOrder", testAValidDeclarationParsesInOrder)
t.Run("AnUnknownTypeRefusesTheWholeDeclaration", testAnUnknownTypeRefusesTheWholeDeclaration)
t.Run("AnUnknownFieldIsRefused", testAnUnknownFieldIsRefused)
t.Run("AFieldTheTypeDoesNotUseIsRefusedNotIgnored", testAFieldTheTypeDoesNotUseIsRefusedNotIgnored)
t.Run("AnUnknownVersionIsRefusedWhole", testAnUnknownVersionIsRefusedWhole)
t.Run("EveryProblemIsReportedAtOnce", testEveryProblemIsReportedAtOnce)
t.Run("IdentityIsRequiredAndUnique", testIdentityIsRequiredAndUnique)
t.Run("ModeIsRefusedUnlessItMeansWhatItLooksLike", testModeIsRefusedUnlessItMeansWhatItLooksLike)
t.Run("ARefusalSaysNothingWasApplied", testARefusalSaysNothingWasApplied)
t.Run("AnEmptyDeclarationIsAMistake", testAnEmptyDeclarationIsAMistake)
t.Run("AnActionOverTheLinkIsRefused", testAnActionOverTheLinkIsRefused)
t.Run("AnActionWithoutVerifyIsRefused", testAnActionWithoutVerifyIsRefused)
t.Run("AnImageMustBePinnedByDigest", testAnImageMustBePinnedByDigest)
t.Run("AnImageTheMachineHoldsIsNamedByItsOwnDigest", testAnImageTheMachineHoldsIsNamedByItsOwnDigest)
t.Run("AFieldTheNewTypesDoNotUseIsRefused", testAFieldTheNewTypesDoNotUseIsRefused)
t.Run("TheVocabularyIsTheTwelveShapesTheMeshNeeds", testTheVocabularyIsTheTwelveShapesTheMeshNeeds)
t.Run("ADeclarationFromDiskMayBeAnnotated", testADeclarationFromDiskMayBeAnnotated)
t.Run("SomethingInsideAValueIsNotAComment", testSomethingInsideAValueIsNotAComment)
t.Run("SomethingAfterTheDeclarationIsRefused", testSomethingAfterTheDeclarationIsRefused)
t.Run("ANetworkNameIsRefusedIfItIsNotOne", testANetworkNameIsRefusedIfItIsNotOne)
t.Run("AFileAskingForASecretItWasNotGivenIsRefused", testAFileAskingForASecretItWasNotGivenIsRefused)
t.Run("ASecretTheContentNeverUsesIsRefused", testASecretTheContentNeverUsesIsRefused)
t.Run("AContainersResolverAndAddressAreAddressesOrRefused", testAContainersResolverAndAddressAreAddressesOrRefused)
t.Run("AnExplicitlyEmptyDeclarationIsAccepted", testAnExplicitlyEmptyDeclarationIsAccepted)
t.Run("KeptNetworksAndLeftOutModulesAreReadStrictly", testKeptNetworksAndLeftOutModulesAreReadStrictly)
t.Run("ACapabilityIsNamedOrRefused", testACapabilityIsNamedOrRefused)
t.Run("AContainerMayLogToTheJournalAndNowhereElse", testAContainerMayLogToTheJournalAndNowhereElse)
t.Run("ADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles", testADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles)
t.Run("BadUnknownAndKnowsFormsAreRefusedNamingTheResource", testBadUnknownAndKnowsFormsAreRefusedNamingTheResource)
}
+16 -6
View File
@@ -15,7 +15,7 @@ func refFile(extra string) string {
}
// 1694/AC1: a reference round trips, and an invalid one is refused naming the field.
func TestAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) {
func testAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) {
d, err := Parse([]byte(refFile("")))
if err != nil {
t.Fatal(err)
@@ -49,7 +49,7 @@ func TestAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) {
}
// 1694/AC2: a declaration as today's SDK writes it reads unchanged and its bytes carry no new key.
func TestAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) {
func testAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) {
const in = `{"id":"m.f","type":"file","path":"/e","content":"hello"}`
d, err := Parse([]byte(`{"declaration":1,"resources":[` + in + `]}`))
if err != nil {
@@ -65,7 +65,7 @@ func TestAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) {
}
// 1694/AC3: the controller's helper makes a reference of a big file and the engine's check refuses a changed byte.
func TestABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) {
func testABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) {
big := strings.Repeat("x", FileReferenceThreshold+1)
f := &File{ID: "m.f", Type: TypeFile, Path: "/e", Content: big}
if !f.TravelsByDigest() {
@@ -95,7 +95,7 @@ func TestABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) {
}
// 1694/AC4: what stays inline, and bytes files travel decoded.
func TestWhichFilesTravelByDigest(t *testing.T) {
func testWhichFilesTravelByDigest(t *testing.T) {
small := strings.Repeat("x", FileReferenceThreshold)
big := strings.Repeat("x", FileReferenceThreshold+1)
for name, f := range map[string]File{
@@ -126,7 +126,7 @@ func TestWhichFilesTravelByDigest(t *testing.T) {
}
}
func TestTheSharedNames(t *testing.T) {
func testTheSharedNames(t *testing.T) {
if FileBucketPrefix != "declaration-files-" || FileReferencesCapability != "file-references" || FileReferenceThreshold != 65536 {
t.Fatal("a name changed")
}
@@ -136,7 +136,7 @@ func TestTheSharedNames(t *testing.T) {
}
// 1694/AC6: each node has a bucket of its own, so a grant names it and never needs a `*` (ADR 0486 §2).
func TestEachNodeHasABucketOfItsOwn(t *testing.T) {
func testEachNodeHasABucketOfItsOwn(t *testing.T) {
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
b, err := FileBucketFor(n)
if err != nil || b != "declaration-files-"+n {
@@ -156,3 +156,13 @@ func TestEachNodeHasABucketOfItsOwn(t *testing.T) {
}
}
}
// TestFilereference holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestFilereference(t *testing.T) {
t.Run("AFileReferenceRoundTripsAndABadOneNamesItsField", testAFileReferenceRoundTripsAndABadOneNamesItsField)
t.Run("AnInlineFileIsReadAndWrittenAsBefore", testAnInlineFileIsReadAndWrittenAsBefore)
t.Run("ABigFileBecomesAReferenceAndAChangedByteFailsTheCheck", testABigFileBecomesAReferenceAndAChangedByteFailsTheCheck)
t.Run("WhichFilesTravelByDigest", testWhichFilesTravelByDigest)
t.Run("TheSharedNames", testTheSharedNames)
t.Run("EachNodeHasABucketOfItsOwn", testEachNodeHasABucketOfItsOwn)
}
+8 -2
View File
@@ -9,7 +9,7 @@ import (
// A container may be given supplementary groups (novox/hq ADR 0480 clause 7, issue 1636): numbers, each above zero.
// 1636/AC1: a declaration with groups decodes, is written back with them, and a declaration without them has none.
func TestAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) {
func testAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"n8n.server","type":"container","name":"n8n","image":"` + anImage + `","groups":[2000,2001]},
{"id":"n8n.plain","type":"container","name":"plain","image":"` + anImage + `"}]}`))
@@ -34,7 +34,7 @@ func TestAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) {
}
// 1636/AC2: zero, a number below it, one the kernel keeps for "no group" and a number twice are refused, naming why.
func TestAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) {
func testAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) {
for want, groups := range map[string]string{
"never root": `[0]`,
"below zero": `[-5]`,
@@ -53,3 +53,9 @@ func TestAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) {
t.Error("a group named by text was accepted")
}
}
// TestGroups holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestGroups(t *testing.T) {
t.Run("AContainerWithGroupsDecodesAndIsWrittenBack", testAContainerWithGroupsDecodesAndIsWrittenBack)
t.Run("AContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated", testAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated)
}
+8 -2
View File
@@ -8,7 +8,7 @@ import (
const anImage = "docker.io/library/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
// The fields ADR 0325 adds, read as the controller sends them (novox/hq ADR 0325 §2, §3, §5, issue 567).
func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) {
func testIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,
"identities":[{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}],
"resources":[
@@ -35,7 +35,7 @@ func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) {
// What the engine refuses on arrival: a root number, an identity not named for its module, a field it does not
// know, a user that writes a read access, a variable set twice.
func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) {
func testWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) {
for want, raw := range map[string]string{
"never root": `"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]`,
"named mesh-<module>": `"identities":[{"module":"m","name":"m","uid":61001,"gid":61001}],` +
@@ -59,3 +59,9 @@ func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) {
}
}
}
// TestIdentity holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestIdentity(t *testing.T) {
t.Run("IdentitiesUsersAndARunAsAreReadAsSent", testIdentitiesUsersAndARunAsAreReadAsSent)
t.Run("WhatADeclarationMayNotSayOfAnIdentity", testWhatADeclarationMayNotSayOfAnIdentity)
}
+10 -3
View File
@@ -4,7 +4,7 @@ import "testing"
// 1132/AC1: a resource says its module, and the declaration says each module's build; a resource that does
// not say gets today's guess, so an older controller still works.
func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
func testAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,
"builds":{"novox.be":"b-9","web":"b-4"},
"resources":[
@@ -30,7 +30,7 @@ func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
}
// 1132/AC1: the module the declaration gives wins over the guess, even when the id would guess another.
func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
func testTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"a.b.c","type":"directory","path":"/srv/a","module":"a.b"},
{"id":"x.y","type":"directory","path":"/srv/x","module":"other"}]}`))
@@ -43,7 +43,7 @@ func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
}
// 1132/AC1: every kind accepts the field.
func TestEveryKindAcceptsAModule(t *testing.T) {
func testEveryKindAcceptsAModule(t *testing.T) {
for _, typ := range []Type{TypeDirectory, TypeFile, TypeUser, TypeNetwork, TypeAccess, TypeArchive, TypeProcess,
TypeService, TypeOpening, TypePackage, TypeContainer, TypeAction} {
r := newOf(typ)
@@ -58,3 +58,10 @@ func TestEveryKindAcceptsAModule(t *testing.T) {
}
}
}
// TestModule holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestModule(t *testing.T) {
t.Run("AResourceSaysItsModuleAndTheDeclarationItsBuilds", testAResourceSaysItsModuleAndTheDeclarationItsBuilds)
t.Run("TheDeclaredModuleWinsOverTheGuess", testTheDeclaredModuleWinsOverTheGuess)
t.Run("EveryKindAcceptsAModule", testEveryKindAcceptsAModule)
}
+10 -3
View File
@@ -12,7 +12,7 @@ func withOrder(order string) string {
return `{"declaration":1,` + order + `"resources":[{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"}]}`
}
func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) {
func testADeclarationCarriesItsEpochAndSequence(t *testing.T) {
d, err := Parse([]byte(withOrder(`"epoch":57,"sequence":3,`)))
if err != nil {
t.Fatalf("a declaration carrying its order was refused: %v", err)
@@ -27,7 +27,7 @@ func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) {
}
}
func TestAnOrderBelowZeroIsRefused(t *testing.T) {
func testAnOrderBelowZeroIsRefused(t *testing.T) {
for _, order := range []string{`"epoch":-1,`, `"sequence":-4,`} {
refusal := refusalFor(t, withOrder(order))
if !strings.Contains(refusal.Error(), "below zero") {
@@ -38,7 +38,7 @@ func TestAnOrderBelowZeroIsRefused(t *testing.T) {
// **The assignment generation it was composed from, and whether it is a put-back** (novox/hq issue 234):
// absent is no claim; below zero is none the mesh assigns; a carried bundle puts nothing back.
func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) {
func testADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) {
d, err := Parse([]byte(withOrder(`"sequence":3,"generation":41,"put_back":true,`)))
if err != nil {
t.Fatalf("a declaration carrying its generation was refused: %v", err)
@@ -57,3 +57,10 @@ func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) {
t.Errorf("a carried bundle said it was a put-back and was not refused for it: %v", err)
}
}
// TestOrder holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestOrder(t *testing.T) {
t.Run("ADeclarationCarriesItsEpochAndSequence", testADeclarationCarriesItsEpochAndSequence)
t.Run("AnOrderBelowZeroIsRefused", testAnOrderBelowZeroIsRefused)
t.Run("ADeclarationCarriesItsGenerationAndWhetherItIsAPutBack", testADeclarationCarriesItsGenerationAndWhetherItIsAPutBack)
}
+14 -5
View File
@@ -9,7 +9,7 @@ import (
// — owned, seed or managed — and the declaration names the files the provisioner fills. The words are
// read and judged here before any controller sends them.
func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) {
func testAFileSaysWhoOwnsItAtRuntime(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"o","type":"file","path":"/etc/o","content":"a","owner-at-runtime":"owned"},
{"id":"s","type":"file","path":"/etc/s","content":"a","owner-at-runtime":"seed"},
@@ -31,7 +31,7 @@ func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) {
}
// A file that does not say is owned by its old form, so the word means exactly what that form does.
func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) {
func testAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"o","type":"file","path":"/etc/o","content":"a"},
{"id":"s","type":"file","path":"/etc/s","content":"a","create-once":true},
@@ -53,7 +53,7 @@ func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) {
}
}
func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) {
func testAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) {
for name, c := range map[string]struct{ resource, refusal string }{
"another word": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"mine"}`,
`owner-at-runtime "mine"`},
@@ -78,7 +78,7 @@ func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) {
}
}
func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) {
func testTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"filled_files":["/etc/a","/etc/b"],"resources":[
{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"},
{"id":"b","type":"file","path":"/etc/b","into":"block","content":"b\n","owner-at-runtime":"managed"},
@@ -104,7 +104,7 @@ func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) {
}
}
func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) {
func testAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) {
for name, c := range map[string]struct{ filled, resources, refusal string }{
"not declared": {`["/etc/none"]`,
`{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`,
@@ -128,3 +128,12 @@ func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) {
}
}
}
// TestOwnership holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestOwnership(t *testing.T) {
t.Run("AFileSaysWhoOwnsItAtRuntime", testAFileSaysWhoOwnsItAtRuntime)
t.Run("AFileThatDoesNotSayIsOwnedByItsOldForm", testAFileThatDoesNotSayIsOwnedByItsOldForm)
t.Run("AnOwnershipThatContradictsTheFileIsRefused", testAnOwnershipThatContradictsTheFileIsRefused)
t.Run("TheDeclarationNamesTheFilesTheProvisionerFills", testTheDeclarationNamesTheFilesTheProvisionerFills)
t.Run("AFilledFileMustBeADeclaredFileThatSaysItsOwner", testAFilledFileMustBeADeclaredFileThatSaysItsOwner)
}
+24 -10
View File
@@ -15,7 +15,7 @@ func aProcess() *Process {
}
// A process is part of the vocabulary, or a declaration carrying one is refused whole.
func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) {
func testAProcessIsSomethingTheHostSpeaks(t *testing.T) {
var found bool
for _, kind := range Vocabulary() {
if kind == TypeProcess {
@@ -33,7 +33,7 @@ func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) {
// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a
// reference somebody can repoint is not pinned, and it is the one thing on a machine that would
// then be running code nobody reviewed.
func TestAProcesssBundleMustBePinned(t *testing.T) {
func testAProcesssBundleMustBePinned(t *testing.T) {
for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} {
d := aProcess()
d.Digest = bad
@@ -45,7 +45,7 @@ func TestAProcesssBundleMustBePinned(t *testing.T) {
// What to run is named, never inferred. Guessing an entrypoint from which files are present makes
// a process change what it runs when somebody adds a file.
func TestAProcessMustSayWhatToRun(t *testing.T) {
func testAProcessMustSayWhatToRun(t *testing.T) {
d := aProcess()
d.Run = nil
if problems := d.validate("a process", false); len(problems) == 0 {
@@ -54,7 +54,7 @@ func TestAProcessMustSayWhatToRun(t *testing.T) {
}
// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant.
func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
func testAProcesssNameCannotEscapeItsUnit(t *testing.T) {
// "." and ".." are one path element each, and the mesh's own bundle directory and its parent:
// removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR
// 0118). A leading dash is an option to the service manager, not a unit.
@@ -68,7 +68,7 @@ func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
}
// And a well-formed one is accepted, or the tests above prove only that everything is refused.
func TestAWellFormedDaemonIsAccepted(t *testing.T) {
func testAWellFormedDaemonIsAccepted(t *testing.T) {
if problems := aProcess().validate("a process", false); len(problems) != 0 {
t.Fatalf("a well-formed daemon was refused: %v", problems)
}
@@ -78,7 +78,7 @@ func TestAWellFormedDaemonIsAccepted(t *testing.T) {
// once does not run on a schedule; something not running between fires cannot be restarted when a
// file changes. A container's modes carry the same rule, and this is the same rule because it is
// the same thing hosted differently.
func TestTheModesAreExclusive(t *testing.T) {
func testTheModesAreExclusive(t *testing.T) {
both := aProcess()
both.RunOnce = true
both.Schedule = "0 3 * * *"
@@ -110,7 +110,7 @@ func TestTheModesAreExclusive(t *testing.T) {
// A cadence that is not a cadence is refused near its author, rather than by a machine at the far
// end of a declaration.
func TestAScheduleMustBeACadence(t *testing.T) {
func testAScheduleMustBeACadence(t *testing.T) {
for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} {
p := aProcess()
p.Schedule = bad
@@ -121,7 +121,7 @@ func TestAScheduleMustBeACadence(t *testing.T) {
}
// And each mode on its own is accepted, or the tests above prove only that everything is refused.
func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
func testEachModeOnItsOwnIsAccepted(t *testing.T) {
once := aProcess()
once.RunOnce = true
if problems := once.validate("a process", false); len(problems) != 0 {
@@ -148,7 +148,7 @@ func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
//
// So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of
// a declaration.
func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
func testAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
for _, bad := range []string{
"safe\nExecStart=/usr/bin/whatever",
"carriage\rreturn",
@@ -173,10 +173,24 @@ func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
// And ordinary awkward values are accepted, because escaping is what handles those — refusing them
// too would make a module unable to hold a generated password.
func TestOrdinaryAwkwardValuesAreAccepted(t *testing.T) {
func testOrdinaryAwkwardValuesAreAccepted(t *testing.T) {
p := aProcess()
p.Env = map[string]string{"PASSWORD": `a%H b"c\d`}
if problems := p.validate("a process", false); len(problems) != 0 {
t.Fatalf("a password containing the characters passwords contain was refused: %v", problems)
}
}
// TestProcess holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestProcess(t *testing.T) {
t.Run("AProcessIsSomethingTheHostSpeaks", testAProcessIsSomethingTheHostSpeaks)
t.Run("AProcesssBundleMustBePinned", testAProcesssBundleMustBePinned)
t.Run("AProcessMustSayWhatToRun", testAProcessMustSayWhatToRun)
t.Run("AProcesssNameCannotEscapeItsUnit", testAProcesssNameCannotEscapeItsUnit)
t.Run("AWellFormedDaemonIsAccepted", testAWellFormedDaemonIsAccepted)
t.Run("TheModesAreExclusive", testTheModesAreExclusive)
t.Run("AScheduleMustBeACadence", testAScheduleMustBeACadence)
t.Run("EachModeOnItsOwnIsAccepted", testEachModeOnItsOwnIsAccepted)
t.Run("AnEnvironmentValueCannotCarryALineBreak", testAnEnvironmentValueCannotCarryALineBreak)
t.Run("OrdinaryAwkwardValuesAreAccepted", testOrdinaryAwkwardValuesAreAccepted)
}
+8 -2
View File
@@ -8,7 +8,7 @@ import (
// Defends novox/hq ADR 0349 §4 (issue 668): the declaration names the account the provisioner's client runs as,
// the only one whose write of a filled file the node-engine accepts. Read here before any controller sends it.
func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) {
func testTheDeclarationNamesTheProvisionersAccount(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":["/etc/a"],
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}]}`))
if err != nil {
@@ -26,7 +26,7 @@ func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) {
}
}
func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) {
func testAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) {
for name, account := range map[string]string{"root": "root", "not a name": "a b", "a path": "../x"} {
_, err := Parse([]byte(`{"declaration":1,"provisioner_account":"` + account + `",
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`))
@@ -35,3 +35,9 @@ func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) {
}
}
}
// TestProvisioner holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestProvisioner(t *testing.T) {
t.Run("TheDeclarationNamesTheProvisionersAccount", testTheDeclarationNamesTheProvisionersAccount)
t.Run("AProvisionersAccountThatIsNoAccountIsRefused", testAProvisionersAccountThatIsNoAccountIsRefused)
}
+14 -5
View File
@@ -15,7 +15,7 @@ func replacing(extra string) string {
return `{"declaration":1,"resources":[` + strings.Replace(aReplacingProcess, "%s", extra, 1) + `]}`
}
func TestAProcessMayNameWhatItReplaces(t *testing.T) {
func testAProcessMayNameWhatItReplaces(t *testing.T) {
d, err := Parse([]byte(replacing(`,"replaces":["m.server"]`)))
if err != nil {
t.Fatalf("a process replacing an undeclared container was refused: %v", err)
@@ -27,7 +27,7 @@ func TestAProcessMayNameWhatItReplaces(t *testing.T) {
// What is replaced is what the declaration no longer says. A resource still declared is applied,
// and handing it over as well would remove something the same declaration asks to keep.
func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
func testAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
raw := `{"declaration":1,"resources":[
{"id":"m.server","type":"directory","path":"/tmp/x"},
` + strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + `]}`
@@ -36,7 +36,7 @@ func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
}
}
func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
func testOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
for _, mode := range []string{`,"run-once":true`, `,"schedule":"0 3 * * *"`} {
if _, err := Parse([]byte(replacing(mode + `,"replaces":["m.server"]`))); err == nil {
t.Errorf("a process with %s was allowed to replace something", mode)
@@ -44,7 +44,7 @@ func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
}
}
func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) {
func testAProcessCannotReplaceItselfOrNothing(t *testing.T) {
for _, bad := range []string{`,"replaces":["m.controller"]`, `,"replaces":[""]`} {
if _, err := Parse([]byte(replacing(bad))); err == nil {
t.Errorf("replaces %s was accepted", bad)
@@ -52,7 +52,7 @@ func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) {
}
}
func TestOneThingHasOneReplacement(t *testing.T) {
func testOneThingHasOneReplacement(t *testing.T) {
second := strings.NewReplacer(`"id":"m.controller"`, `"id":"m.other"`, `"name":"m"`, `"name":"other"`).
Replace(strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1))
raw := `{"declaration":1,"resources":[` +
@@ -61,3 +61,12 @@ func TestOneThingHasOneReplacement(t *testing.T) {
t.Fatal("two processes replacing one resource were accepted")
}
}
// TestReplaces holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
func TestReplaces(t *testing.T) {
t.Run("AProcessMayNameWhatItReplaces", testAProcessMayNameWhatItReplaces)
t.Run("AProcessMayNotReplaceSomethingStillDeclared", testAProcessMayNotReplaceSomethingStillDeclared)
t.Run("OnlyAProcessThatStaysUpReplacesAnything", testOnlyAProcessThatStaysUpReplacesAnything)
t.Run("AProcessCannotReplaceItselfOrNothing", testAProcessCannotReplaceItselfOrNothing)
t.Run("OneThingHasOneReplacement", testOneThingHasOneReplacement)
}

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