GR16 §554: the recovery module's account is granted the bus names of kept data

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
This commit is contained in:
2026-11-12 09:17:07 +01:00
parent 3cc4cb9392
commit f0c4d564d2
8 changed files with 167 additions and 2 deletions
@@ -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 <node>.<module>.<process id>.
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}
@@ -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_<module>_<name>`, a log's,
// a stream's, an object store's) and each entry of `requires` by `<module>_<local>` (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)
}
@@ -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)
}
}
}
@@ -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 `<module>.<tool>` (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 {
@@ -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"},
@@ -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)
}
@@ -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)
}
}
@@ -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])