mesh-grants: create a declared bucket, log or stream the bus lacks, so a new module state no longer waits for a controller start
mesh/delivery merged: source.main moved to novox/mesh-delivery@86db64904b2a
mesh/delivery-group group fix/1790-grants-create-buckets merged: every member is merged
mesh/check checking 12f16bbebd40 on 13380e3d0e52 | novox/mesh-delivery#58 in group fix/1790-grants-create-buckets

A module's new state reached the bus only when the controller started (issue 1790, met three
times: puts, queues, mesh-vault's bus-accounts). The operator allowed one more grant in the
controller's composer on 2026-10-26 08:54, creation only: the part's process now creates each
bucket, log or stream a placed module's manifest declares and the bus lacks, with the same
configuration the controller asserts (mesh-sdk BucketStreamConfig, Log.Config,
ModuleStreamConfig), on the event that brought the manifest, never on a timer. It says asserted
and logs one line per module; a stream the bus holds is never updated or deleted, its difference
only said; a create refused is said and tried at the next event; one created at this manifest
and gone since is made again only by hand. grants_assert asks the process to do the same for one
module now. The user list, the grants and any change to a held stream stay in shadow. mesh-sdk
moves to fix/1790-grants-create-buckets, whose composer grants this, so the shadow list is
composed as the controller's.

Issue: 1790
This commit is contained in:
2026-10-26 09:11:23 +01:00
parent 4ec40eec9a
commit 12f16bbebd
12 changed files with 408 additions and 41 deletions
@@ -0,0 +1,34 @@
package main
import (
"context"
"errors"
"fmt"
"github.com/nats-io/nats.go/jetstream"
"mesh-grants/internal/grants"
)
// creating creates a stream a manifest declares that the bus lacks (issue 1790; the operator, 2026-10-26 08:54): the
// create alone, which the account is granted by the stream's name, never an update or a delete. The one place the part
// creates on the bus.
type creating struct {
ctx context.Context
js jetstream.JetStream
}
func (c creating) CreateDeclared(cfg jetstream.StreamConfig) error {
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
defer cancel()
_, err := c.js.CreateStream(ctx, cfg)
switch {
case err == nil:
return nil
case errors.Is(err, jetstream.ErrStreamNameAlreadyInUse):
return grants.ErrHeld
case errors.Is(err, context.DeadlineExceeded):
return fmt.Errorf("no answer in %s: a create the bus refuses is answered by none (%w)", requestWithin, err)
}
return err
}
+6 -3
View File
@@ -5,7 +5,10 @@
// //
// **Memberships are published for real** (the operator, 2026-10-25 22:31): since release 6 nobody publishes them, so a // **Memberships are published for real** (the operator, 2026-10-25 22:31): since release 6 nobody publishes them, so a
// module placed since cannot serve its seat or reach a new state. Each is published on the assignments stream only when // module placed since cannot serve its seat or reach a new state. Each is published on the assignments stream only when
// it differs from the one last published there. **The user list, the assertions and the grants stay in shadow** until // it differs from the one last published there. **A bucket, log or stream a manifest declares that the bus lacks is
// created** (the operator, 2026-10-26 08:54; issue 1790), once per manifest, with the manifest's limits, on the event
// that brought the manifest; one the bus holds is never changed. **The user list, the rest of the assertions and the
// grants stay in shadow** until
// the operator's take-over, which this milestone always refuses: they are composed, compared with what the bus loaded // the operator's take-over, which this milestone always refuses: they are composed, compared with what the bus loaded
// and holds (through the bus module's own read tools) and kept in the part's own state, and take no effect. // and holds (through the bus module's own read tools) and kept in the part's own state, and take no effect.
// //
@@ -112,7 +115,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
} }
bus := newBusReader(ctx, js) bus := newBusReader(ctx, js)
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn}, f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
Publish: memberships{ctx: ctx, nc: nc, js: js}} Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}}
f.Watch = bus f.Watch = bus
f.Asked = func(wake func()) error { f.Asked = func(wake func()) error {
return conn.State(grants.StateUserList).Watch(grants.KeyAsked, func(c stdio.StateChange) error { return conn.State(grants.StateUserList).Watch(grants.KeyAsked, func(c stdio.StateChange) error {
@@ -122,7 +125,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return nil return nil
}) })
} }
logger.Printf("started on the account %s: publishing memberships; the user list, assertions and grants in shadow", account.User) logger.Printf("started on the account %s: publishing memberships and creating the declared buckets, logs and streams the bus lacks; the user list, grants and any change to a held stream in shadow", account.User)
f.Run(ctx) f.Run(ctx)
return nil return nil
} }
+4 -2
View File
@@ -91,8 +91,10 @@ func toolsOf(p *grants.Part) []stdio.Tool {
"verb, and the answer says so.", "verb, and the answer says so.",
Run: func(a map[string]any) (any, error) { return p.AskCompose(stdio.CallerOf(a)) }}, Run: func(a map[string]any) (any, error) { return p.AskCompose(stdio.CallerOf(a)) }},
{Name: grants.ToolAssert, Description: "One module's buckets, logs and streams (ADR 0547 clause 6), composed from its manifest at " + {Name: grants.ToolAssert, Description: "One module's buckets, logs and streams (ADR 0547 clause 6), composed from its manifest at " +
"the indexer's trunk with their limits, beside what the bus holds (through the bus module's nats_streams). In shadow it " + "the indexer's trunk with their limits, beside what the bus holds (through the bus module's nats_streams). When the " +
"asserts nothing, and says so.", "bus lacks one, asks the part's process (which alone holds the grant) to create it now with the manifest's limits, " +
"as it does by itself when a manifest is registered (issue 1790), and says asserted; one the bus holds is never " +
"changed, a difference is only said. Answers the comparison and the ask; call again to see what the bus holds.",
Input: schema(map[string]string{"module": "the module"}, "module"), Input: schema(map[string]string{"module": "the module"}, "module"),
Run: func(a map[string]any) (any, error) { return p.AssertOne(grants.Answer(a, "module")) }}, Run: func(a map[string]any) (any, error) { return p.AssertOne(grants.Answer(a, "module")) }},
{Name: grants.ToolTakeOver, Description: "The operator's switch to the grants part's list (ADR 0547 clause 8), refused without the " + {Name: grants.ToolTakeOver, Description: "The operator's switch to the grants part's list (ADR 0547 clause 8), refused without the " +
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-grants
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025215021-b7b5c6ef27a0 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025215021-b7b5c6ef27a0 h1:8dKGrZzPthx/jZlV9Bjm2lWGNORUTETmq2vxKN+MRV0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896 h1:5rHfru5NJ7SK20QOt8YUJSglcn1gsjtLc9AGmWYGnRQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025215021-b7b5c6ef27a0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+135 -6
View File
@@ -14,6 +14,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
@@ -43,6 +45,9 @@ type fakeBus struct {
refusePub bool refusePub bool
// hold, when set, keeps every nats_users call waiting until it is closed: a pass waiting on the bus. // hold, when set, keeps every nats_users call waiting until it is closed: a pass waiting on the bus.
hold chan struct{} hold chan struct{}
// creates are the streams the part asked to create, refused when refuseCreate.
creates []string
refuseCreate bool
} }
func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) { func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) {
@@ -72,6 +77,25 @@ func (b *fakeBus) PublishMembership(node, module string, body []byte, after uint
return nil return nil
} }
// CreateDeclared is the bus's create of a stream: refused when refuseCreate, held when the bus holds it, and otherwise
// held from now on, as nats_streams then reads it.
func (b *fakeBus) CreateDeclared(cfg jetstream.StreamConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.creates = append(b.creates, cfg.Name)
if b.refuseCreate {
return errors.New("Permissions Violation for Publish to $JS.API.STREAM.CREATE." + cfg.Name)
}
for _, s := range b.streams {
if s["name"] == cfg.Name {
return grants.ErrHeld
}
}
b.streams = append(b.streams, map[string]any{"name": cfg.Name, "subjects": cfg.Subjects, "retention": "limits",
"max_age_hours": int(cfg.MaxAge.Hours()), "max_per_subject": int(cfg.MaxMsgsPerSubject)})
return nil
}
func newFakeBus() *fakeBus { func newFakeBus() *fakeBus {
return &fakeBus{states: map[string]map[string][]byte{}, refused: map[string]bool{}, own: map[string]map[string][]byte{}, return &fakeBus{states: map[string]map[string][]byte{}, refused: map[string]bool{}, own: map[string]map[string][]byte{},
revs: map[string]uint64{}, refuseTo: map[string]bool{}} revs: map[string]uint64{}, refuseTo: map[string]bool{}}
@@ -295,12 +319,14 @@ func TestGrants(t *testing.T) {
} }
}) })
t.Run("no timer, no create, a publish only of memberships", func(t *testing.T) { t.Run("no timer, no update or delete, a publish only of memberships, a create only of declared streams", func(t *testing.T) {
forbidden := []string{"time.Tick", "time.NewTicker", "time.Sleep", "time.After", "time.NewTimer", "time.AfterFunc", forbidden := []string{"time.Tick", "time.NewTicker", "time.Sleep", "time.After", "time.NewTimer", "time.AfterFunc",
"PublishMsg", "CreateStream", "CreateKeyValue", "CreateOrUpdate", "UpdateStream", "UpdateKeyValue", "PublishMsg", "CreateKeyValue", "CreateOrUpdate", "UpdateStream", "UpdateKeyValue", "DeleteStream", "PurgeStream",
"CreateObjectStore", "EnsureStream", "RaiseBuckets", "RaiseLogs", ".Create("} "DeleteKeyValue", "CreateObjectStore", "EnsureStream", "RaiseBuckets", "RaiseLogs", ".Create("}
// The one publish: a membership, on its own subject, in publish.go. // The one publish: a membership, on its own subject, in publish.go; the one create: a declared stream the bus lacks,
// in create.go (issue 1790).
publishOnly := filepath.Join("cmd", "mesh-grants", "publish.go") publishOnly := filepath.Join("cmd", "mesh-grants", "publish.go")
createOnly := filepath.Join("cmd", "mesh-grants", "create.go")
files := 0 files := 0
for _, root := range []string{"cmd", "internal"} { for _, root := range []string{"cmd", "internal"} {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
@@ -315,6 +341,9 @@ func TestGrants(t *testing.T) {
if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 { if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 {
t.Errorf("publish.go publishes other than one membership") t.Errorf("publish.go publishes other than one membership")
} }
if n := strings.Count(string(src), "CreateStream"); (path == createOnly && n != 1) || (path != createOnly && n != 0) {
t.Errorf("%s holds CreateStream %d times: only create.go creates, once", path, n)
}
for _, f := range forbidden { for _, f := range forbidden {
if strings.Contains(string(src), f) { if strings.Contains(string(src), f) {
t.Errorf("%s holds %s: the part holds no timer and creates or asserts nothing", path, f) t.Errorf("%s holds %s: the part holds no timer and creates or asserts nothing", path, f)
@@ -513,8 +542,9 @@ func TestGrants(t *testing.T) {
} }
a, err := p.AssertOne("mod-x") a, err := p.AssertOne("mod-x")
as, _ := a.(grants.Assertion) am, _ := a.(map[string]any)
if err != nil || as.Asserted || as.Says != grants.ShadowAsserts || len(as.Items) != 3 { as, _ := am["assertion"].(grants.Assertion)
if err != nil || as.Asserted || as.Says != grants.ShadowAsserts || len(as.Items) != 3 || am["asked"] == nil {
t.Fatalf("assert = %+v, %v", a, err) t.Fatalf("assert = %+v, %v", a, err)
} }
for _, it := range as.Items { for _, it := range as.Items {
@@ -721,6 +751,95 @@ func TestGrants(t *testing.T) {
}) })
}) })
t.Run("assertions: what the bus lacks is created once, what it holds is never touched", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
// The bus holds mod-x's bucket with another history, and neither its log nor its stream.
b.streams = []map[string]any{{"name": "KV_mod-x_kept", "subjects": []string{"$KV.mod-x_kept.>"}, "retention": "limits", "max_per_subject": 2}}
p := partOn(b)
p.Create = b
assertion := func() grants.Assertion {
raw, _, _, _ := b.ownGet(grants.StateAssertions, "mod-x")
var a grants.Assertion
_ = json.Unmarshal(raw, &a)
return a
}
first := p.Compose()
if !slices.Equal(b.creates, []string{"LOG_mod-x_history", "THINGS"}) || len(first.Asserted) != 1 ||
!strings.HasPrefix(first.Asserted[0], "mod-x at ") || !slices.Contains(b.events, grants.EventAsserted) {
t.Fatalf("the first pass created %v, said %v, events %v", b.creates, first.Asserted, b.events)
}
a := assertion()
if !a.Asserted || a.Says != grants.CreatesMissing {
t.Errorf("the assertion kept = %+v", a)
}
for _, it := range a.Items {
switch it.Stream {
case "KV_mod-x_kept":
if it.Created != "" || !slices.Contains(it.Differences, "history 2 held, 3 composed") {
t.Errorf("a bucket the bus holds was touched, or its difference not said: %+v", it)
}
default:
if it.Created != grants.ItemCreated {
t.Errorf("%s: %q, want created", it.Stream, it.Created)
}
}
}
if again := p.Compose(); len(again.Asserted) != 0 || len(b.creates) != 2 {
t.Errorf("a pass with everything held created again: %v, %v", again.Asserted, b.creates)
}
// THINGS deleted since: not made again at the same manifest, said; a hand assert (the tool, asking the process)
// makes it again.
b.mu.Lock()
b.streams = slices.DeleteFunc(b.streams, func(s map[string]any) bool { return s["name"] == "THINGS" })
b.mu.Unlock()
gone := p.Compose()
if len(b.creates) != 2 || !strings.Contains(strings.Join(gone.NotAsserted, " "), "THINGS: created at this manifest and gone since") {
t.Errorf("a stream gone since was made again by itself, or not said: %v, %v", b.creates, gone.NotAsserted)
}
tool := partOn(b)
if got, err := tool.AssertOne("mod-x"); err != nil || fmt.Sprint(got) == "" || len(b.creates) != 2 {
t.Fatalf("the tool = %v, %v, creates %v: it asks, and creates nothing itself", got, err, b.creates)
}
if hand := p.Compose(); len(hand.Asserted) != 1 || b.creates[len(b.creates)-1] != "THINGS" {
t.Errorf("the hand assert did not create THINGS again: %v, %v", hand.Asserted, b.creates)
}
if after := p.Compose(); len(after.Asserted) != 0 {
t.Errorf("the hand ask was acted on twice: %v", after.Asserted)
}
for _, c := range b.creates {
if c == "KV_mod-x_kept" {
t.Error("the bucket the bus holds was asked to be created")
}
}
})
t.Run("assertions: a refused create is said, never stops it, and is tried at the next event", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
b.refuseCreate = true
p := partOn(b)
p.Create = b
refused := p.Compose()
if len(refused.Asserted) != 0 || !strings.Contains(strings.Join(refused.NotAsserted, " "), "Permissions Violation") ||
slices.Contains(b.events, grants.EventAsserted) {
t.Fatalf("a refused create = %+v", refused)
}
b.refuseCreate = false
if next := p.Compose(); len(next.Asserted) != 1 || !strings.Contains(next.Asserted[0], "KV_mod-x_kept") {
t.Errorf("the next event created %v", next.Asserted)
}
// What the bus holds unread: nothing is created.
c := fixture()
c.loaded = shadowOf(t, c)
q := partOn(c)
q.Create = c
q.Call = refusingStreams{c}
if r := q.Compose(); len(c.creates) != 0 || len(r.Asserted) != 0 {
t.Errorf("created %v with what the bus holds unread", c.creates)
}
})
t.Run("memberships held back: every one is said and listed", func(t *testing.T) { t.Run("memberships held back: every one is said and listed", func(t *testing.T) {
b := fixture() b := fixture()
b.loaded = shadowOf(t, b) b.loaded = shadowOf(t, b)
@@ -813,6 +932,16 @@ func nonNil(s []string) []string {
} }
// fakeWatcher refuses a state's first watches as told, and hands changes to the watches it opened. // fakeWatcher refuses a state's first watches as told, and hands changes to the watches it opened.
// refusingStreams is the bus with nats_streams refused: what the bus holds cannot be read.
type refusingStreams struct{ *fakeBus }
func (r refusingStreams) Call(key string, body any) (json.RawMessage, error) {
if key == grants.CallStreams {
return nil, errors.New("refused")
}
return r.fakeBus.Call(key, body)
}
type fakeWatcher struct { type fakeWatcher struct {
mu sync.Mutex mu sync.Mutex
refuse map[string]int refuse map[string]int
@@ -2,9 +2,14 @@ package grants
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"slices" "slices"
"sort" "sort"
"strings"
"sync"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
@@ -19,6 +24,9 @@ type Item struct {
Held *Limits `json:"held,omitempty"` Held *Limits `json:"held,omitempty"`
// Differences says how they differ, in words; empty when they agree in what can be read from here. // Differences says how they differ, in words; empty when they agree in what can be read from here.
Differences []string `json:"differences,omitempty"` Differences []string `json:"differences,omitempty"`
// Created says what became of creating it when the bus lacked it: "created", or why not (issue 1790). Empty when
// the bus holds it or no creation was asked here.
Created string `json:"created,omitempty"`
} }
// Limits are the facts of a stream an assertion sets and the bus module's nats_streams reads back. Its size cap is not // Limits are the facts of a stream an assertion sets and the bus module's nats_streams reads back. Its size cap is not
@@ -31,8 +39,8 @@ type Limits struct {
MaxBytes int64 `json:"max_bytes,omitempty"` MaxBytes int64 `json:"max_bytes,omitempty"`
} }
// Assertion is one module's declared buckets, logs and streams, composed and compared. Asserted is always false in // Assertion is one module's declared buckets, logs and streams, composed and compared. Asserted says this pass created
// shadow: nothing is created or changed. // one the bus lacked (the operator, 2026-10-26 08:54); one the bus holds is never changed.
type Assertion struct { type Assertion struct {
Module string `json:"module"` Module string `json:"module"`
Manifest string `json:"manifest"` Manifest string `json:"manifest"`
@@ -41,9 +49,119 @@ type Assertion struct {
Says string `json:"says"` Says string `json:"says"`
} }
// ShadowAsserts is what every assertion says in this milestone. // ShadowAsserts is what an assertion says where nothing may be created: in the tools, which ask the part's process.
const ShadowAsserts = "in shadow: composed and compared, nothing asserted (ADR 0547 clause 5 asserts from take-over)" const ShadowAsserts = "in shadow: composed and compared, nothing asserted (ADR 0547 clause 5 asserts from take-over)"
// CreatesMissing is what an assertion of the part's process says (the operator, 2026-10-26 08:54; issue 1790).
const CreatesMissing = "a bucket, log or stream the manifest declares and the bus lacks is created with the manifest's limits, once per " +
"manifest; one the bus holds is never changed or deleted, a difference is only said (ADR 0547 clause 5 asserts the rest from take-over)"
// Creator creates one stream the bus does not hold, with the configuration given, and never updates or deletes one: the
// part's process alone, whose account is granted the create of each declared stream by name (the operator, 2026-10-26
// 08:54). A stream the bus holds already answers ErrHeld.
type Creator interface {
CreateDeclared(cfg jetstream.StreamConfig) error
}
// ErrHeld is a create refused because the bus holds the stream already (made meanwhile): left as it is.
var ErrHeld = errors.New("the bus holds it already, so it is left as it is")
// Created words of an item.
const (
ItemCreated = "created"
ItemNotCreated = "not created: "
)
// Configs are the streams a manifest declares, as the controller asserts them at its start (mesh-sdk buscompose), by
// stream name. Pure.
func Configs(m catalogue.Manifest) map[string]jetstream.StreamConfig {
out := map[string]jetstream.StreamConfig{}
for _, b := range bucketsOf(m) {
c := buscompose.BucketStreamConfig(b)
out[c.Name] = c
}
for _, l := range logsOf(m) {
c := l.Config()
out[c.Name] = c
}
for _, s := range streamsOf(m) {
c := buscompose.ModuleStreamConfig(s)
out[c.Name] = c
}
return out
}
// creations remembers each stream created, by the manifest digest it was created at: created once per manifest, so a
// stream deleted since is not made again until a new manifest or a hand assert (grants_assert).
type creations struct {
mu sync.Mutex
made map[string]string
}
func (c *creations) madeAt(stream string) string {
c.mu.Lock()
defer c.mu.Unlock()
return c.made[stream]
}
func (c *creations) keep(stream, digest string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.made == nil {
c.made = map[string]string{}
}
c.made[stream] = digest
}
// createMissing creates each item of a the bus lacks, with the manifest's limits, and says what became of it. It never
// touches an item the bus holds, and does nothing when what the bus holds was not read. force asks again a stream
// created at this manifest and gone since (a hand assert). It answers the streams created.
func createMissing(c Creator, done *creations, a *Assertion, m catalogue.Manifest, force bool) []string {
cfgs := Configs(m)
var made []string
a.Says = CreatesMissing
for i := range a.Items {
it := &a.Items[i]
if it.Held != nil || !slices.Contains(it.Differences, "the bus holds no stream "+it.Stream) {
continue
}
if !force && done.madeAt(it.Stream) == a.Manifest {
it.Created = ItemNotCreated + "created at this manifest and gone since; grants_assert creates it again"
continue
}
cfg, ok := cfgs[it.Stream]
if !ok {
it.Created = ItemNotCreated + "no configuration composed for it"
continue
}
switch err := c.CreateDeclared(cfg); {
case err == nil:
it.Created = ItemCreated
done.keep(it.Stream, a.Manifest)
made = append(made, it.Kind+" "+it.Stream)
case errors.Is(err, ErrHeld):
it.Created = ItemNotCreated + ErrHeld.Error()
default:
it.Created = ItemNotCreated + err.Error() + " (tried again at the next event)"
}
}
if len(made) > 0 {
a.Asserted = true
}
return made
}
// notCreated is each item of a creation refused, with why, as `<stream>: <why>`.
func notCreated(a Assertion) []string {
var out []string
for _, it := range a.Items {
if why, ok := strings.CutPrefix(it.Created, ItemNotCreated); ok && it.Created != "" {
out = append(out, it.Stream+": "+why)
}
}
return out
}
// Composed is a module's buckets, logs and streams as an assertion would ask for them. Pure. // Composed is a module's buckets, logs and streams as an assertion would ask for them. Pure.
func Composed(m catalogue.Manifest) []Item { func Composed(m catalogue.Manifest) []Item {
var out []Item var out []Item
+15 -4
View File
@@ -36,8 +36,9 @@ type Follower struct {
watched map[string]bool watched map[string]bool
refused map[string]string refused map[string]string
have map[string]string have map[string]string
// held is what the last pass held back, said again only when it changes. // held and notAsserted are what the last pass held back and could not create, said again only when they change.
held string held string
notAsserted string
} }
func (f *Follower) init() { func (f *Follower) init() {
@@ -145,8 +146,9 @@ func (f *Follower) open() {
} }
} }
// said logs every membership the pass held back, each with what it would lose (issue 1945), when the set changes from // said logs every membership the pass held back, each with what it would lose (issue 1945), and every declared stream
// the last pass's. // the pass could not create, each with why (issue 1790), when the set changes from the last pass's; and every stream it
// created, one line per module.
func (f *Follower) said(res Result) { func (f *Follower) said(res Result) {
if held := strings.Join(res.NotPublished, "\n"); held != f.held { if held := strings.Join(res.NotPublished, "\n"); held != f.held {
f.held = held f.held = held
@@ -160,6 +162,15 @@ func (f *Follower) said(res Result) {
} }
} }
} }
for _, a := range res.Asserted {
f.logf("asserted %s", a)
}
if not := strings.Join(res.NotAsserted, "\n"); not != f.notAsserted {
f.notAsserted = not
for _, n := range res.NotAsserted {
f.logf("not asserted %s", n)
}
}
} }
// Run composes at start and after every change until ctx ends. It returns only when ctx ends. // Run composes at start and after every change until ctx ends. It returns only when ctx ends.
@@ -1,8 +1,10 @@
// Package grants is the grants part in shadow (novox/hq ADR 0547 clauses 5 to 8, issue 1945): it composes the bus's user // Package grants is the grants part in shadow (novox/hq ADR 0547 clauses 5 to 8, issue 1945): it composes the bus's user
// list, each module's membership and each manifest's buckets, logs and streams from what the bus holds, with the bus // list, each module's membership and each manifest's buckets, logs and streams from what the bus holds, with the bus
// composer the controller uses (mesh-sdk buscompose), and compares them with what the bus loaded and holds. It writes only // composer the controller uses (mesh-sdk buscompose), and compares them with what the bus loaded and holds. Beyond its own
// its own state and takes no effect: it publishes no membership, asserts nothing, writes no grant and no user list the // state it takes effect in two things alone, each by a grant the operator allowed: it publishes memberships (2026-10-25
// bus node reads, until the operator's take-over, which this milestone always refuses. // 22:31), and creates a bucket, log or stream a manifest declares that the bus lacks (2026-10-26 08:54, issue 1790). It
// changes no stream the bus holds, writes no grant and no user list the bus node reads, until the operator's take-over,
// which this milestone always refuses.
package grants package grants
import ( import (
+59 -14
View File
@@ -35,10 +35,12 @@ var States = []string{StateAssertions, StateMemberships, StateTakeover, StateUse
const ( const (
EventUserList = "user-list-changed" EventUserList = "user-list-changed"
EventMembership = "membership-changed" EventMembership = "membership-changed"
// EventAsserted is said when the part created a bucket, log or stream a manifest declares (issue 1790).
EventAsserted = "asserted"
) )
// Emits is every event the part says. // Emits is every event the part says.
var Emits = []string{EventMembership, EventUserList} var Emits = []string{EventAsserted, EventMembership, EventUserList}
// Shadow is the switch's one state in this milestone: the bus node follows the controller's list. // Shadow is the switch's one state in this milestone: the bus node follows the controller's list.
const Shadow = "shadow" const Shadow = "shadow"
@@ -83,21 +85,25 @@ func (s UserListState) inputs() string {
// Result is what one composition did. // Result is what one composition did.
type Result struct { type Result struct {
UserList UserListState `json:"user_list"` UserList UserListState `json:"user_list"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision"`
Written bool `json:"written"` Written bool `json:"written"`
Memberships int `json:"memberships"` Memberships int `json:"memberships"`
MembershipsWritten int `json:"memberships_written"` MembershipsWritten int `json:"memberships_written"`
Published []string `json:"published,omitempty"` Published []string `json:"published,omitempty"`
NotPublished []string `json:"not_published,omitempty"` NotPublished []string `json:"not_published,omitempty"`
Assertions int `json:"assertions"` Assertions int `json:"assertions"`
AssertionsWritten int `json:"assertions_written"` AssertionsWritten int `json:"assertions_written"`
Errors []string `json:"errors,omitempty"` // Asserted is each module whose declared streams this pass created, `<module> at <digest>: created …`, and
PublishedOrAsserted string `json:"published_or_asserted"` // NotAsserted each creation refused, `<module> <stream>: <why>` (issue 1790).
Asserted []string `json:"asserted,omitempty"`
NotAsserted []string `json:"not_asserted,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
} }
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships. // NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
const NothingTakesEffect = "memberships only (on the assignments stream, a first one or one that only adds to the last published or drops only a verb the seat catalogue retired; any other loss stays a shadow difference); the user list, assertions and grants stay in shadow until take-over" const NothingTakesEffect = "memberships (on the assignments stream, a first one or one that only adds to the last published or drops only a verb the seat catalogue retired; any other loss stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over"
// Part is the grants part: it reads, calls and writes through these, and nothing else. // Part is the grants part: it reads, calls and writes through these, and nothing else.
type Part struct { type Part struct {
@@ -107,11 +113,36 @@ type Part struct {
Emit Emitter Emit Emitter
// Publish publishes memberships: the process's connection; nil in the tools, which ask the process instead. // Publish publishes memberships: the process's connection; nil in the tools, which ask the process instead.
Publish Publisher Publish Publisher
Now func() time.Time // Create creates a declared stream the bus lacks: the process's connection; nil in the tools, which ask the process.
Create Creator
Now func() time.Time
cache Manifests cache Manifests
mu sync.Mutex mu sync.Mutex
last Result last Result
made creations
// askSeen is the revision of the last ask to compose (KeyAsked) this part acted on.
askSeen uint64
}
// Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing
// streams again even where they were created at this manifest before.
type Ask struct {
By string `json:"by"`
At time.Time `json:"at"`
Assert string `json:"assert,omitempty"`
}
// askedAssert is the module a new ask names to assert by hand, read once per ask; empty when none.
func (p *Part) askedAssert() string {
raw, rev, found, err := p.Store.Get(StateUserList, KeyAsked)
if err != nil || !found || rev == p.askSeen {
return ""
}
p.askSeen = rev
var a Ask
_ = json.Unmarshal(raw, &a)
return a.Assert
} }
func (p *Part) now() time.Time { func (p *Part) now() time.Time {
@@ -187,11 +218,25 @@ func (p *Part) Compose() Result {
} }
} }
held, heldErr := HeldStreams(p.Call) held, heldErr := HeldStreams(p.Call)
forced := ""
if p.Create != nil {
forced = p.askedAssert()
}
for _, module := range sortedKeys(in.Manifests) { for _, module := range sortedKeys(in.Manifests) {
if !assigned(in, module) { if !assigned(in, module) {
continue continue
} }
a := Assert(module, in.Digests[module], in.Manifests[module], held, heldErr) a := Assert(module, in.Digests[module], in.Manifests[module], held, heldErr)
// Created only where the bus was read and lacks it, never changed where it holds it (issue 1790).
if p.Create != nil && heldErr == nil {
if made := createMissing(p.Create, &p.made, &a, in.Manifests[module], forced == module); len(made) > 0 {
res.Asserted = append(res.Asserted, fmt.Sprintf("%s at %s: created %s", module, a.Manifest, strings.Join(made, ", ")))
p.say(&res, EventAsserted, map[string]any{"module": module, "manifest": a.Manifest, "created": made})
}
for _, why := range notCreated(a) {
res.NotAsserted = append(res.NotAsserted, module+" "+why)
}
}
res.Assertions++ res.Assertions++
data, _ := json.Marshal(a) data, _ := json.Marshal(a)
if p.changed(&res, StateAssertions, module, data) { if p.changed(&res, StateAssertions, module, data) {
+25 -3
View File
@@ -137,7 +137,7 @@ const NoGrantAgain = "missing: a verb to ask the controller to write a module's
// AskCompose is the tool compose-now: it asks the part's process to compose and publish now (only the process holds the // AskCompose is the tool compose-now: it asks the part's process to compose and publish now (only the process holds the
// grant to publish a membership), and answers the last pass, whose revision moves when the process answered. // grant to publish a membership), and answers the last pass, whose revision moves when the process answered.
func (p *Part) AskCompose(caller string) (any, error) { func (p *Part) AskCompose(caller string) (any, error) {
ask, _ := json.Marshal(map[string]any{"by": caller, "at": p.now()}) ask, _ := json.Marshal(Ask{By: caller, At: p.now()})
rev, err := p.Store.Put(StateUserList, KeyAsked, ask) rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
if err != nil { if err != nil {
return nil, fmt.Errorf("the ask could not be written: %w", err) return nil, fmt.Errorf("the ask could not be written: %w", err)
@@ -180,7 +180,9 @@ func (p *Part) UserList() (any, error) {
} }
// AssertOne answers the tool assert: one module's buckets, logs and streams as composed from its manifest at the // AssertOne answers the tool assert: one module's buckets, logs and streams as composed from its manifest at the
// indexer's trunk, beside what the bus holds. It asserts nothing. // indexer's trunk, beside what the bus holds, and asks the part's process (which alone holds the grant to create) to
// create what the bus lacks now, even one it created at this manifest and that is gone since (issue 1790). It changes no
// stream the bus holds. Here, in the tools, it creates nothing itself: the answer names the ask.
func (p *Part) AssertOne(module string) (any, error) { func (p *Part) AssertOne(module string) (any, error) {
if module == "" { if module == "" {
return nil, errors.New("assert names a module") return nil, errors.New("assert names a module")
@@ -201,7 +203,27 @@ func (p *Part) AssertOne(module string) (any, error) {
return nil, err return nil, err
} }
held, heldErr := HeldStreams(p.Call) held, heldErr := HeldStreams(p.Call)
return Assert(module, v.Manifest, m, held, heldErr), nil a := Assert(module, v.Manifest, m, held, heldErr)
out := map[string]any{"assertion": a}
missing := 0
for _, it := range a.Items {
if it.Held == nil {
missing++
}
}
if missing == 0 {
out["says"] = "the bus holds every bucket, log and stream the manifest declares: nothing to create; a difference is only said"
return out, nil
}
ask, _ := json.Marshal(Ask{By: "grants_assert", At: p.now(), Assert: module})
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
if err != nil {
return nil, fmt.Errorf("the ask to create could not be written: %w", err)
}
out["asked"] = rev
out["says"] = fmt.Sprintf("%d the bus lacks: the part's process creates them at this ask if %s is placed (its grant names only placed "+
"modules' streams) and says asserted; grants_assert again shows what the bus holds then", missing, module)
return out, nil
} }
// Answer is a tool's arguments read as text. // Answer is a tool's arguments read as text.
+1
View File
@@ -19,6 +19,7 @@
"nats.nats_users" "nats.nats_users"
], ],
"emits": [ "emits": [
"asserted",
"membership-changed", "membership-changed",
"user-list-changed" "user-list-changed"
], ],