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 714a8b8..913a30a 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) } } @@ -561,6 +564,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}, @@ -636,6 +642,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 +791,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 +836,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"}, } @@ -828,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). @@ -885,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, } } 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..8b56c96 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 { @@ -1061,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 @@ -1226,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. @@ -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"}, diff --git a/go/catalogue/controller_dependence.go b/go/catalogue/controller_dependence.go new file mode 100644 index 0000000..298b8ba --- /dev/null +++ b/go/catalogue/controller_dependence.go @@ -0,0 +1,238 @@ +package catalogue + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// **What still depends on the controller, and the lease write's check** (novox/hq ADR 0571 §13, ADR 0582 clause 2, +// issue 774): every declaration of a manifest other than the controller's on the controller (a read of one of its +// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, a use of its seat), judged +// against the named, temporary list of what another accepted record keeps until the lease write. The grants part's +// grants_lease_check runs it over every manifest the indexer holds; ManifestRefusals runs it over a repository's +// manifests, in the test ADR 0571 §13 names in mesh-catalog, mesh-delivery and mesh-tools. A read of the lease key is no +// dependency: it names the lease's holder, whoever that is, and parts read it to learn that the write happened. + +// ControllerLease is the controller's lease as a reader names it (`.`): 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. It +// holds the three kept to the write itself and nothing else: the lease write's own delivery dropped every other entry +// (ADR 0571 §13; issue 774), so its check runs with these alone in either phase. The third, the bus node agent's +// following of the controller's user list (ADR 0571 §8, ADR 0547 §8), is no manifest's declaration but the +// node-engine's own, so no manifest matches it; it stays named here so the list reads as the record has it. +var KeepList = []KeptDependency{ + {Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true}, + {Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true}, + {Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true}, +} + +// EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved. +// The controller's `grants` is one: ADR 0571 §9 has no manifest read it from L7's delivery, which changes ADR 0567 §10 +// and ADR 0547 §5's shadow compare, so neither the assigner's nor the grants part's read of it is kept. +var EndedBy = map[string]string{ + DependsRead + " mesh-controller.grants": "ADR 0571 §9: no manifest reads the controller's grants; the grants part's key is the one read", + DependsInvoke + " seat:mesh-controller.hand-act": "ADR 0565 §11: every recorder calls mesh-hand-acts", + DependsInvoke + " seat:mesh-controller.warranted": "ADR 0565 §11: every recorder calls mesh-hand-acts", + DependsInvoke + " seat:mesh-controller.root-free": "ADR 0571 §11: each machine's engine answers root-free (seat:node-engine.root-free@machine)", + DependsInvoke + " seat:mesh-controller.held": "ADR 0571 §2: held is the seat mesh-artifact-sweep's", +} + +// keptFor is the list's entry for one dependency, or nil. +func keptFor(d Dependency) *KeptDependency { + for i, k := range KeepList { + if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What { + return &KeepList[i] + } + } + return nil +} + +// onController says whether a name `.` 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..410e27f --- /dev/null +++ b/go/catalogue/controller_dependence_test.go @@ -0,0 +1,103 @@ +package catalogue + +import ( + "slices" + "strings" + "testing" +) + +// Issue 774 (ADR 0571 §13, ADR 0582 clause 2): the lease write's check judges every manifest's dependency on the +// controller against the named list, at the write (the three kept to it alone) and before its delivery. Moved with the +// judge from mesh-grants' leasecheck_test.go (TestTheLeaseCheckJudgesEveryDependencyOnTheController's manifest rows and +// TestTheKeepListNamesItsRecordsAndThreeToTheWrite), whose manifests were mesh-grants' fixtures; the conditions row is +// mesh-conditions' manifest at mesh-catalog ce3bedd5 (module.json:31-32). +func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) { + toTheWrite := 0 + for _, k := range KeepList { + if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" { + t.Errorf("entry %+v names no record, module or declaration", k) + } + if k.ToTheWrite { + toTheWrite++ + } + } + if toTheWrite != 3 || len(KeepList) != 3 { + t.Errorf("%d entries, %d kept to the write itself: ADR 0571 §13 keeps the three alone from the write's delivery", len(KeepList), toTheWrite) + } + + type want struct { + deps []string // " ", kept by the record or "unkept" + write string // a reason the check at the write names, or "" when it passes + before string // a reason the check before the delivery names, or "" when it passes + } + for _, row := range []struct { + name string + manifests map[string]string + want want + }{ + // 774/S4/AC2. + {"a read of the lease alone is no dependency, and the controller's own manifest is not judged", + map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`, + "mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`}, + want{}}, + // 774/S4/AC1, 774/S30/AC1: the keeper's read of the controller's conditions put back is refused in either phase, + // since the lease write's delivery dropped its entry (it was kept by ADR 0564 §9 before). + {"the keeper's read of the controller's conditions, put back, is refused", + map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`}, + want{deps: []string{"mesh-conditions read mesh-controller.conditions unkept"}, + write: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write", + before: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write"}}, + // 774/S30/AC1: the three kept to the write pass in either phase. + {"the artifact sweep's collect is kept to the write itself", + map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","version":"1","invokes":["seat:mesh-controller.collect"]}`}, + want{deps: []string{"artifact-store-tools invoke seat:mesh-controller.collect kept by ADR 0571 §4, §13"}}}, + {"the builder's consume of artifacts-gone is kept to the write itself", + map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`}, + want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}}, + {"a read no record keeps fails both phases, named", + map[string]string{"mod-u": `{"module":"mod-u","version":"1","reads":["mesh-controller.declared-packages"]}`}, + want{deps: []string{"mod-u read mesh-controller.declared-packages unkept"}, + write: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write", + before: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write"}}, + {"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none", + map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` + + `"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`, + "node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`}, + want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept", + "mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"}, + write: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps until the lease write (ADR 0571 §13); ended by ADR 0571 §11", + before: "node-tools declares waits mesh-controller.checked"}}, + {"a process's own invoke of the controller is its module's dependency, named by the process", + map[string]string{"mod-p": `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},{"id":"worker","type":"process","run":["./w"],"user":"mod-p",` + + `"bus":{"invokes":["seat:mesh-controller.held"]}}]}`}, + want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"}, + write: "mod-p (process worker) declares invoke seat:mesh-controller.held", before: "mod-p (process worker)"}}, + {"no manifest fails both phases", map[string]string{}, want{write: "no manifest was read", before: "no manifest was read"}}, + } { + manifests := map[string]Manifest{} + for name, raw := range row.manifests { + m, err := Decode([]byte(raw)) + if err != nil { + t.Fatalf("%s: %s: %v", row.name, name, err) + } + manifests[name] = m + } + var deps []string + for _, d := range Dependencies(manifests) { + kept := "unkept" + if d.Kept != "" { + kept = "kept by " + d.Kept + } + deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept) + } + if !slices.Equal(deps, row.want.deps) { + t.Errorf("%s: dependencies %q, want %q", row.name, deps, row.want.deps) + } + for phase, reason := range map[string]string{PhaseWrite: row.want.write, PhaseBefore: row.want.before} { + got := ManifestRefusals(manifests, phase) + if (reason == "") != (len(got) == 0) || !slices.ContainsFunc(got, func(r string) bool { return strings.Contains(r, reason) }) && reason != "" { + t.Errorf("%s: at %s refused %q, want %q", row.name, phase, got, reason) + } + } + } +} 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/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..62e08b3 --- /dev/null +++ b/go/catalogue/grants_seat_test.go @@ -0,0 +1,68 @@ +package catalogue + +import ( + "reflect" + "strings" + "testing" +) + +// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774, 774/S1/AC2): its holder serves lease-write and +// lease-hand-back, both a condition of holding, and nothing else; the seat says lease-handed, lease-altered and +// removal-failed. A case of TestTheDeliverySeatPromisesChecks, the seat-verb test, within the test budget. +func grantsSeatPromisesTheLeaseWrite(t *testing.T) { + seat, ok := SeatNamed(GrantsSeat) + if !ok { + t.Fatal("the seat mesh-grants is not in the set") + } + if seat.Scope != ScopeMesh || seat.Delivers != "" { + t.Errorf("mesh-grants is at scope %q delivering %q, want mesh and nothing", seat.Scope, seat.Delivers) + } + if got, want := VerbNames(seat.Serves), []string{"lease-write", "lease-hand-back"}; !reflect.DeepEqual(got, want) { + t.Errorf("mesh-grants serves %v, want %v", got, want) + } + if want := []string{"lease-handed", "lease-altered", "removal-failed"}; !reflect.DeepEqual(seat.Emits, want) { + t.Errorf("mesh-grants says %v, want %v", seat.Emits, want) + } + for _, v := range seat.Serves { + if v.Optional { + t.Errorf("%s is optional: the holder ships with the seat, so every verb is a condition of holding", v.Name) + } + props, _ := v.Input["properties"].(map[string]any) + if req, _ := v.Input["required"].([]string); len(props) != 1 || !reflect.DeepEqual(req, []string{"why"}) { + t.Errorf("%s takes %v requiring %v, want only a why", v.Name, props, req) + } + } + + claim := func(serves ...string) Manifest { + return Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeMesh, Serves: serves}}} + } + for _, c := range []struct { + name string + m Manifest + refuse string // empty: held + }{ + {"both verbs", claim("lease-write", "lease-hand-back"), ""}, + {"without the hand-back", claim("lease-write"), "lease-hand-back"}, + {"a verb the seat does not promise", claim("lease-write", "lease-hand-back", "lease-take"), "lease-take"}, + {"at node scope", Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeNode, + Serves: []string{"lease-write", "lease-hand-back"}}}}, "scope"}, + } { + err := CanHold(c.m, seat) + switch { + case c.refuse == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)): + t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.refuse) + } + } + + // No other seat changed: the set is the one before it with mesh-grants added beside mesh-hand-acts. + var names []string + for _, s := range DefaultSeats() { + names = append(names, s.Name) + } + at := strings.Index(strings.Join(names, " "), HandActsSeat+" "+GrantsSeat+" ") + if at < 0 { + t.Errorf("mesh-grants does not follow mesh-hand-acts in the seat set: %v", names) + } +} 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()}, 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/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 { 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. 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") } }