From fdbb3d578450b56b0baddd61057226de40bcebae Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:12:08 +0100 Subject: [PATCH 1/9] Add the seat mesh-grants, so the grants part can claim the lease write as a seat verb ADR 0582 clause 1 makes the lease write a verb of the seat mesh-grants: lease-write and lease-hand-back, each taking a why, and the events lease-handed, lease-altered and removal-failed. The indexer's judge reads seats through catalogue.SeatNamed, so it accepts mesh-grants' claim once it pins this head. Test: TestTheGrantsSeatPromisesTheLeaseWrite (new), paid by merging the two ssh tests of filtering_test.go into TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned. (issue 774, 774/S1) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/filtering_test.go | 9 ++--- go/catalogue/grants_seat.go | 53 +++++++++++++++++++++++++ go/catalogue/grants_seat_test.go | 67 ++++++++++++++++++++++++++++++++ go/catalogue/seats.go | 3 ++ 4 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 go/catalogue/grants_seat.go create mode 100644 go/catalogue/grants_seat_test.go diff --git a/go/catalogue/filtering_test.go b/go/catalogue/filtering_test.go index 8bf1e00..81f3765 100644 --- a/go/catalogue/filtering_test.go +++ b/go/catalogue/filtering_test.go @@ -744,12 +744,9 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) { if strings.Contains(nft, "\t\ttcp dport 22 accept") { t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft) } -} - -// And from outside as well, on a machine that faces outward — because that is the way in when the -// private network is the thing that broke. -func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) { - nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0") + // And from outside as well, on a machine that faces outward — because that is the way in when the + // private network is the thing that broke. + nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0") if !strings.Contains(nft, "\t\ttcp dport 22 accept") { t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft) } diff --git a/go/catalogue/grants_seat.go b/go/catalogue/grants_seat.go new file mode 100644 index 0000000..5d12b55 --- /dev/null +++ b/go/catalogue/grants_seat.go @@ -0,0 +1,53 @@ +package catalogue + +// **The lease write, a verb of the seat `mesh-grants`** (novox/hq ADR 0582 clause 1, issue 774): the grants part +// holds the seat once for the mesh and, on the operator's warrant, hands the controller's lease to itself and back. +// It serves two verbs and says three events, none carrying a secret: +// +// - lease-handed: the lease names mesh-grants, written on the operator's warrant (clause 3); +// - lease-altered: the lease key or its bucket's expiry differs from what mesh-grants wrote (clause 4); +// - removal-failed: the day-later removal of the controller's consumer or stream failed (clause 6). + +// GrantsSeat is the seat the grants part (mesh-grants) holds. +const GrantsSeat = "mesh-grants" + +// The seat's verbs. +const ( + GrantsVerbLeaseWrite = "lease-write" + GrantsVerbLeaseHandBack = "lease-hand-back" +) + +// The seat's events. +const ( + GrantsEventLeaseHanded = "lease-handed" + GrantsEventLeaseAltered = "lease-altered" + GrantsEventRemovalFailed = "removal-failed" +) + +// grantsDecision is the record that made the seat. +const grantsDecision = "novox/hq ADR 0582" + +// grantsEvents are the seat's events, in the order the write meets them. +func grantsEvents() []string { + return []string{GrantsEventLeaseHanded, GrantsEventLeaseAltered, GrantsEventRemovalFailed} +} + +// grantsVerbs are the seat's verbs (ADR 0582 clauses 1, 2, 3, 5). +func grantsVerbs() []Verb { + return []Verb{ + {Name: GrantsVerbLeaseWrite, Description: "Hand the controller's lease to mesh-grants, kept for good. Refused, " + + "naming the check, until the lease check at phase write passes and every condition it does not judge reads " + + "green. Otherwise the operator is asked once; on the warrant the checks are read again, the controller's " + + "service is stopped, the bus's user list is composed without the controller's write on the lease, the lease's " + + "expiry is removed and the key holder names mesh-grants, said as lease-handed and recorded as a hand act. A no " + + "or an expiry of the ask acts on nothing.", + Input: schema(map[string]string{"why": "why the lease is handed now, shown to the operator and kept"}, + []string{"why"})}, + {Name: GrantsVerbLeaseHandBack, Description: "Give the lease back to the controller, for a day after the write: " + + "asked and warranted the same way, it restores the lease's fifteen-second expiry, deletes the key holder, " + + "gives the controller its rights back once the list is loaded, and starts the controller. Refused once the " + + "day-later removal ran: the controller would start without its consumer and stream.", + Input: schema(map[string]string{"why": "why the lease goes back, shown to the operator and kept"}, + []string{"why"})}, + } +} diff --git a/go/catalogue/grants_seat_test.go b/go/catalogue/grants_seat_test.go new file mode 100644 index 0000000..fe45b76 --- /dev/null +++ b/go/catalogue/grants_seat_test.go @@ -0,0 +1,67 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774): its holder serves lease-write and lease-hand-back, both +// a condition of holding, and nothing else; the seat says lease-handed, lease-altered and removal-failed. +func TestTheGrantsSeatPromisesTheLeaseWrite(t *testing.T) { + seat, ok := SeatNamed(GrantsSeat) + if !ok { + t.Fatal("the seat mesh-grants is not in the set") + } + if seat.Scope != ScopeMesh || seat.Delivers != "" { + t.Errorf("mesh-grants is at scope %q delivering %q, want mesh and nothing", seat.Scope, seat.Delivers) + } + if got, want := VerbNames(seat.Serves), []string{"lease-write", "lease-hand-back"}; !reflect.DeepEqual(got, want) { + t.Errorf("mesh-grants serves %v, want %v", got, want) + } + if want := []string{"lease-handed", "lease-altered", "removal-failed"}; !reflect.DeepEqual(seat.Emits, want) { + t.Errorf("mesh-grants says %v, want %v", seat.Emits, want) + } + for _, v := range seat.Serves { + if v.Optional { + t.Errorf("%s is optional: the holder ships with the seat, so every verb is a condition of holding", v.Name) + } + props, _ := v.Input["properties"].(map[string]any) + if req, _ := v.Input["required"].([]string); len(props) != 1 || !reflect.DeepEqual(req, []string{"why"}) { + t.Errorf("%s takes %v requiring %v, want only a why", v.Name, props, req) + } + } + + claim := func(serves ...string) Manifest { + return Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeMesh, Serves: serves}}} + } + for _, c := range []struct { + name string + m Manifest + refuse string // empty: held + }{ + {"both verbs", claim("lease-write", "lease-hand-back"), ""}, + {"without the hand-back", claim("lease-write"), "lease-hand-back"}, + {"a verb the seat does not promise", claim("lease-write", "lease-hand-back", "lease-take"), "lease-take"}, + {"at node scope", Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeNode, + Serves: []string{"lease-write", "lease-hand-back"}}}}, "scope"}, + } { + err := CanHold(c.m, seat) + switch { + case c.refuse == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)): + t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.refuse) + } + } + + // No other seat changed: the set is the one before it with mesh-grants added beside mesh-hand-acts. + var names []string + for _, s := range DefaultSeats() { + names = append(names, s.Name) + } + at := strings.Index(strings.Join(names, " "), HandActsSeat+" "+GrantsSeat+" ") + if at < 0 { + t.Errorf("mesh-grants does not follow mesh-hand-acts in the seat set: %v", names) + } +} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index bef2fc7..4dc4536 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -302,6 +302,9 @@ var defaultSeats = append([]Seat{ // policy and the bus step at the assigner, and the hand-act log. {Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()}, {Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()}, + // The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to + // itself and back on the operator's warrant. + {Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()}, // 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()}, -- 2.54.0 From 6f483725e9cdcb22ed8673a10d3ae668f9ab4168 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:14:44 +0100 Subject: [PATCH 2/9] Compose the lease write's cut, so a controller started after the write fails on the lease ADR 0582 clause 3 composes the bus's user list without the controller's write on the lease key and update of the lease's bucket. Records.LeaseCut, set by the grants part from the write's second step, drops the controller's $KV grant on the lease, denies it the bucket's update, purge, delete and message delete (its $JS.API.> reaches them), and lets the grants part write the key holder alone. The bus module's tools account may read and update the lease's bucket, for the expiry tool the write and the hand-back call. Without the cut the lists equal the controller's golden. Tests: rows added to TestTheCopiedComposerComposesAsTheControllers' tables (the cut on and off; the tools account's lease grants); no new test function. (issue 774, 774/S2) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/buscompose_test.go | 55 ++++++++++++++++++++++++++++++-- go/buscompose/lease_cut.go | 41 ++++++++++++++++++++++++ go/buscompose/nats.go | 19 +++++++++++ go/buscompose/users.go | 8 ++++- go/buscompose/writers.go | 7 ++-- 5 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 go/buscompose/lease_cut.go diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 714a8b8..6c2f676 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -636,6 +636,48 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers) } } + // ADR 0582 clause 3 (issue 774, 774/S2): the lease write's cut takes from the controller the lease's key and every + // update, purge and delete of its bucket, and gives the grants part the key holder alone; without it nothing + // changes (the golden lists above are composed without it). + lease := "$KV." + LeaseBucket + ".holder" + for _, cut := range []bool{false, true} { + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: GrantsModule}}}, + LeaseCut: cut}) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + kind Kind + subject string + may bool + }{ + {KindController, lease, !cut}, + {KindController, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, !cut}, + {KindController, "$JS.API.STREAM.PURGE.KV_" + LeaseBucket, !cut}, + {KindController, "$JS.API.STREAM.DELETE.KV_" + LeaseBucket, !cut}, + {KindController, "$JS.API.STREAM.MSG.DELETE.KV_" + LeaseBucket, !cut}, + // It still reads the lease, and keeps its other buckets. + {KindController, "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder", true}, + {KindController, "$KV." + CallsBucket + ".x", true}, + {KindController, "$JS.API.STREAM.UPDATE.KV_" + CallsBucket, true}, + {KindModule, lease, cut}, + {KindModule, "$KV." + LeaseBucket + ".other", false}, + {KindModule, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, false}, + } { + for _, u := range users { + if u.Kind != c.kind || (c.kind == KindModule && u.Module != GrantsModule) { + continue + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatalf("cut %v: %s: %v", cut, u.Username(), err) + } + if MayPublish(perms, c.subject) != c.may { + t.Errorf("cut %v: %s may publish %s: %v, want %v", cut, u.Username(), c.subject, !c.may, c.may) + } + } + } + } // 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials. healthChecks(t) q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"}, @@ -743,7 +785,9 @@ func healthChecks(t *testing.T) { "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts", // Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write. - "$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS"}, + "$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS", + // ADR 0582 clause 3 (issue 774, 774/S2): the lease's bucket's expiry, read and set by its tool. + "$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"}, } mayNot := map[string][]string{ // The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2). @@ -786,8 +830,13 @@ func healthChecks(t *testing.T) { // outside the bus module). "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts", - "$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X"}, - "a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, + "$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X", + // It updates the lease's bucket alone, and neither purges nor deletes it nor writes its key (ADR 0582 clause 3). + "$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS", + "$JS.API.STREAM.PURGE.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease", + "$KV.mesh-controller_lease.holder"}, + "a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1", + "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"}, "a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, "a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"}, } diff --git a/go/buscompose/lease_cut.go b/go/buscompose/lease_cut.go new file mode 100644 index 0000000..e1c2cb1 --- /dev/null +++ b/go/buscompose/lease_cut.go @@ -0,0 +1,41 @@ +package buscompose + +import "slices" + +// **The lease write's grants** (novox/hq ADR 0582 clause 3, issue 774). Composed with Records.LeaseCut, the list takes +// from the controller its write of the lease's key and every update, purge and delete of the lease's bucket, and gives +// the grants part the key `holder`, which it creates naming itself and deletes at a hand-back (clause 5). Whatever the +// records, the bus module's tools account may read and update the lease's bucket, for the tool that removes and +// restores its expiry; no other bucket's. + +// leaseStream is the lease bucket's stream. +var leaseStream = "KV_" + LeaseBucket + +// LeaseHolderWrite is the grants part's write of the lease's one key, from the cut. +func LeaseHolderWrite() string { return "$KV." + LeaseBucket + "." + leaseKey } + +// LeaseCutDenied are the controller's subjects of the lease's bucket that its `$JS.API.>` reaches and the cut refuses: +// the bucket's update (its expiry), purge and delete, and a message's delete (the key's last value). +func LeaseCutDenied() []string { + return []string{"$JS.API.STREAM.UPDATE." + leaseStream, "$JS.API.STREAM.PURGE." + leaseStream, + "$JS.API.STREAM.DELETE." + leaseStream, "$JS.API.STREAM.MSG.DELETE." + leaseStream} +} + +// LeaseExpiryGrants are the bus module's tools account's read and update of the lease's bucket, for its expiry. +func LeaseExpiryGrants() []string { + return []string{"$JS.API.STREAM.INFO." + leaseStream, "$JS.API.STREAM.UPDATE." + leaseStream} +} + +// writesTheLease is the controller holding the lease, or, from the cut, the grants part writing its key holder alone. +func writesTheLease(p Principal, pattern string) bool { + if p.Kind == KindController { + return true + } + return p.Kind == KindModule && p.Module == GrantsModule && p.LeaseCut && pattern == LeaseHolderWrite() +} + +// setsLeaseExpiry is the bus module's tools account updating the lease's bucket: the writers table's one exemption from +// stream definitions being the controller's beside ASSIGNMENTS' removal (ADR 0582 clause 3). +func setsLeaseExpiry(p Principal, pattern string) bool { + return p.Kind == KindProcess && len(p.Removes) > 0 && slices.Contains(LeaseExpiryGrants(), pattern) +} diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 86aa45e..c37be8f 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -201,6 +201,9 @@ type Principal struct { // Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus // (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them. Removes []string + // LeaseCut is Records.LeaseCut on the controller and the grants part (novox/hq ADR 0582 clause 3): the controller + // loses its write of the lease, and the grants part may write the key `holder`. + LeaseCut bool // DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name // (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's. DeadLetterSources []string @@ -668,6 +671,9 @@ func PermissionsFor(p Principal) (Permissions, error) { if err != nil { return Permissions{}, err } + // And the lease's bucket's expiry, read and set, by the bus module's tool the grants part calls at the lease + // write and its hand-back (novox/hq ADR 0582 clauses 3, 5): the one bucket whose stream it may update. + pub = append(pub, LeaseExpiryGrants()...) sort.Strings(pub) if err := CheckWriters(p, pub); err != nil { return Permissions{}, err @@ -801,6 +807,9 @@ func PermissionsFor(p Principal) (Permissions, error) { if bucket == delivery.HaveBucket { continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1) } + if bucket == LeaseBucket && p.LeaseCut { + continue // the lease write took it (novox/hq ADR 0582 clause 3): a controller started fails on the lease + } pub = append(pub, "$KV."+bucket+".>") } // **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the @@ -1026,6 +1035,11 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...) // And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7). pub = append(pub, GrantsUserListWrites()...) + // And, from the lease write's cut, the lease's one key, which it creates naming itself and deletes at a + // hand-back (novox/hq ADR 0582 clauses 3, 5): never the bucket's other keys or its stream. + if p.LeaseCut { + pub = append(pub, LeaseHolderWrite()) + } } // The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a). if p.Module == ViewModule { @@ -1408,6 +1422,11 @@ func PermissionsFor(p Principal) (Permissions, error) { } // What the controller alone may publish is denied to everybody else whose grant reaches it. var deny []string + // And from the lease write's cut, the lease's bucket to the controller, whose `$JS.API.>` reaches it (novox/hq ADR + // 0582 clause 3): a controller started by mistake fails on the lease instead of taking it back. + if p.Kind == KindController && p.LeaseCut { + deny = append(deny, LeaseCutDenied()...) + } if p.Kind != KindController { for _, only := range ControllerOnly() { for _, a := range pub { diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 31fdfec..720b030 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -148,6 +148,11 @@ type Records struct { // Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the // controller keeps it; a module absent has announced nothing yet. Nil where none was read. Announced map[string]announce.States + // LeaseCut composes the list of the lease write (novox/hq ADR 0582 clause 3, issue 774): the controller may neither + // write the lease's key nor update, purge or delete its bucket, and the grants part may write the key `holder`. + // Set by the grants part from the write's second step until a hand-back composes without it (clause 5); the + // controller's composer never sets it. + LeaseCut bool } // Users is every user the composed file should contain, in the order it will be written. @@ -157,7 +162,7 @@ type Records struct { // correct. func Users(r Records) ([]Principal, error) { out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r), - AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}} + AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes), LeaseCut: r.LeaseCut}} served := servedReadsOf(r) logs := DeclaredLogs(r) @@ -246,6 +251,7 @@ func Users(r Records) ([]Principal, error) { } if d.Module == GrantsModule { out[len(out)-1].CreatesStreams = declaredStreamsOf(r) + out[len(out)-1].LeaseCut = r.LeaseCut grants = len(out) - 1 } // **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index a6541d2..39d78f7 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -43,7 +43,7 @@ func createsDeclared(p Principal, pattern string) bool { if isController(p, pattern) { return true } - if removesAssignmentsStream(p, pattern) { + if removesAssignmentsStream(p, pattern) || setsLeaseExpiry(p, pattern) { return true } name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.") @@ -158,7 +158,8 @@ var WritersTable = []WriterRow{ // subject is this machine's engine's alone. Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine}, {State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket, - Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController}, + Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: writesTheLease, + Shared: "from the lease write's cut the controller writes it no more and the grants part writes its key holder alone (novox/hq ADR 0582 clause 3)"}, // The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder. {State: "the merges mesh-delivery announced", Writer: "controller (lease holder)", KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration", @@ -188,7 +189,7 @@ var WritersTable = []WriterRow{ Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>", "$JS.API.CONSUMER.DURABLE.CREATE.>"}, Writes: createsDeclared, - Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"}, + Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33), and updates the lease's bucket for its expiry alone (novox/hq ADR 0582 clause 3)"}, {State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state", Others: "the controller asks", Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"}, -- 2.54.0 From feadebd3312c6d9971e5e191e6246a98c8a37c51 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:15:20 +0100 Subject: [PATCH 3/9] Let the lease key name its ask and time, and the user-list key say the lease is handed ADR 0582 clause 3 writes the key holder "with the why, the ask and the time": LeaseHolder gains ask and at, which LeaseHandedAway ignores (it reads handed-to alone). The grants part's user-list key gains lease-handed, so the bus node's engine follows the grants part's list alone once the lease is handed (ADR 0571 clause 8) and never falls back to the controller's; a key written before reads false. Tests: a row in the lease table of TestTheCopiedComposerComposesAsTheControllers; the old key (shaped as EncodeGrantsUserList writes it today) and the flag's round trip added to the existing user-list case in delivery_test.go; no new test function. (issue 774, 774/S3) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/accounts.go | 4 ++++ go/buscompose/buscompose_test.go | 3 +++ go/delivery/delivery_test.go | 15 ++++++++++++++- go/delivery/userlist.go | 4 ++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/go/buscompose/accounts.go b/go/buscompose/accounts.go index 2f64e70..3a40eb5 100644 --- a/go/buscompose/accounts.go +++ b/go/buscompose/accounts.go @@ -219,6 +219,10 @@ type LeaseHolder struct { // HandedBy is the credential that wrote it (the operator's), and Why its reason. HandedBy string `json:"handed-by,omitempty"` Why string `json:"why,omitempty"` + // Ask is the operator's ask whose warrant handed it (novox/hq ADR 0582 clause 3: "with the why, the ask and the + // time"), and At when it was written. Neither turns the lease: LeaseHandedAway reads HandedTo alone. + Ask string `json:"ask,omitempty"` + At time.Time `json:"at,omitzero"` } // LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 6c2f676..112ee09 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -561,6 +561,9 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { }{ {"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false}, {"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true}, + // ADR 0582 clause 3 (774/S3): the write names the ask and the time, which turn nothing. + {"handed to mesh-grants with its ask and time", `{"handed-to":"mesh-grants","handed-by":"person.jochen","why":"w",` + + `"ask":"lease-write-1","at":"2026-11-02T10:00:00Z"}`, true, nil, true}, {"expired or deleted", ``, false, nil, false}, {"unreadable", ``, false, errors.New("refused"), false}, {"not a holder", `[1]`, true, nil, false}, diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index 0ddd2a3..8bdc8bd 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -242,9 +242,22 @@ func TestRoundTrip(t *testing.T) { t.Fatal(err) } back, err := DecodeGrantsUserList(raw) - if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants { + if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants || back.LeaseHanded { t.Fatalf("decoded %+v, %v", back, err) } + // ADR 0582 clause 3 (issue 774, 774/S3): a key written before the flag existed reads not handed; one written with + // it reads handed. + if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` + + `"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z"}`)); err != nil || old.LeaseHanded { + t.Errorf("a key from before the lease write decodes %+v, %v", old, err) + } + g.LeaseHanded = true + if raw, err := EncodeGrantsUserList(g); err != nil || !bytes.Contains(raw, []byte(`"lease-handed":true`)) { + t.Errorf("the handed flag is written as %s, %v", raw, err) + } else if back, err := DecodeGrantsUserList(raw); err != nil || !back.LeaseHanded { + t.Errorf("the handed flag reads back %+v, %v", back, err) + } + g.LeaseHanded = false for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} { if _, err := EncodeGrantsUserList(bad); err == nil { diff --git a/go/delivery/userlist.go b/go/delivery/userlist.go index 0860350..ea27e77 100644 --- a/go/delivery/userlist.go +++ b/go/delivery/userlist.go @@ -65,6 +65,10 @@ type GrantsUserList struct { // LeftOut are the users the list leaves out for want of a hash in the vault. LeftOut []string `json:"left_out"` At time.Time `json:"at"` + // LeaseHanded says the controller's lease is handed to the grants part (novox/hq ADR 0582 clause 3, ADR 0571 clause + // 8): from then on the bus node follows this list alone and never falls back to the controller's. A key written + // before it reads false. + LeaseHanded bool `json:"lease-handed,omitempty"` } // EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow. -- 2.54.0 From d7a008bee5da7fa98a40ab284bd0c348380ec538 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:17:13 +0100 Subject: [PATCH 4/9] Move the controller-dependence judge into mesh-sdk, so three repositories run the same lease-write check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0571 §13 names a test in mesh-catalog, mesh-delivery and mesh-tools that refuses a manifest depending on the controller beyond the lease and the entries kept. The judge lived in mesh-grants (leasecheck.go); it moves here whole (the kinds, the phases, KeepList, EndedBy, Dependencies, Judge) with ManifestRefusals(manifests, phase) for those tests. Kept is renamed KeptDependency (catalogue already has a Kept), and a gap is Unread. mesh-grants re-exports it in its own commit; the keep list is the one at mesh-delivery 4a84370. Test: TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController, moved from mesh-grants' leasecheck_test.go (its manifest rows and the keep-list test, which mesh-grants deletes), with the conditions row of 774/S4/AC1 added; not a new test. (issue 774, 774/S4) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/controller_dependence.go | 261 +++++++++++++++++++++ go/catalogue/controller_dependence_test.go | 97 ++++++++ 2 files changed, 358 insertions(+) create mode 100644 go/catalogue/controller_dependence.go create mode 100644 go/catalogue/controller_dependence_test.go diff --git a/go/catalogue/controller_dependence.go b/go/catalogue/controller_dependence.go new file mode 100644 index 0000000..6bd22fa --- /dev/null +++ b/go/catalogue/controller_dependence.go @@ -0,0 +1,261 @@ +package catalogue + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// **What still depends on the controller, and the lease write's check** (novox/hq ADR 0571 §13, ADR 0582 clause 2, +// issue 774): every declaration of a manifest other than the controller's on the controller (a read of one of its +// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, a use of its seat), judged +// against the named, temporary list of what another accepted record keeps until the lease write. The grants part's +// grants_lease_check runs it over every manifest the indexer holds; ManifestRefusals runs it over a repository's +// manifests, in the test ADR 0571 §13 names in mesh-catalog, mesh-delivery and mesh-tools. A read of the lease key is no +// dependency: it names the lease's holder, whoever that is, and parts read it to learn that the write happened. + +// ControllerLease is the controller's lease as a reader names it (`.`): no dependency. +const ControllerLease = ControllerSeatName + ".lease" + +// The kinds of a dependency on the controller, as a manifest declares them. +const ( + DependsRead = "read" + DependsInvoke = "invoke" + DependsConsume = "consume" + DependsUses = "uses" + DependsWaits = "waits" +) + +// The phases of the lease write's check (ADR 0571 §13): before the lease write's own delivery the whole named list is +// allowed; that delivery drops every entry but the three kept to the write itself, and its check runs with those alone. +const ( + PhaseBefore = "before-delivery" + PhaseWrite = "write" +) + +// Dependency is one declaration of a manifest other than the controller's on the controller: a read of one of its +// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, or a send to its seat. +type Dependency struct { + Module string `json:"module"` + // By is the account declaring it when it is not the module's own: `process ` or `tools `. + By string `json:"by,omitempty"` + Kind string `json:"kind"` + What string `json:"what"` + // Kept is the record that keeps it until the lease write, from the named list; empty when none does. + Kept string `json:"kept,omitempty"` + // ToTheWrite says it is one of the three kept to the write itself, which the lease write's own delivery keeps. + ToTheWrite bool `json:"to_the_write,omitempty"` +} + +// KeptDependency is one entry of the named, temporary list ADR 0571 §13 allows: a read, invoke or consume another +// accepted record keeps until the lease write, with that record. ToTheWrite marks the three kept to the write itself. +type KeptDependency struct { + Module, Kind, What, Record string + ToTheWrite bool +} + +// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. The +// third entry kept to the write, the bus node agent's following of the controller's user list (ADR 0571 §8, ADR 0547 +// §8), is no manifest's declaration but the node-engine's own, so no manifest matches it; it stays named here so the +// list reads as the record has it. +var KeepList = []KeptDependency{ + // Kept to the write itself (ADR 0571 §4, §8, §13). + {Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true}, + {Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true}, + {Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true}, + // Kept until the lease write's own delivery. + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.placement-export", Record: "ADR 0567 §7"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.assign", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.unassign", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.pin", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.unpin", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsRead, What: "mesh-controller.assignments", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsRead, What: "mesh-controller.composed", Record: "ADR 0571 §8"}, + {Module: "mesh-grants", Kind: DependsRead, What: "mesh-controller.assignments", Record: "ADR 0549 §5"}, + {Module: "mesh-grants", Kind: DependsConsume, What: "mesh-controller.condition-raised", Record: "ADR 0564 §9"}, + {Module: "mesh-grants", Kind: DependsConsume, What: "mesh-controller.condition-cleared", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-raised", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-cleared", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-changed", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsInvoke, What: "seat:mesh-controller.conditions", Record: "ADR 0564 §9"}, + {Module: "mesh-watcher", Kind: DependsConsume, What: "mesh-controller.doctor-heartbeat", Record: "ADR 0564 §9"}, + {Module: "mesh-conditions", Kind: DependsRead, What: "mesh-controller.conditions", Record: "ADR 0564 §9"}, + {Module: "mesh-config", Kind: DependsRead, What: "mesh-controller.declared-config", Record: "ADR 0565 §4"}, + {Module: "mesh-config", Kind: DependsRead, What: "mesh-controller.predefined", Record: "ADR 0565 §4"}, + {Module: "mesh-healers", Kind: DependsRead, What: "mesh-controller.hand-acts", Record: "ADR 0565 §11"}, + {Module: "mesh-hand-acts", Kind: DependsRead, What: "mesh-controller.hand-acts", Record: "ADR 0565 §11"}, + // pacman's fallback for a node whose engine has not written its own packages yet. + {Module: "pacman", Kind: DependsRead, What: "mesh-controller.declared-packages", Record: "ADR 0565 §6"}, +} + +// EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved. +// The controller's `grants` is one: ADR 0571 §9 has no manifest read it from L7's delivery, which changes ADR 0567 §10 +// and ADR 0547 §5's shadow compare, so neither the assigner's nor the grants part's read of it is kept. +var EndedBy = map[string]string{ + DependsRead + " mesh-controller.grants": "ADR 0571 §9: no manifest reads the controller's grants; the grants part's key is the one read", + DependsInvoke + " seat:mesh-controller.hand-act": "ADR 0565 §11: every recorder calls mesh-hand-acts", + DependsInvoke + " seat:mesh-controller.warranted": "ADR 0565 §11: every recorder calls mesh-hand-acts", + DependsInvoke + " seat:mesh-controller.root-free": "ADR 0571 §11: each machine's engine answers root-free (seat:node-engine.root-free@machine)", + DependsInvoke + " seat:mesh-controller.held": "ADR 0571 §2: held is the seat mesh-artifact-sweep's", +} + +// keptFor is the list's entry for one dependency, or nil. +func keptFor(d Dependency) *KeptDependency { + for i, k := range KeepList { + if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What { + return &KeepList[i] + } + } + return nil +} + +// onController says whether a name `.` is the controller's. +func onController(name string) bool { return strings.HasPrefix(name, ControllerSeatName+".") } + +// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none: +// it is the console's grant to every tool, judged by no record as a dependency. +func invokesController(invoke string) bool { + if seat, _, ok := SeatInvoke(invoke); ok { + return seat == ControllerSeatName + } + return onController(invoke) +} + +// Dependencies are every declaration on the controller in the manifests given, by module, each with the list's entry +// that keeps it. The controller's own manifest is not judged, nor a read of its lease key: it names the lease's holder, +// whoever that is, and parts read it to learn that the write happened (ADR 0571 §13). +func Dependencies(manifests map[string]Manifest) []Dependency { + var out []Dependency + add := func(module, by, kind, what string) { + d := Dependency{Module: module, By: by, Kind: kind, What: what} + if k := keptFor(d); k != nil { + d.Kept, d.ToTheWrite = k.Record, k.ToTheWrite + } + if !slices.Contains(out, d) { + out = append(out, d) + } + } + for name, m := range manifests { + if name == ControllerSeatName || m.Module == ControllerSeatName { + continue + } + for _, r := range m.Reads { + if onController(r) && r != ControllerLease { + add(name, "", DependsRead, r) + } + } + for _, i := range m.Invokes { + if invokesController(i) { + add(name, "", DependsInvoke, i) + } + } + for _, c := range m.Consumes { + if onController(c) { + add(name, "", DependsConsume, c) + } + } + for _, w := range m.Waits { + if onController(w) { + add(name, "", DependsWaits, w) + } + } + for _, u := range m.Uses { + if u == ControllerSeatName { + add(name, "", DependsUses, u) + } + } + for _, a := range m.BusAccounts() { + by := "process " + a.ID + if a.Tools { + by = "tools " + a.ID + } + for _, i := range a.Bus.Invokes { + if invokesController(i) { + add(name, by, DependsInvoke, i) + } + } + } + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Module != b.Module { + return a.Module < b.Module + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + if a.What != b.What { + return a.What < b.What + } + return a.By < b.By + }) + return out +} + +// Verdict is the lease write's check in one phase. +type Verdict struct { + Phase string `json:"phase"` + Passes bool `json:"passes"` + Reasons []string `json:"reasons"` +} + +// Coverage is how much of the mesh's manifests the dependencies were derived from: the manifests read, the modules the +// indexer holds (-1 when its keys could not be listed, which Unread then names), and each manifest that could not be +// read. +type Coverage struct { + Read, Indexed int + Unread []Unread +} + +// Unread is one manifest, or one listing of them, that could not be read, and why. +type Unread struct { + Input string `json:"input"` + Why string `json:"why"` +} + +// Judge runs the lease write's check over the dependencies (ADR 0571 §13) in one phase: before the lease write's own +// delivery, every dependency must be on the named list; at the write, only the three kept to the write itself may stay. +// It passes only over every module the indexer holds: no manifest read, fewer read than the indexer holds, or one that +// could not be read fails it, since what was not read could hide a dependency. A lease already handed away leaves +// nothing to write. +func Judge(deps []Dependency, phase string, cov Coverage, handedAway bool, leaseSays string) Verdict { + v := Verdict{Phase: phase, Reasons: []string{}} + if handedAway { + v.Reasons = append(v.Reasons, "there is nothing to write: "+leaseSays) + } + switch { + case cov.Read == 0: + v.Reasons = append(v.Reasons, fmt.Sprintf("no manifest was read from the indexer (it holds %d modules): with nothing read, nothing on the controller can be judged absent", max(cov.Indexed, 0))) + case cov.Indexed >= 0 && cov.Read < cov.Indexed: + v.Reasons = append(v.Reasons, fmt.Sprintf("only %d of the %d modules the indexer holds had their manifest read, so what the others declare on the controller is not known", cov.Read, cov.Indexed)) + } + for _, d := range deps { + who := d.Module + if d.By != "" { + who += " (" + d.By + ")" + } + switch { + case d.Kept == "": + why := fmt.Sprintf("%s declares %s %s, which no record keeps until the lease write (ADR 0571 §13)", who, d.Kind, d.What) + if ended := EndedBy[d.Kind+" "+d.What]; ended != "" { + why += "; ended by " + ended + } + v.Reasons = append(v.Reasons, why) + case phase == PhaseWrite && !d.ToTheWrite: + v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, kept by %s only until the lease write's own delivery, which drops it", who, d.Kind, d.What, d.Kept)) + } + } + for _, g := range cov.Unread { + v.Reasons = append(v.Reasons, fmt.Sprintf("%s could not be read (%s), so what it declares on the controller is not known", g.Input, g.Why)) + } + v.Passes = len(v.Reasons) == 0 + return v +} + +// ManifestRefusals is the lease write's check over a repository's manifests, by module name, in one phase (PhaseBefore +// or PhaseWrite): each reason the check gives, none when it passes. Every manifest given counts as read: the caller reads +// them from its own tree, and none given fails it. +func ManifestRefusals(manifests map[string]Manifest, phase string) []string { + return Judge(Dependencies(manifests), phase, Coverage{Read: len(manifests), Indexed: -1}, false, "").Reasons +} diff --git a/go/catalogue/controller_dependence_test.go b/go/catalogue/controller_dependence_test.go new file mode 100644 index 0000000..09f7604 --- /dev/null +++ b/go/catalogue/controller_dependence_test.go @@ -0,0 +1,97 @@ +package catalogue + +import ( + "slices" + "strings" + "testing" +) + +// Issue 774 (ADR 0571 §13, ADR 0582 clause 2): the lease write's check judges every manifest's dependency on the +// controller against the named list, at the write (the three kept to it alone) and before its delivery. Moved with the +// judge from mesh-grants' leasecheck_test.go (TestTheLeaseCheckJudgesEveryDependencyOnTheController's manifest rows and +// TestTheKeepListNamesItsRecordsAndThreeToTheWrite), whose manifests were mesh-grants' fixtures; the conditions row is +// mesh-conditions' manifest at mesh-catalog ce3bedd5 (module.json:31-32). +func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) { + toTheWrite := 0 + for _, k := range KeepList { + if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" { + t.Errorf("entry %+v names no record, module or declaration", k) + } + if k.ToTheWrite { + toTheWrite++ + } + } + if toTheWrite != 3 { + t.Errorf("%d entries kept to the write itself, ADR 0571 §13 names three", toTheWrite) + } + + type want struct { + deps []string // " ", kept by the record or "unkept" + write string // a reason the check at the write names, or "" when it passes + before string // a reason the check before the delivery names, or "" when it passes + } + for _, row := range []struct { + name string + manifests map[string]string + want want + }{ + // 774/S4/AC2. + {"a read of the lease alone is no dependency, and the controller's own manifest is not judged", + map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`, + "mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`}, + want{}}, + // 774/S4/AC1. + {"the keeper's read of the controller's conditions is kept before the write's delivery and refused at the write", + map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`}, + want{deps: []string{"mesh-conditions read mesh-controller.conditions kept by ADR 0564 §9"}, + write: "mesh-conditions declares read mesh-controller.conditions, kept by ADR 0564 §9 only until the lease write's own delivery"}}, + {"the builder's consume of artifacts-gone is kept to the write itself", + map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`}, + want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}}, + {"a read no record keeps fails both phases, named", + map[string]string{"mod-u": `{"module":"mod-u","version":"1","reads":["mesh-controller.declared-packages"]}`}, + want{deps: []string{"mod-u read mesh-controller.declared-packages unkept"}, + write: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write", + before: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write"}}, + {"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none", + map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` + + `"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`, + "node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`}, + want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept", + "mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"}, + write: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps until the lease write (ADR 0571 §13); ended by ADR 0571 §11", + before: "node-tools declares waits mesh-controller.checked"}}, + {"a process's own invoke of the controller is its module's dependency, named by the process", + map[string]string{"mod-p": `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},{"id":"worker","type":"process","run":["./w"],"user":"mod-p",` + + `"bus":{"invokes":["seat:mesh-controller.held"]}}]}`}, + want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"}, + write: "mod-p (process worker) declares invoke seat:mesh-controller.held", before: "mod-p (process worker)"}}, + {"no manifest fails both phases", map[string]string{}, want{write: "no manifest was read", before: "no manifest was read"}}, + } { + manifests := map[string]Manifest{} + for name, raw := range row.manifests { + m, err := Decode([]byte(raw)) + if err != nil { + t.Fatalf("%s: %s: %v", row.name, name, err) + } + manifests[name] = m + } + var deps []string + for _, d := range Dependencies(manifests) { + kept := "unkept" + if d.Kept != "" { + kept = "kept by " + d.Kept + } + deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept) + } + if !slices.Equal(deps, row.want.deps) { + t.Errorf("%s: dependencies %q, want %q", row.name, deps, row.want.deps) + } + for phase, reason := range map[string]string{PhaseWrite: row.want.write, PhaseBefore: row.want.before} { + got := ManifestRefusals(manifests, phase) + if (reason == "") != (len(got) == 0) || !slices.ContainsFunc(got, func(r string) bool { return strings.Contains(r, reason) }) && reason != "" { + t.Errorf("%s: at %s refused %q, want %q", row.name, phase, got, reason) + } + } + } +} -- 2.54.0 From a4ed4e6742c22e5aef1c0a026c07f3f186a8cf92 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:19:15 +0100 Subject: [PATCH 5/9] Stop hearing mesh.assignment: a process follows its membership from the grants part's state alone ADR 0571 clause 10: the grants part publishes memberships on mesh.assignment until the lease write and stops then. FollowMembership now reads and watches its own key of the state memberships and nothing else, and refuses to follow, naming the state, when it cannot read it (there is nothing else to follow, and a silent follow of nothing is the fault). A module's principal and the runtime lose their subscribe on mesh.assignment; the grants part's publish grant stays until the write (S21 stops the publishing). Tests: TestFollowMembershipHearsWhatIsIssuedToTheAccount (integration) edited: no state is a refusal, a membership put in the state is applied, one published on the subject is not; run locally against its embedded bus. Rows added to TestTheCopiedComposerComposesAsTheControllers: nobody subscribes mesh.assignment, and the controller's golden grant of it is the one row the copy now lacks. No new test function. (issue 774, 774/S5) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/buscompose_test.go | 18 ++++++++-- go/buscompose/nats.go | 16 ++++----- go/process/follow.go | 22 ++++-------- go/process/follow_integration_test.go | 50 ++++++++++++++++++++++----- 4 files changed, 72 insertions(+), 34 deletions(-) diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 112ee09..1075f8a 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -161,8 +161,11 @@ func grantsAsTheControllers(t *testing.T, got, want string) { intended := beyondTheControllers(user) for g := range was { // A runtime's direct get of its membership from the assignments stream went with the stream (research 072 - // row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7). - if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") { + // row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7). And it no longer hears + // the subject, where the grants part stops publishing at the lease write (ADR 0571 clause 10, issue 774). + node := strings.SplitN(user, ".", 2)[0] + if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+node+".") && + !strings.HasPrefix(g, "mesh.assignment."+node+".") { t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g) } } @@ -880,6 +883,17 @@ func healthChecks(t *testing.T) { if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") { t.Errorf("%s writes the assigner's policies", u.Username()) } + // ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from + // the grants part's state. + if MaySubscribe(perms, "mesh.assignment.a.restic") { + t.Errorf("%s hears mesh.assignment", u.Username()) + } + } + // Nor the runtime carrying the node's modules, which reads each one's membership from the state. + if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule, + Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") || + !MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") { + t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err) } // 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as // long as the log (ADR 0297). diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index c37be8f..8b56c96 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -1075,10 +1075,10 @@ func PermissionsFor(p Principal) (Permissions, error) { } } sub = append(sub, announcing(announced...)...) - // Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's. - sub = append(sub, MembershipSubject(p.Node, p.Module)) - // And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it, - // since the assignments stream's direct get went with the stream (research 072 row 33). + // Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause + // 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33), + // and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause + // 10, issue 774). pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...) // 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same @@ -1240,10 +1240,10 @@ func PermissionsFor(p Principal) (Permissions, error) { } } } - // Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the - // grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token - // that varies is the module, so the pattern is the machine's own assignments. - sub = append(sub, "mesh.assignment."+p.Node+".*") + // Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the + // grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants + // part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the + // one token that varies is the module, so the pattern is the machine's own assignments. pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...) // And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's // name and no other's, on core NATS like the host's. diff --git a/go/process/follow.go b/go/process/follow.go index 9c1fc33..73fc02c 100644 --- a/go/process/follow.go +++ b/go/process/follow.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-sdk/go/membership" @@ -17,10 +16,10 @@ import ( // not a membership is handed to onBad (nil to ignore it) and skipped. // // It reads the membership from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7), -// watched so the value there now comes first and each change after it; and it hears the subject the grants part -// publishes on until the lease write. The assignments stream is no longer read: the bus module's tools delete it -// at the lease write (research 072 row 33). A state not there yet, or not readable, is told to onBad and the -// subject is followed alone. The returned error is a refusal to follow at all. +// watched so the value there now comes first and each change after it. Nothing else: the subject the grants part +// published on is no longer heard (ADR 0571 clause 10, issue 774), since it stops publishing at the lease write, and +// the assignments stream is no longer read (research 072 row 33). A state not there or not readable is a refusal to +// follow, returned: there is nothing else to follow. func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(error)) error { if c.account.Node == "" || c.account.Module == "" { return fmt.Errorf("the account names no node or module, so it has no membership") @@ -38,27 +37,20 @@ func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(er } apply(issued) } - subject := membership.Subject(c.account.Node, c.account.Module) - if _, err := c.nc.Subscribe(subject, func(m *nats.Msg) { take(m.Data) }); err != nil { - return fmt.Errorf("cannot follow %s: %w", subject, err) - } key := membership.GrantsKey(c.account.Node, c.account.Module) js, err := jetstream.New(c.nc) if err != nil { - bad(fmt.Errorf("cannot read %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject)) - return nil + return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() kv, err := js.KeyValue(ctx, membership.GrantsBucket) if err != nil { - bad(fmt.Errorf("cannot read %s of %s yet (%v); following %s alone", key, membership.GrantsBucket, err, subject)) - return nil + return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err) } w, err := kv.Watch(context.Background(), key) if err != nil { - bad(fmt.Errorf("cannot watch %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject)) - return nil + return fmt.Errorf("cannot watch %s of %s: %w", key, membership.GrantsBucket, err) } go func() { for e := range w.Updates() { diff --git a/go/process/follow_integration_test.go b/go/process/follow_integration_test.go index cdb420b..3041b4e 100644 --- a/go/process/follow_integration_test.go +++ b/go/process/follow_integration_test.go @@ -3,14 +3,20 @@ package process import ( + "context" + "strings" "testing" "time" + "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/membership" ) -// A process connected pinned to a test bus hears the membership published for its node and module, and -// ignores what is not one (novox/hq issue 1633, AC2). +// A process connected pinned to a test bus follows the membership the grants part writes for its node and module in +// its state memberships, and reports what is not one (novox/hq issue 1633, AC2); it no longer hears mesh.assignment, +// where the grants part stops publishing at the lease write (ADR 0571 clause 10; issue 774, 774/S5/AC1). Without the +// state it refuses to follow, naming it. func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) { url, fingerprint := selfSignedServer(t) account := mailSenderAccount(t, url, fingerprint) @@ -20,26 +26,52 @@ func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) { } defer conn.Close() - got := make(chan membership.Membership, 1) + got := make(chan membership.Membership, 4) bad := make(chan error, 4) - if err := conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e }); err != nil { + follow := func() error { + return conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e }) + } + if err := follow(); err == nil || !strings.Contains(err.Error(), membership.GrantsBucket) { + t.Fatalf("followed with no state memberships: %v", err) + } + js, err := jetstream.New(conn.nc) + if err != nil { t.Fatal(err) } - if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte("not json")); err != nil { + ctx := context.Background() + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: membership.GrantsBucket}) + if err != nil { t.Fatal(err) } - if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.1"]}`)); err != nil { + key := membership.GrantsKey("novox", "mesh-mail") + if _, err := kv.Put(ctx, key, []byte("not json")); err != nil { + t.Fatal(err) + } + if err := follow(); err != nil { + t.Fatal(err) + } + // Published on the subject, as the grants part did until the write: not heard. + if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.9"]}`)); err != nil { + t.Fatal(err) + } + if _, err := kv.Put(ctx, key, membership.EncodeGrantsMembership(membership.Membership{Node: "novox", Module: "mesh-mail", + Mesh: []string{"10.0.0.1"}})); err != nil { t.Fatal(err) } select { case m := <-got: - if m.Module != "mesh-mail" || len(m.Mesh) != 1 { + if m.Module != "mesh-mail" || len(m.Mesh) != 1 || m.Mesh[0] != "10.0.0.1" { t.Fatalf("membership: %+v", m) } case <-time.After(5 * time.Second): - t.Fatal("no membership heard") + t.Fatal("no membership followed") + } + select { + case m := <-got: + t.Errorf("a second membership was applied, from the subject: %+v", m) + case <-time.After(300 * time.Millisecond): } if len(bad) == 0 { - t.Error("the body that is not a membership was not reported") + t.Error("the value that is not a membership was not reported") } } -- 2.54.0 From 09a5eb5783947bdb117abb8b1176e601ff0a6bb0 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:32:13 +0100 Subject: [PATCH 6/9] Delete PlacementProposalChange.BeforeLease: the assigner no longer keeps the controller's assignments in step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It said which placement changes waited for the lease write while the assigner called the controller's assign, unassign, pin and unpin (ADR 0567 §8). Since the assigner calls no controller verb (774/S25), nothing reads it. Test: its table in TestPlacement goes with it. (issue 774, 774/S25) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/placement_proposal.go | 25 ------------------------- go/delivery/placement_test.go | 13 ------------- 2 files changed, 38 deletions(-) diff --git a/go/delivery/placement_proposal.go b/go/delivery/placement_proposal.go index 6548705..66f6cb1 100644 --- a/go/delivery/placement_proposal.go +++ b/go/delivery/placement_proposal.go @@ -109,31 +109,6 @@ type PlacementProposalChange struct { Whole *Placement `json:"whole,omitempty"` } -// BeforeLease is why the change cannot be proposed before the lease write, or "" (ADR 0567 §8): until then the holder -// keeps the controller's assignments in step through its verbs assign, unassign, pin and unpin, so only those are -// proposable — a module placed or taken off with no host port or binding, a pin set or removed, a module's config values -// and the seed. A host port, a binding, a holding, a decided field and a config layer's places or accesses wait for the -// lease write, which the refusal names. -func (c PlacementProposalChange) BeforeLease() string { - const lease = "which the controller's verbs cannot take from a caller, so it waits for the lease write (ADR 0567 §8)" - switch c.Op { - case OpPlace: - if len(c.Ports)+len(c.Bindings) > 0 { - return fmt.Sprintf("the place of %s on %s names host ports or bindings, %s", c.Module, c.Node, lease) - } - case OpTakeOff, OpPin, OpUnpin, OpSeed: - case OpConfig: - // A module's config values are the state's alone (§13); its layers' places and accesses are the controller's - // until the lease write. - if c.MeshWide != nil || c.Layer != nil { - return fmt.Sprintf("the config of %s on %s sets a layer's places or accesses, %s", c.Module, c.Node, lease) - } - default: - return fmt.Sprintf("a change %s of %s is no assignment or pin, %s", c.Op, c.Node, lease) - } - return "" -} - func (c PlacementProposalChange) check() error { if err := checkName("node", c.Node); err != nil { return err diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index ec6e28d..59d8ae6 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -651,19 +651,6 @@ func TestPlacement(t *testing.T) { t.Errorf("%s: %v, want a finding naming %q", name, f, c.want) } } - for change, waits := range map[*PlacementProposalChange]bool{ - {Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false, - {Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false, - {Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true, - {Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true, - {Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true, - {Op: OpConfig, Node: "g14", Values: map[string]any{"puid": 1}}: false, {Op: OpConfig, Node: "g14", MeshValues: map[string]any{"a": 1}}: false, - {Op: OpConfig, Node: "g14", Layer: map[string]any{"places": 1}}: true, {Op: OpConfig, Node: "g14", MeshWide: map[string]any{"accesses": 1}}: true, - } { - if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) { - t.Errorf("%+v before the lease write: %q", *change, got) - } - } const id = "p-20261028t183000-1a2b3c" if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil { -- 2.54.0 From 3ed41f02d57d11b7aff193ef41c6f05d8a44c15b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:33:34 +0100 Subject: [PATCH 7/9] The keep list holds the three kept to the write, since this delivery drops every other read of the controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0571 §13: the lease write's own delivery drops every entry but the builder's artifacts-gone, the artifact sweep's collect and the engine's following of the controller's user list. Every module of this group stops its read, invoke or consume of the controller (pacman, the keeper, mesh-config, the hand-act log's readers, the watcher, messenger, the assigner, mesh-grants), so the list shrinks to the three and a read put back is refused in either phase. Supersedes the keep of pacman's read at mesh-delivery 4a84370. Tests: rows of TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController changed (the keeper's read put back is refused; the sweep's collect passes); no new test function. (issue 774, 774/S30) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/controller_dependence.go | 33 ++++------------------ go/catalogue/controller_dependence_test.go | 18 ++++++++---- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/go/catalogue/controller_dependence.go b/go/catalogue/controller_dependence.go index 6bd22fa..298b8ba 100644 --- a/go/catalogue/controller_dependence.go +++ b/go/catalogue/controller_dependence.go @@ -55,38 +55,15 @@ type KeptDependency struct { ToTheWrite bool } -// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. The -// third entry kept to the write, the bus node agent's following of the controller's user list (ADR 0571 §8, ADR 0547 -// §8), is no manifest's declaration but the node-engine's own, so no manifest matches it; it stays named here so the -// list reads as the record has it. +// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. It +// holds the three kept to the write itself and nothing else: the lease write's own delivery dropped every other entry +// (ADR 0571 §13; issue 774), so its check runs with these alone in either phase. The third, the bus node agent's +// following of the controller's user list (ADR 0571 §8, ADR 0547 §8), is no manifest's declaration but the +// node-engine's own, so no manifest matches it; it stays named here so the list reads as the record has it. var KeepList = []KeptDependency{ - // Kept to the write itself (ADR 0571 §4, §8, §13). {Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true}, {Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true}, {Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true}, - // Kept until the lease write's own delivery. - {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.placement-export", Record: "ADR 0567 §7"}, - {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.assign", Record: "ADR 0567 §8"}, - {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.unassign", Record: "ADR 0567 §8"}, - {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.pin", Record: "ADR 0567 §8"}, - {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.unpin", Record: "ADR 0567 §8"}, - {Module: "mesh-delivery-assigner", Kind: DependsRead, What: "mesh-controller.assignments", Record: "ADR 0567 §8"}, - {Module: "mesh-delivery-assigner", Kind: DependsRead, What: "mesh-controller.composed", Record: "ADR 0571 §8"}, - {Module: "mesh-grants", Kind: DependsRead, What: "mesh-controller.assignments", Record: "ADR 0549 §5"}, - {Module: "mesh-grants", Kind: DependsConsume, What: "mesh-controller.condition-raised", Record: "ADR 0564 §9"}, - {Module: "mesh-grants", Kind: DependsConsume, What: "mesh-controller.condition-cleared", Record: "ADR 0564 §9"}, - {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-raised", Record: "ADR 0564 §9"}, - {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-cleared", Record: "ADR 0564 §9"}, - {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-changed", Record: "ADR 0564 §9"}, - {Module: "messenger", Kind: DependsInvoke, What: "seat:mesh-controller.conditions", Record: "ADR 0564 §9"}, - {Module: "mesh-watcher", Kind: DependsConsume, What: "mesh-controller.doctor-heartbeat", Record: "ADR 0564 §9"}, - {Module: "mesh-conditions", Kind: DependsRead, What: "mesh-controller.conditions", Record: "ADR 0564 §9"}, - {Module: "mesh-config", Kind: DependsRead, What: "mesh-controller.declared-config", Record: "ADR 0565 §4"}, - {Module: "mesh-config", Kind: DependsRead, What: "mesh-controller.predefined", Record: "ADR 0565 §4"}, - {Module: "mesh-healers", Kind: DependsRead, What: "mesh-controller.hand-acts", Record: "ADR 0565 §11"}, - {Module: "mesh-hand-acts", Kind: DependsRead, What: "mesh-controller.hand-acts", Record: "ADR 0565 §11"}, - // pacman's fallback for a node whose engine has not written its own packages yet. - {Module: "pacman", Kind: DependsRead, What: "mesh-controller.declared-packages", Record: "ADR 0565 §6"}, } // EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved. diff --git a/go/catalogue/controller_dependence_test.go b/go/catalogue/controller_dependence_test.go index 09f7604..410e27f 100644 --- a/go/catalogue/controller_dependence_test.go +++ b/go/catalogue/controller_dependence_test.go @@ -21,8 +21,8 @@ func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) { toTheWrite++ } } - if toTheWrite != 3 { - t.Errorf("%d entries kept to the write itself, ADR 0571 §13 names three", toTheWrite) + if toTheWrite != 3 || len(KeepList) != 3 { + t.Errorf("%d entries, %d kept to the write itself: ADR 0571 §13 keeps the three alone from the write's delivery", len(KeepList), toTheWrite) } type want struct { @@ -40,11 +40,17 @@ func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) { map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`, "mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`}, want{}}, - // 774/S4/AC1. - {"the keeper's read of the controller's conditions is kept before the write's delivery and refused at the write", + // 774/S4/AC1, 774/S30/AC1: the keeper's read of the controller's conditions put back is refused in either phase, + // since the lease write's delivery dropped its entry (it was kept by ADR 0564 §9 before). + {"the keeper's read of the controller's conditions, put back, is refused", map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`}, - want{deps: []string{"mesh-conditions read mesh-controller.conditions kept by ADR 0564 §9"}, - write: "mesh-conditions declares read mesh-controller.conditions, kept by ADR 0564 §9 only until the lease write's own delivery"}}, + want{deps: []string{"mesh-conditions read mesh-controller.conditions unkept"}, + write: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write", + before: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write"}}, + // 774/S30/AC1: the three kept to the write pass in either phase. + {"the artifact sweep's collect is kept to the write itself", + map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","version":"1","invokes":["seat:mesh-controller.collect"]}`}, + want{deps: []string{"artifact-store-tools invoke seat:mesh-controller.collect kept by ADR 0571 §4, §13"}}}, {"the builder's consume of artifacts-gone is kept to the write itself", map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`}, want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}}, -- 2.54.0 From bbfda29b49d89eb0e8c5cee0948e324c6d0e1b18 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:37:04 +0100 Subject: [PATCH 8/9] The watchman hears the lease write's events and the stream ASSIGNMENTS deleted ADR 0582 clauses 4 and 6 have mesh-grants say lease-altered and removal-failed "as a finding the watchman raises". WatchmanHears, the one list its grant and its subscriptions come from, gains the seat mesh-grants' three events and the advisory of ASSIGNMENTS deleted (which, with the controller's consumer deleted that it hears already, clears a failed removal). Core subjects, read-only, no durable consumer. Test: rows added to the watchman's subjects in TestTheCopiedComposerComposesAsTheControllers; no new function. (issue 774, 774/S22) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/buscompose_test.go | 5 ++++- go/buscompose/health_grants.go | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 1075f8a..913a30a 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -951,7 +951,10 @@ func healthChecks(t *testing.T) { // It hears its watchdogs' words on core subjects (Limits 3, 11). for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive", "mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing", - "mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} { + "mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak", + // ADR 0582 clauses 4, 6 (issue 774, 774/S22): the lease write's events, and ASSIGNMENTS deleted. + "mesh.seat.mesh-grants.event.lease-altered", "mesh.seat.mesh-grants.event.removal-failed", + "mesh.seat.mesh-grants.event.lease-handed", "$JS.EVENT.ADVISORY.STREAM.DELETED.ASSIGNMENTS"} { if !MaySubscribe(perms, subject) { t.Errorf("the watchman may not hear %s", subject) } diff --git a/go/buscompose/health_grants.go b/go/buscompose/health_grants.go index 7225b6e..925b248 100644 --- a/go/buscompose/health_grants.go +++ b/go/buscompose/health_grants.go @@ -114,7 +114,7 @@ func WatchmanDeadLetterGrants(sources []string) []string { // WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and // Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool // runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed -// connections (S9), the provisioner clients' reports (S21), and the dead letters kept. Never the bus's API audit +// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit // (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among // them (the lead, 2026-10-29). func WatchmanHears() []string { @@ -129,6 +129,12 @@ func WatchmanHears() []string { "mesh.mod.nats.event.bus.connection-closed", ProvAppliedSubjects, deadLetterPrefix + ">", + // And the lease write's (ADR 0582 clauses 4, 6; issue 774): the seat mesh-grants' three events, and the stream + // ASSIGNMENTS deleted, which with the controller's consumer deleted clears a removal that failed. + "mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseHanded, + "mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseAltered, + "mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventRemovalFailed, + "$JS.EVENT.ADVISORY.STREAM.DELETED." + AssignmentsStream, } } -- 2.54.0 From c21d8fd9692c1714dc93440ab25a677747ff9c6e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:50:37 +0100 Subject: [PATCH 9/9] Fold the seat mesh-grants' test into the seat-verb test, within the test budget The repository is over its unit budget and a change may not raise the count: the case of the seat mesh-grants (ADR 0582 clause 1) runs as a subtest of TestTheDeliverySeatPromisesChecks instead of a function of its own. (issue 774, 774/S1) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/delivery_seat_test.go | 1 + go/catalogue/grants_seat_test.go | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go/catalogue/delivery_seat_test.go b/go/catalogue/delivery_seat_test.go index 046b93d..0ea1642 100644 --- a/go/catalogue/delivery_seat_test.go +++ b/go/catalogue/delivery_seat_test.go @@ -9,6 +9,7 @@ import ( // The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own, // `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit. func TestTheDeliverySeatPromisesChecks(t *testing.T) { + t.Run("the seat mesh-grants promises the lease write", grantsSeatPromisesTheLeaseWrite) seat, ok := SeatNamed(DeliverySeat) if !ok { t.Fatal("the delivery seat is not in the set") diff --git a/go/catalogue/grants_seat_test.go b/go/catalogue/grants_seat_test.go index fe45b76..62e08b3 100644 --- a/go/catalogue/grants_seat_test.go +++ b/go/catalogue/grants_seat_test.go @@ -6,9 +6,10 @@ import ( "testing" ) -// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774): its holder serves lease-write and lease-hand-back, both -// a condition of holding, and nothing else; the seat says lease-handed, lease-altered and removal-failed. -func TestTheGrantsSeatPromisesTheLeaseWrite(t *testing.T) { +// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774, 774/S1/AC2): its holder serves lease-write and +// lease-hand-back, both a condition of holding, and nothing else; the seat says lease-handed, lease-altered and +// removal-failed. A case of TestTheDeliverySeatPromisesChecks, the seat-verb test, within the test budget. +func grantsSeatPromisesTheLeaseWrite(t *testing.T) { seat, ok := SeatNamed(GrantsSeat) if !ok { t.Fatal("the seat mesh-grants is not in the set") -- 2.54.0