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"},