diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 5e7f36e..4a10d97 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -34,12 +34,13 @@ var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATE // keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation // (issue 1790) is the other row beyond, matched by createdAdvisory. // -// And the watchman's dead letters (novox/hq ADR 0564 clause 5, issue 1952 S5): its own consumer on DEAD_LETTER_NOTICES and -// a read of DEAD_LETTERS, the controller's job until the lease write. +// And the watchman's dead letters and watchdogs (novox/hq ADR 0564 clauses 4, 5, issue 1952 S5): its own consumer on +// DEAD_LETTER_NOTICES, a read of DEAD_LETTERS, and the words its watchdogs hear, the controller's jobs until the lease +// write. func beyondTheControllers(user string) map[string]bool { out := map[string]bool{} if strings.HasSuffix(user, "."+WatchmanModule) { - for _, s := range WatchmanDeadLetterGrants(nil) { + for _, s := range append(WatchmanDeadLetterGrants(nil), WatchmanHears()...) { out[s] = true } } @@ -605,8 +606,8 @@ func healthChecks(t *testing.T) { "a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"}, WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"}, - WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.MSG.DELETE.SEAT_TELEGRAM_SENDER", - "$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "mesh.events.dead.EVENTS.a_m", "mesh.again.a_m.x"}, + WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER", + "$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"}, "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"}, } @@ -619,7 +620,10 @@ func healthChecks(t *testing.T) { // Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's // (the lead, 2026-10-29). WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x", - "$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER"}, + "$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER", + // In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29). + "$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x", + "mesh.again.a_m.x"}, WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule}, // The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts @@ -640,6 +644,15 @@ func healthChecks(t *testing.T) { if err != nil { t.Fatalf("%s: %v", name, err) } + if name == WatchmanModule { + // 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"} { + if !MaySubscribe(perms, subject) { + t.Errorf("the watchman may not hear %s", subject) + } + } + } subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...) denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...) if len(may[name+" sources"]) > 0 { diff --git a/go/buscompose/health_grants.go b/go/buscompose/health_grants.go index 46605dc..26f3c3b 100644 --- a/go/buscompose/health_grants.go +++ b/go/buscompose/health_grants.go @@ -62,14 +62,14 @@ func removesEngineActs(p Principal, pattern string) bool { // controller and `controller` is gone (ADR 0564 clause 5). const WatchmanDeadLetterConsumer = WatchmanModule -// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters (ADR 0564 clause 5): its own pulled -// consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged), whether `controller` still stands (read only), a -// read of DEAD_LETTERS (its list by subject, one kept message, an ordered consumer of its own to page them, a drop), the -// copies kept and the messages delivered again (`mesh.events.dead.>`, `mesh.again.>`), and on each stream a notice can -// name (sources: EVENTS, the seats' work queues and the modules' own) the message given up on read, its consumer read, -// and its message deleted from the work queue when delivered again, each stream by its name. Never a wildcard over the -// streams and never a bucket's or an object store's stream: a read of any message would read every bucket (the lead, -// 2026-10-29, on issue 1952 S5). +// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least +// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller` +// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept +// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the +// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name. +// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read +// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may +// neither delete a message nor publish `mesh.again.>`. func WatchmanDeadLetterGrants(sources []string) []string { own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer out := []string{ @@ -81,19 +81,29 @@ func WatchmanDeadLetterGrants(sources []string) []string { "$JS.ACK." + own + ".>", "$JS.API.STREAM.INFO." + DeadLettersStream, "$JS.API.STREAM.MSG.GET." + DeadLettersStream, - "$JS.API.STREAM.MSG.DELETE." + DeadLettersStream, - "$JS.API.CONSUMER.CREATE." + DeadLettersStream + ".>", - "$JS.API.CONSUMER.DELETE." + DeadLettersStream + ".*", - "$JS.FC." + DeadLettersStream + ".>", deadLetterPrefix + ">", - againPrefix + ">", } for _, s := range DeadLetterSources(sources) { - out = append(out, "$JS.API.STREAM.MSG.GET."+s, "$JS.API.STREAM.MSG.DELETE."+s, "$JS.API.CONSUMER.INFO."+s+".*") + out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s) } return out } +// 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 (S9), and the dead letters kept. +func WatchmanHears() []string { + return []string{ + "mesh.mod.power.event.*", + "mesh.mod.*.event.provisioner.failing", + "mesh.mod.*.event.provisioner.recovered", + "mesh.control.*.tools-alive", + "mesh.seat.mesh-self-check.event." + health.EventChecked, + "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", + deadLetterPrefix + ">", + } +} + // DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a // bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern. func DeadLetterSources(names []string) []string { @@ -136,8 +146,10 @@ func deadLetterSourcesOf(r Records) []string { return DeadLetterSources(out) } -// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies and the deliveries again -// beside the controller, which acts until its lease is handed away and the watchman after (ADR 0564 clause 5). +// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which +// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the +// controller's in L4. func keepsDeadLetters(p Principal, pattern string) bool { - return isController(p, pattern) || (p.Kind == KindModule && p.Module == WatchmanModule) + return isController(p, pattern) || + (p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">") } diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index e27d05d..25edb1a 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -1006,6 +1006,8 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, BusConnzSubject) // And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5). pub = append(pub, WatchmanDeadLetterGrants(p.DeadLetterSources)...) + // And the words its watchdogs hear (ADR 0564 clause 4, Limits 3, 11). + sub = append(sub, WatchmanHears()...) } // The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5; // the operator, 2026-10-25 22:31): one per subject, `mesh.assignment..`, reading the last one of each diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index 0911597..1d94184 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -221,7 +221,7 @@ var WritersTable = []WriterRow{ {State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream, Others: "read, delivered again or dropped through the controller's dead-letters verb", Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters, - Shared: "the watchman keeps them from the controller's lease write, the controller before it (novox/hq ADR 0564 clause 5)"}, + Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"}, // The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets // anything else write them is refused at composition. Built so far: the assignments and each registered // module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).