From f0c4d564d2c469f0f809e4b4c20da584e20ad18d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:17:07 +0100 Subject: [PATCH] =?UTF-8?q?GR16=20=C2=A7554:=20the=20recovery=20module's?= =?UTF-8?q?=20account=20is=20granted=20the=20bus=20names=20of=20kept=20dat?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account of the module claiming the seat mesh-recovery is granted the stream snapshot and restore subjects with their chunk subjects, a stream's deletion and a durable consumer's creation, only for the bus names of every kept data of every manifest the part holds and of every restore point or aside kept-points names; never a stream of the mesh's own (CONTROL, NODES, EVENTS, DEAD_LETTER*, PROV_APPLIED, CHECKS_*, SEAT_*, a seat's KV_). The writers table names the account as a deleter of kept data's streams (ADR 0672 §4). Builds: design 47 G §554, §565, A-230 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-grants/internal/buscompose/nats.go | 4 ++ .../internal/buscompose/recovery.go | 67 +++++++++++++++++++ .../buscompose/recovery_engine_test.go | 37 ++++++++++ .../mesh-grants/internal/buscompose/users.go | 7 ++ .../internal/buscompose/writers.go | 19 +++++- .../mesh-grants/internal/grants/keptpoints.go | 15 +++++ .../internal/grants/keptpoints_test.go | 16 +++++ .../mesh-grants/internal/grants/records.go | 4 ++ 8 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-grants/internal/buscompose/recovery.go diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 8c2abfea..f73f6d40 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -158,6 +158,8 @@ type Principal struct { // PurgesNamed are the named asks of every queue of a build seat a composed user holds, for the grants part alone: the // purge of their subjects on the seat's stream and the delete of their named workers (design 47 G §544). PurgesNamed []NamedQueue + // Recovers are the bus names of kept data a module holding the seat mesh-recovery is granted (RecoveryGrants), for a KindModule principal. + Recovers []string // Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2): // Username and inbox are ... Process string @@ -966,6 +968,8 @@ func permissionsFor(p Principal) (Permissions, error) { // away — no other principal may subscribe this namespace, and a caller's authority is // still granted per tool, by name, on the publish side. sub = append(sub, own+".tool.>") + // The holder of the seat mesh-recovery: the bus names of kept data it takes, restores and deletes (design 47 G §554). + pub = append(pub, RecoveryGrants(p.Recovers)...) // It says what it serves (novox/hq ADR 0197): discovery for its own name and every seat it // holds a verb of, answered by the runtime that serves them. announced := []string{p.Module} diff --git a/modules/mesh-grants/internal/buscompose/recovery.go b/modules/mesh-grants/internal/buscompose/recovery.go new file mode 100644 index 00000000..b89e2def --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/recovery.go @@ -0,0 +1,67 @@ +package buscompose + +import ( + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-contracts/go/seats/meshrecovery" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **The recovery module's account** (design 47 G §554, §565; ADR 0672 §4): the account of the module holding the seat mesh-recovery +// (the seat, never a module's name) is granted the bus's stream snapshot and restore subjects with their chunk subjects, a stream's +// deletion and a durable consumer's creation, only for the bus names of kept data: those of every kept data of every catalog graph +// entry and of every entry a restore point names (an old section's name before a form change, a template's binding after), the +// second read by the grants part through `kept-points`. The subjects its seat says, its hand-act log write, its consumes of +// `node-backup` and its calls come from its manifest as any module's. No stream of the mesh's own is ever granted. + +// RecoverySeat is the seat whose holder's account is granted the bus names of kept data. +const RecoverySeat = meshrecovery.Seat + +// meshStreams are the streams of the mesh's own, never granted to the recovery module's account (§554); every `SEAT_` stream and +// every `KV_` bucket of a seat (KV_SEAT_…) are refused by their prefix. +var meshStreams = []string{"CONTROL", "NODES", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"} + +// RecoveryNameRefused says a bus name is one of the mesh's own streams, or no plain subject token. +func RecoveryNameRefused(name string) bool { + return !safeSubject.MatchString(name) || strings.Contains(name, ".") || slices.Contains(meshStreams, name) || + strings.HasPrefix(name, "SEAT_") || strings.HasPrefix(name, "KV_SEAT_") || name == "KV_CONTROL" || name == "KV_NODES" +} + +// RecoveryGrants are what the account publishes for each bus name of kept data: the stream's snapshot and its chunks' +// acknowledgements, its restore and the chunks sent to it, its deletion, and the creation of a durable consumer on it. +func RecoveryGrants(names []string) []string { + var out []string + for _, n := range names { + if RecoveryNameRefused(n) { + continue + } + out = append(out, "$JS.API.STREAM.SNAPSHOT."+n, "$JS.SNAPSHOT.ACK."+n+".>", "$JS.API.STREAM.RESTORE."+n, + "$JS.SNAPSHOT.RESTORE."+n+".>", "$JS.API.STREAM.DELETE."+n, "$JS.API.CONSUMER.DURABLE.CREATE."+n+".*") + } + return unique(out) +} + +// KeptBusNames are the bus names of every kept data a manifest declares: the old sections' (a state's `KV__`, a log's, +// a stream's, an object store's) and each entry of `requires` by `_` (BusStateStreams). Sorted, once each. Pure. +func KeptBusNames(m catalogue.Manifest) []string { + var out []string + for _, s := range m.State { + out = append(out, "KV_"+m.Module+"_"+s.Name) + } + for _, l := range m.Logs { + out = append(out, "LOG_"+m.Module+"_"+l.Name) + } + for _, s := range m.Streams { + out = append(out, s.BusName()) + } + for _, o := range m.Objects { + out = append(out, "OBJ_"+catalogue.ObjectsBucket(m.Module, o.Name)) + } + for _, e := range m.BusState { + out = append(out, BusStateStreams(m.Module, e)...) + } + sort.Strings(out) + return slices.Compact(out) +} diff --git a/modules/mesh-grants/internal/buscompose/recovery_engine_test.go b/modules/mesh-grants/internal/buscompose/recovery_engine_test.go index 11f66d56..c10a5862 100644 --- a/modules/mesh-grants/internal/buscompose/recovery_engine_test.go +++ b/modules/mesh-grants/internal/buscompose/recovery_engine_test.go @@ -47,3 +47,40 @@ func TestTheNodeEngineIsGrantedWhatItNeedsOfTheRecoverySeat(t *testing.T) { } } } + +// Design 47 G §554: the account of the module holding the seat mesh-recovery is granted the snapshot, the restore with its +// chunks, the deletion and a durable consumer's creation for the bus names of kept data, and no stream of the mesh's own; no other +// module is granted them. +func TestTheRecoveryAccountIsGrantedTheBusNamesOfKeptDataAlone(t *testing.T) { + names := []string{"KV_a_x", "OBJ_b_y", "EVENTS", "SEAT_node-build-go", "KV_SEAT_x", "CONTROL", "bad.name"} + records := Records{Nodes: []string{"one"}, RecoveryNames: names, + Assigned: map[string][]Declared{"one": {{Module: "recovery", HoldsRecovery: true}, {Module: "plain"}}}} + users, err := Users(records) + if err != nil { + t.Fatal(err) + } + perms := map[string]Permissions{} + for _, u := range users { + if u.Kind == KindModule { + if perms[u.Module], err = PermissionsFor(u); err != nil { + t.Fatal(err) + } + } + } + for _, n := range []string{"KV_a_x", "OBJ_b_y"} { + for _, want := range []string{"$JS.API.STREAM.SNAPSHOT." + n, "$JS.SNAPSHOT.ACK." + n + ".abc", "$JS.API.STREAM.RESTORE." + n, + "$JS.SNAPSHOT.RESTORE." + n + ".abc", "$JS.API.STREAM.DELETE." + n, "$JS.API.CONSUMER.DURABLE.CREATE." + n + ".keeper"} { + if !MayPublish(perms["recovery"], want) { + t.Errorf("the recovery account may not publish %s", want) + } + if MayPublish(perms["plain"], want) { + t.Errorf("a module that holds no recovery seat may publish %s", want) + } + } + } + for _, n := range []string{"EVENTS", "SEAT_node-build-go", "KV_SEAT_x", "CONTROL"} { + if MayPublish(perms["recovery"], "$JS.API.STREAM.DELETE."+n) || MayPublish(perms["recovery"], "$JS.API.STREAM.SNAPSHOT."+n) { + t.Errorf("the recovery account may touch %s, a stream of the mesh's own", n) + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index c3eefa31..e51e8428 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -88,6 +88,8 @@ type Declared struct { // SnapshotsTheBus says the module holds mesh-broker — it is the bus — and so is the one module // granted the snapshot API, to copy the bus's streams for the night's backup (novox/hq ADR 0235). SnapshotsTheBus bool + // HoldsRecovery says the module claims the seat mesh-recovery: its account is granted the bus names of kept data (recovery.go). + HoldsRecovery bool // Checks are the module's own tools its health asks, each `.` (novox/hq ADR 0240, to-be // 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more. Checks []string @@ -151,6 +153,8 @@ type ProcessPrincipal struct { // Records is what composing a user list needs to know about the mesh, and nothing more. type Records struct { + // RecoveryNames are the bus names of kept data the holder of the seat mesh-recovery is granted (recovery.go, design 47 G §554, §565). + RecoveryNames []string // Kept is every seat event kept in a stream of its own (kept.go): the grants part makes each stream, so its principal is // granted the create of each by name. Kept []KeptEvent @@ -328,6 +332,9 @@ func Users(r Records) ([]Principal, error) { Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues, Objects: objectNames(d.Objects), ReadsObjects: d.ReadsObjects, Bound: d.Bindings, AnswersSeat: d.ServesOwn, }) + if d.HoldsRecovery { + out[len(out)-1].Recovers = r.RecoveryNames + } // **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26 // 08:54): every placed module's buckets, logs and streams, each by its stream's name. if d.Module == WatchmanModule { diff --git a/modules/mesh-grants/internal/buscompose/writers.go b/modules/mesh-grants/internal/buscompose/writers.go index 7ecb789f..c14cbca2 100644 --- a/modules/mesh-grants/internal/buscompose/writers.go +++ b/modules/mesh-grants/internal/buscompose/writers.go @@ -38,7 +38,7 @@ type WriterRow struct { // that only adds subjects, never a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54, 2026-11-05); or the bus module's tools deleting one of // the ended controller's leftover streams (ADR 0590 clause 6). func createsDeclared(p Principal, pattern string) bool { - if removesLeftover(p, pattern) || keepsBusState(p, pattern) { + if removesLeftover(p, pattern) || keepsBusState(p, pattern) || recoversKept(p, pattern) { return true } name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.") @@ -54,6 +54,21 @@ func createsDeclared(p Principal, pattern string) bool { safeSubject.MatchString(name) } +// recoversKept is the account of the holder of the seat mesh-recovery deleting a stream of a kept data, or creating a durable +// consumer on one, by its name alone (design 47 G §554, ADR 0672 §4: this changes ADR 0436 §3 and §5 for this account alone). +func recoversKept(p Principal, pattern string) bool { + if p.Kind != KindModule || len(p.Recovers) == 0 { + return false + } + for _, prefix := range []string{"$JS.API.STREAM.DELETE.", "$JS.API.CONSUMER.DURABLE.CREATE."} { + if rest, ok := strings.CutPrefix(pattern, prefix); ok { + name, _, _ := strings.Cut(rest, ".") + return slices.Contains(p.Recovers, name) && !RecoveryNameRefused(name) + } + } + return false +} + // ownMachine is a node writing a subject whose third token is its own name, and no wildcard there. func ownMachine(p Principal, pattern string) bool { tokens := strings.Split(pattern, ".") @@ -154,7 +169,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, adds to a held one the subjects its manifest names, and never removes a subject or deletes a stream (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54, 2026-11-05); the bus module's tools account deletes the ended controller's leftover streams, each by its name, and no other (novox/hq ADR 0590 clause 6); the bus module's process creates, updates and deletes every module's bus state, each stream by its name (novox/hq ADR 0595 §4)"}, + Shared: "the grants part creates a stream a manifest declares, by its name, adds to a held one the subjects its manifest names, and never removes a subject or deletes a stream (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54, 2026-11-05); the bus module's tools account deletes the ended controller's leftover streams, each by its name, and no other (novox/hq ADR 0590 clause 6); the bus module's process creates, updates and deletes every module's bus state, each stream by its name (novox/hq ADR 0595 §4); the account of the holder of the seat mesh-recovery deletes the stream of a kept data and creates a durable consumer on it, each by its name (design 47 G §554, ADR 0672 §4)"}, {State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state", Others: "the builder asks", Subjects: []string{"mesh.seat.mesh-build-machine.event.built"}, diff --git a/modules/mesh-grants/internal/grants/keptpoints.go b/modules/mesh-grants/internal/grants/keptpoints.go index c6fa4b57..62c275bf 100644 --- a/modules/mesh-grants/internal/grants/keptpoints.go +++ b/modules/mesh-grants/internal/grants/keptpoints.go @@ -10,6 +10,8 @@ import ( "time" "git.novox.be/novox/mesh-contracts/go/seats/meshrecovery" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "mesh-grants/internal/buscompose" ) // **The recovery module's restore points' names, read through `kept-points`** (design 47 G §565, A-230). The bus names of the @@ -298,3 +300,16 @@ func (p *Part) withKeptNames(in *Inputs) error { in.KeptNames = names return nil } + +// recoveryNamesOf are the bus names of kept data of every manifest of the catalog graph the part holds, and the names a restore +// point or an aside names (Inputs.KeptNames): a name a manifest left stays while a point names it. Sorted, once each. Pure. +func recoveryNamesOf(in Inputs) []string { + names := slices.Clone(in.KeptNames) + for _, group := range []map[string]catalogue.Manifest{in.Manifests, in.Running, in.Ran, in.RanByFingerprint, in.Had, in.Held} { + for _, m := range group { + names = append(names, buscompose.KeptBusNames(m)...) + } + } + slices.Sort(names) + return slices.Compact(names) +} diff --git a/modules/mesh-grants/internal/grants/keptpoints_test.go b/modules/mesh-grants/internal/grants/keptpoints_test.go index e52ce1dc..fa2d0252 100644 --- a/modules/mesh-grants/internal/grants/keptpoints_test.go +++ b/modules/mesh-grants/internal/grants/keptpoints_test.go @@ -7,6 +7,8 @@ import ( "slices" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // Design 47 G §565, A-230: the part reads points[].names and asides[].names of kept-points, keeps them in its state memberships, @@ -60,3 +62,17 @@ func TestKeptPointsNamesAreKeptUntilTheCallAnswers(t *testing.T) { } } } + +// Design 47 G §554, §565: the names the recovery account is granted are those of every manifest of the catalog graph (an old +// section's and a template entry's) and those a point names, once each; a name a manifest left stays while a point names it. +func TestTheRecoveryNamesAreTheCatalogGraphsAndThePointsNames(t *testing.T) { + trunk := catalogue.Manifest{Module: "a", State: []catalogue.StateDeclaration{{Name: "x"}}} + old := catalogue.Manifest{Module: "a", State: []catalogue.StateDeclaration{{Name: "old"}}, + BusState: []catalogue.BusStateEntry{{Name: "tpl", Storage: catalogue.StorageKeyValue}}} + got := recoveryNamesOf(Inputs{Manifests: map[string]catalogue.Manifest{"a": trunk}, RanByFingerprint: map[string]catalogue.Manifest{"fp": old}, + KeptNames: []string{"KV_a_gone", "KV_a_x"}}) + want := []string{"KV_a_gone", "KV_a_old", "KV_a_tpl", "KV_a_x"} + if !slices.Equal(got, want) { + t.Errorf("names %v, want %v", got, want) + } +} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 721ced60..2c0f025e 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -53,6 +53,9 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { out.BusState[module] = m.BusState } } + // The bus names of kept data the recovery module's account is granted (design 47 G §554, §565): those of every manifest of the + // catalog graph the part holds (the trunk's, each node's wanted, ran and had builds), and those restore points name. + out.RecoveryNames = recoveryNamesOf(in) // The holders on record, after the seats whose holder has left are passed (ADR 0660): the one rule of mesh-sdk, the // node-engine's too, read with the manifest each node runs, else the trunk's. Composed, never recorded. holders, passed := holdersAfterPass(in) @@ -174,6 +177,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio d.ProvisionsNode = true } d.SnapshotsTheBus = m.ClaimsSeat(meshbroker.Seat) + d.HoldsRecovery = m.ClaimsSeat(buscompose.RecoverySeat) for _, c := range m.Claims { if s, hasAProtocol := seats[c.Name]; hasAProtocol { held := asSeat(s, declarers[s.Name])