Compare commits

..
Author SHA1 Message Date
jschoubben 289d1e8fa6 Format mesh-delivery-view's read.go as gofmt writes it (issue 1126, the group check's failure)
mesh/delivery ready: it is merged on the Approve
mesh/check pass 289d1e8fa69a on bab2e1391ae5 | unit 2860 run, 1141 kept, 36 skipped | integration 2887 run, 1241 kept, 29 skipped | over bound: unit (…
mesh/delivery-group group fix/1126-shadow-bodies ready: every member ready, and composed together they pass
2026-10-28 01:49:54 +01:00
jschoubben 968b9bf2e6 mesh-delivery-view: a node's candidate answer is shown as the node wrote it; pin mesh-sdk 362a3f1 (issue 1126, probe #532)
mesh/check failed (2 tests): unit: modules/mesh-delivery-view.gofmt: not as gofmt writes them (run gofmt -w): modules/mesh-delivery-view/internal/view…
mesh/delivery-group group fix/1126-shadow-bodies rejected: composed together: the checker's verdict at the members' heads (46e1e846f8db): unit: modules/mesh-de…
mesh/delivery closed: a newer head of the same pull request
The bodies of each difference never reached the answer shown: the view decoded
the node's answer with its mesh-sdk, which did not know the field, and encoded
it again. The answer now rides through as stored; a decoded copy only says its
state.
2026-10-28 01:39:34 +01:00
mesh-tools bab2e1391a Merge pull request 'mesh-grants: stop rewriting the shadow user list every 3 s when nothing changed (issue 1126, group fix/1126-switch-safe)' (#91) from fix/1126-switch-safe into main 2026-10-27 23:52:23 +00:00
jschoubben 663c9cbc34 mesh-grants: pin mesh-sdk 7359d18, the group's head (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-delivery@bab2e1391ae5
mesh/delivery-group group fix/1126-switch-safe merged: every member is merged
mesh/check pass 663c9cbc348d on a49fd9cc65f7 | unit 2992 run, 1008 kept, 36 skipped | integration 3025 run, 1102 kept, 29 skipped | over bound: unit (…
2026-10-28 00:34:13 +01:00
jschoubben 3d916a685e mesh-grants: the same list said again moves neither the sealed key nor the shadow list (issue 1126)
Live since the take-over the shadow list was written every 3 s (revision 99270
to 99357 in 90 s, nothing differing): the sealed key read back says [] for the
nil left-out a pass composes, so every pass wrote the key again; each write
moved the bus node's report of what it loaded, whose revision and time were
inputs of the shadow list, and woke the next pass. The bus itself reloads only
when the list's bytes change (its log: two configurations alternating with the
placement, f8567865 and 473f9ad3), so this loop was load, not the reload.
2026-10-28 00:20:44 +01:00
26 changed files with 142 additions and 1232 deletions
@@ -170,21 +170,6 @@ func TryTools(tr Trying) []stdio.Tool {
}
}
// Seat is the seat the checker holds (novox/hq ADR 0547 §10, issue 774); its verbs are served as tools named <seat>.<verb>.
const Seat = "mesh-delivery-checker"
// SeatTryTools are the try's tools as the seat's verbs: try, try-read and try-end, run by the same code as checker_try,
// checker_try_read and checker_try_end, so the answer is the same. The module tools stay beside them.
func SeatTryTools(tr Trying) []stdio.Tool {
verbs := map[string]string{check.TryTool: "try", check.TryReadTool: "try-read", check.TryEndTool: "try-end"}
var out []stdio.Tool
for _, t := range TryTools(tr) {
t.Name = Seat + "." + verbs[t.Name]
out = append(out, t)
}
return out
}
// Tools are the read tools over the checker's states.
func Tools(r check.Reader) []stdio.Tool {
return []stdio.Tool{
@@ -292,7 +277,7 @@ func main() {
return err
}}
trying := Trying{Tries: store, Ender: store, Trunks: trunks}
if err := stdio.Serve("", logged(append(append(Tools(r), TryTools(trying)...), SeatTryTools(trying)...))); err != nil {
if err := stdio.Serve("", logged(append(Tools(r), TryTools(trying)...))); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err)
os.Exit(1)
}
@@ -80,24 +80,6 @@ func TestTheTryToolsAskAndReadATry(t *testing.T) { // ADR 0501 §5, the agreed i
if err != nil || !strings.Contains(string(raw.(json.RawMessage)), `"state":"queued"`) {
t.Errorf("read = %s, %v", raw, err)
}
// The seat's verbs answer as the tools do (ADR 0547 §10, issue 774): the same ask is the same try, and the read is alike.
verbs := SeatTryTools(Trying{Tries: store, Ender: store, Now: func() time.Time { return time.Unix(1, 5) },
Trunks: func() (map[string]string, error) {
return map[string]string{"novox/mesh-delivery": strings.Repeat("a", 40)}, nil
}})
if len(verbs) != 3 || verbs[0].Name != Seat+".try" || verbs[1].Name != Seat+".try-read" || verbs[2].Name != Seat+".try-end" {
t.Fatalf("verbs = %+v", verbs)
}
again, err := verbs[0].Run(map[string]any{"repository": "novox/mesh-delivery", "head": strings.Repeat("1", 40), "trunk": "main"})
if err != nil || again.(map[string]any)["id"] != id {
t.Errorf("verb try = %v, %v, want the waiting try %s", again, err, id)
}
if viaVerb, err := verbs[1].Run(map[string]any{"id": id}); err != nil || string(viaVerb.(json.RawMessage)) != string(raw.(json.RawMessage)) {
t.Errorf("verb try-read = %s, %v; tool = %s", viaVerb, err, raw)
}
if _, err := verbs[0].Run(map[string]any{"repository": "novox/hq", "head": strings.Repeat("1", 40)}); err == nil {
t.Error("the verb tried a repository nothing checks")
}
// Issue 734: a try whose ask was lost is ended by hand with why, and its head is asked anew.
ended, err := tools[2].Run(map[string]any{"id": id, "why": "its ask was lost"})
if err != nil || ended.(map[string]any)["state"] != "error" {
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-checker
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952
github.com/nats-io/nats-server/v2 v2.11.17
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.20261027220144-3f7957e16972 h1:iYDYv4HpV1ic20Ban0MaOErryQfNT8ngjlh0bKW6sCU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -11
View File
@@ -38,11 +38,6 @@ type stream struct {
}
type manifest struct {
Claims []struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
} `json:"claims"`
Streams []stream `json:"streams"`
Tools []string `json:"tools"`
Module string `json:"module"`
@@ -133,12 +128,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
if want := []string{"checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// The try is the checker's own seat's verbs (ADR 0547 §10, issue 774), served beside the tools by the same code.
if len(m.Claims) != 1 || m.Claims[0].Name != "mesh-delivery-checker" || m.Claims[0].Scope != "mesh" ||
!reflect.DeepEqual(m.Claims[0].Serves, []string{"try", "try-read", "try-end"}) {
t.Errorf("claims = %+v, want the mesh seat mesh-delivery-checker serving try, try-read and try-end", m.Claims)
}
for _, k := range []string{"logs", "emits", "provides", "requires", "defines-seats", "seat", "seats", "bus", "config"} {
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus", "config"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the checker has none", k)
}
-11
View File
@@ -2,17 +2,6 @@
"module": "mesh-delivery-checker",
"version": "1",
"slug": "checker",
"claims": [
{
"name": "mesh-delivery-checker",
"scope": "mesh",
"serves": [
"try",
"try-read",
"try-end"
]
}
],
"consumes": [
"mesh-build-go.toolchain.reported",
"mesh-build-node.toolchain.reported"
@@ -154,7 +154,10 @@ func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) {
return raw, revs[key], nil
}}
p.HavesState = fake{
"ace.node": `{"v":1,"composed":"x","candidates":{"feat/p":{"head":"` + h2 + `","verdict":"same","same":["plex"]}}}`,
// ace's answer as the node wrote it, with what this view's mesh-sdk may not know (issue 1126, probe #532: the
// bodies of each difference were dropped on the way): shown as stored.
"ace.node": `{"v":1,"composed":"x","candidates":{"feat/p":{"head":"` + h2 + `","verdict":"same","same":["plex"],` +
`"differs":[],"note":"took over a minute","future":{"bodies":[{"id":"x","composed":"<masked>"}]}}}}`,
"g14.node": `{"v":1,"composed":"x","candidates":{"feat/p":{"head":"` + h1 + `","revision":2,"verdict":"same"}}}`,
}.reader().State
out, err = run(t, p, view.PlacementReadTool, map[string]any{})
@@ -164,6 +167,9 @@ func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) {
answered := map[string]string{}
for _, c := range out.(map[string]any)["candidates"].([]view.CandidateRow) {
answered[c.Node] = c.Answered
if c.Node == "ace" && (!strings.Contains(string(c.Answer), `"future":{"bodies"`) || !strings.Contains(string(c.Answer), `"note":"took over a minute"`)) {
t.Errorf("ace's answer is not shown as stored: %s", c.Answer)
}
}
if want := map[string]string{"ace": "same", "g14": view.AnswerStale, "novox": view.AnswerNone}; fmt.Sprint(answered) != fmt.Sprint(want) {
t.Errorf("answers %v, want %v", answered, want)
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-view
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028003907-362a3f1378aa
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028003907-362a3f1378aa h1:DFYX7pIluzeeEyM+YXGeB6GvEnOYyRhz/1Ke/LFa8+8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028003907-362a3f1378aa/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -726,14 +726,16 @@ const (
// CandidateRow is one placement candidate and the node's answer to it.
type CandidateRow struct {
Group string `json:"group"`
Node string `json:"node"`
Pull delivery.Head `json:"pull"`
Revision uint64 `json:"revision"`
Placement json.RawMessage `json:"placement,omitempty"` // the candidate value as stored
Removes bool `json:"removes,omitempty"` // the head removes the node's file
Answered string `json:"answered"` // same, differs, refused, stale or none
Answer *delivery.CandidateAnswer `json:"answer,omitempty"`
Group string `json:"group"`
Node string `json:"node"`
Pull delivery.Head `json:"pull"`
Revision uint64 `json:"revision"`
Placement json.RawMessage `json:"placement,omitempty"` // the candidate value as stored
Removes bool `json:"removes,omitempty"` // the head removes the node's file
Answered string `json:"answered"` // same, differs, refused, stale or none
// Answer is the node's answer as its node wrote it, whole: what this view's mesh-sdk does not know rides through (issue
// 1126, probe #532: each difference's bodies were dropped when the answer was decoded and encoded again here).
Answer json.RawMessage `json:"answer,omitempty"`
}
// PlacementCandidates is every placement candidate, narrowed by node and by a module its placement names, with each
@@ -773,8 +775,8 @@ func (r Reader) PlacementCandidates(node, module string) ([]CandidateRow, error)
placed map[string]bool
}
groups := map[string]*group{}
answers := map[string]map[string]delivery.CandidateAnswer{} // node, then group
answerOf := func(n, g string) (delivery.CandidateAnswer, bool) {
answers := map[string]map[string]storedAnswer{} // node, then group
answerOf := func(n, g string) (storedAnswer, bool) {
if _, read := answers[n]; !read {
answers[n] = r.candidateAnswers(n)
}
@@ -818,8 +820,8 @@ func (r Reader) PlacementCandidates(node, module string) ([]CandidateRow, error)
continue
}
if a, ok := answerOf(n, gname); ok && err == nil {
row.Answer = &a
row.Answered = AnswerOf(c.Pull.Head, rev, true, a)
row.Answer = a.raw
row.Answered = AnswerOf(c.Pull.Head, rev, true, a.a)
}
rows = append(rows, row)
}
@@ -841,8 +843,8 @@ func (r Reader) PlacementCandidates(node, module string) ([]CandidateRow, error)
}
row := CandidateRow{Group: gname, Node: n, Pull: g.pull, Answered: AnswerNone}
if a, ok := answerOf(n, gname); ok {
row.Answer = &a
row.Answered = AnswerOf(g.pull.Head, 0, false, a)
row.Answer = a.raw
row.Answered = AnswerOf(g.pull.Head, 0, false, a.a)
}
rows = append(rows, row)
}
@@ -859,8 +861,15 @@ func AnswerOf(head string, rev uint64, keyed bool, a delivery.CandidateAnswer) s
return a.Verdict
}
// candidateAnswers is a node's answers to its candidates, by group, from its `<node>.node` have; none when it has none.
func (r Reader) candidateAnswers(node string) map[string]delivery.CandidateAnswer {
// storedAnswer is a node's answer to one group: as stored, and read for its head, revision and verdict.
type storedAnswer struct {
raw json.RawMessage
a delivery.CandidateAnswer
}
// candidateAnswers is a node's answers to its candidates, by group, from its `<node>.node` have, each as stored; none
// when it has none.
func (r Reader) candidateAnswers(node string) map[string]storedAnswer {
if r.HavesState.Get == nil {
return nil
}
@@ -872,9 +881,21 @@ func (r Reader) candidateAnswers(node string) map[string]delivery.CandidateAnswe
if err != nil || len(raw) == 0 {
return nil
}
h, err := delivery.DecodeHaveNode(raw)
if err != nil {
if _, err := delivery.DecodeHaveNode(raw); err != nil {
return nil
}
return h.Candidates
var stored struct {
Candidates map[string]json.RawMessage `json:"candidates"`
}
if json.Unmarshal(raw, &stored) != nil {
return nil
}
out := make(map[string]storedAnswer, len(stored.Candidates))
for g, ans := range stored.Candidates {
var a delivery.CandidateAnswer
if json.Unmarshal(ans, &a) == nil {
out[g] = storedAnswer{raw: ans, a: a}
}
}
return out
}
@@ -214,10 +214,6 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
return s.c.State(state).Put(key, json.RawMessage(value))
}
func (s ownStore) Keys(state string) ([]string, error) { return s.c.State(state).Keys() }
func (s ownStore) Delete(state, key string) error { return s.c.State(state).Delete(key) }
// emitting is the process connection as the part's emitter: its events, with no options.
type emitting struct{ c *process.Conn }
@@ -18,41 +18,6 @@ type creating struct {
js jetstream.JetStream
}
// CreateConsumer creates one durable consumer an account needs, by its stream and name (ADR 0561 §6): the create alone;
// one the bus holds answers ErrHeld and is left as it is.
func (c creating) CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error {
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
defer cancel()
if _, err := c.js.Consumer(ctx, stream, cfg.Durable); err == nil {
return grants.ErrHeld
}
_, err := c.js.CreateConsumer(ctx, stream, cfg)
switch {
case err == nil:
return nil
case errors.Is(err, jetstream.ErrConsumerExists):
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
}
// MatchConsumer brings a held consumer to cfg on a person's ask (grants_consumer_match), keeping where it delivers from:
// the server refuses a changed deliver policy, and a consumer moved would replay or lose what it holds.
func (c creating) MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error {
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
defer cancel()
held, err := c.js.Consumer(ctx, stream, cfg.Durable)
if err != nil {
return err
}
have := held.CachedInfo().Config
cfg.DeliverPolicy, cfg.OptStartSeq, cfg.OptStartTime = have.DeliverPolicy, have.OptStartSeq, have.OptStartTime
_, err = c.js.UpdateConsumer(ctx, stream, cfg)
return err
}
func (c creating) CreateDeclared(cfg jetstream.StreamConfig) error {
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
defer cancel()
+12 -9
View File
@@ -62,9 +62,14 @@ func main() {
}
}
// keptStates is what the part keeps (ADR 0495 §2-§3): each readable, for the view and a session, but the wanted accounts,
// by the vault alone (ADR 0561 §2).
func keptStates() []announce.State { return grants.Announced() }
// keptStates is what the part keeps, each readable (ADR 0495 §2-§3): the view and a session read them.
func keptStates() []announce.State {
var out []announce.State
for _, s := range grants.States {
out = append(out, announce.State{Name: s, Readable: true})
}
return out
}
// run is the process: it returns when ctx ends, or when it has no credential or cannot dial the bus at all. A bucket or
// read the bus refuses never ends it.
@@ -112,9 +117,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
bus := newBusReader(ctx, js)
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}, Consumers: creating{ctx: ctx, js: js},
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf,
Bus: grants.BusAddress{URL: account.URL, Fingerprint: account.Fingerprint}}
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js},
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf}
f.Watch = bus
// The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on
// the warrant once (mesh-sdk asker), after its refusals.
@@ -134,9 +138,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
})
}
// The controller makes this module's durable consumer on EVENTS from the manifest's consumes (filtered on the two
// condition subjects and the vault's account-issued; ADR 0561 §6 gives that to the part from the lease write); it is
// pulled and every message acknowledged, so its pending never grows. Where it is not there yet the pass says so and
// hears again at the next change. The vault's bus-accounts is watched beside it, so a hash is composed either way.
// condition subjects); it is pulled and every message acknowledged, so its pending never grows. Where it is not there
// yet the pass says so and hears again at the next change.
f.Hear = func(handle func(string, []byte)) error {
cons, err := js.Consumer(ctx, "EVENTS", account.Node+"_"+account.Module)
if err != nil {
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -40,10 +39,6 @@ func (runtimeStore) Put(state, key string, value []byte) (uint64, error) {
return stdio.State(state).Put(key, json.RawMessage(value))
}
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
// runtimeBus calls tools and says events through the runtime.
type runtimeBus struct{}
@@ -118,43 +113,6 @@ func toolsOf(p *grants.Part) []stdio.Tool {
Run: func(a map[string]any) (any, error) {
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
}},
{Name: grants.ToolAccounts, Description: "The bus accounts the mesh wants (novox/hq ADR 0561 §2, §9): every account the vault " +
"mints that placement composes (a module's, a process's, a machine's provisioner client's), as the grants part last " +
"wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " +
"fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; " +
"what the last pass wrote, deleted or kept; and the durable consumers the accounts need as the last pass found them " +
"(missing, differing and why, created; whether the lease is handed away). Never a password. Read-only: " +
"grants_compose_now writes them again.",
Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}),
Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }},
{Name: grants.ToolConsumerMatch, Description: "Bring one durable consumer an account needs (a module's on EVENTS, a process's on " +
"its work queue; novox/hq ADR 0561 §6) to the configuration the bus composer composes, by hand: the recovery when " +
"the condition mesh-grants.consumer-differs names it. Asks the part's process, which alone may, and keeps where the " +
"consumer delivers from. Refused while the controller holds its lease (the consumers are the controller's then) and " +
"for a consumer the records do not compose. grants_user_list's last pass says what became of it.",
Input: schema(map[string]string{"stream": "the stream, e.g. EVENTS", "consumer": "the consumer, e.g. ace_mesh-build-go"}, "stream", "consumer"),
Run: func(a map[string]any) (any, error) {
return p.MatchConsumer(grants.Answer(a, "stream"), grants.Answer(a, "consumer"))
}},
{Name: grants.ToolAcceptHashes, Description: "Release the hold of a user list that changes several loaded users' hashes at " +
"once (novox/hq ADR 0561 §7; the condition mesh-grants.user-list-held names them), by a person's act with why: names " +
"exactly the users held, bound to the held list's digest, so the part's process writes that list once at its next pass " +
"and says hashes-accepted. It never releases another hold (a failed read, a user dropped for want of a hash, a lost " +
"right), and a list composed otherwise since is held again for a new acceptance.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"users": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the users held, as the condition names them"},
"why": map[string]any{"type": "string", "description": "why their hashes change"}}, "required": []string{"users", "why"}},
Run: func(a map[string]any) (any, error) {
var users []string
if list, ok := a["users"].([]any); ok {
for _, u := range list {
if s, ok := u.(string); ok {
users = append(users, strings.TrimSpace(s))
}
}
}
return p.AcceptHashes(stdio.CallerOf(a), users, grants.Answer(a, "why"))
}},
{Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " +
"every act with what became of it (asked, switched, declined, expired, refused and why).",
Input: schema(nil),
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-grants
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027232145-7359d187db8d
github.com/nats-io/nats.go v1.54.0
)
+14 -2
View File
@@ -1,5 +1,17 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972 h1:iYDYv4HpV1ic20Ban0MaOErryQfNT8ngjlh0bKW6sCU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027220144-3f7957e16972/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117 h1:88CNZOoQgu7YPYKW16qkWT8Gw9Ucg07aTrIImA2nYUk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f h1:AKzV973UTWe0OYFoIjgGu0f+B26xh8QsAHzCpsTIjM8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f h1:mimAGERguQmCGYVNdRTrFcZ1dhpU6GWo+Q3EDhHOrVk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027195758-cebc6519d5b0 h1:9B7B38e3RtjbtX1uGP9dRgU9VY1/J2yAfnb5Ajoq4/s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027195758-cebc6519d5b0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027200317-538ffbaa085b h1:LuBK5dtBgm978krhj41N2MKA07Yv4zetRPOdHRTB6L8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027200317-538ffbaa085b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648 h1:XoMsgg9kSgjQWa86NZM8h8OpxJ2kovxcMS2C0i6WSVU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027232145-7359d187db8d h1:DhR7DRKaF0OtA5l/ikIk7gZGeIM1lRz65PUE1DGEkvU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027232145-7359d187db8d/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+15 -327
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
@@ -58,45 +57,6 @@ type fakeBus struct {
refuseUsers bool
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
rootFree func(machines []string) (any, error)
// consumers are the durable consumers the bus holds, by stream, as nats_streams answers them for one stream; and
// consumerCreates each consumer the part asked to create, `<stream>.<name>`.
consumers map[string][]map[string]any
consumerCreates []string
}
// MatchConsumer is the bus's update of a held durable consumer to the configuration given.
func (b *fakeBus) MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.consumerCreates = append(b.consumerCreates, "match "+stream+"."+cfg.Durable)
for _, c := range b.consumers[stream] {
if c["name"] == cfg.Durable {
c["max_deliver"] = cfg.MaxDeliver
return nil
}
}
return errors.New("consumer not found")
}
// CreateConsumer is the bus's create of a durable consumer: held when the bus holds one of that name, else held from now.
func (b *fakeBus) CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.consumerCreates = append(b.consumerCreates, stream+"."+cfg.Durable)
for _, c := range b.consumers[stream] {
if c["name"] == cfg.Durable {
return grants.ErrHeld
}
}
filters := append([]string{}, cfg.FilterSubjects...)
if cfg.FilterSubject != "" {
filters = []string{cfg.FilterSubject}
}
if b.consumers == nil {
b.consumers = map[string][]map[string]any{}
}
b.consumers[stream] = append(b.consumers[stream], map[string]any{"name": cfg.Durable, "filters": filters, "max_deliver": cfg.MaxDeliver})
return nil
}
func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) {
@@ -217,9 +177,6 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
}
return json.Marshal(map[string]any{"users": out})
case grants.CallStreams:
if stream, _ := args["stream"].(string); stream != "" {
return json.Marshal(map[string]any{"detail": []map[string]any{{"name": stream, "consumer_detail": b.consumers[stream]}}})
}
return json.Marshal(map[string]any{"detail": b.streams})
case grants.CallRootFree:
if b.rootFree == nil {
@@ -260,27 +217,6 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
return b.revs[state+"/"+key], nil
}
func (s ownStore) Keys(state string) ([]string, error) {
b := s.b
b.mu.Lock()
defer b.mu.Unlock()
var out []string
for k := range b.own[state] {
out = append(out, k)
}
slices.Sort(out)
return out, nil
}
func (s ownStore) Delete(state, key string) error {
b := s.b
b.mu.Lock()
defer b.mu.Unlock()
b.writes = append(b.writes, state+" delete")
delete(b.own[state], key)
return nil
}
func (b *fakeBus) Emit(event string, _ any) error {
b.mu.Lock()
defer b.mu.Unlock()
@@ -1366,6 +1302,21 @@ func TestGrants(t *testing.T) {
if e := effect(); !strings.HasPrefix(e, "switched") {
t.Errorf("effect once the bus node loaded the written list = %s", e)
}
// Steady (issue 1126, 2026-10-28 00:16: the shadow list moved every 3 s, 99270 to 99357, with nothing
// differing): the bus node saying again that it loaded the same list, at a later time and the key's
// later revision, moves neither the shadow list nor the sealed key.
_, sealedRev, _ := p.Sealed()
p.Compose()
for i := 0; i < 3; i++ {
withKey(delivery.UserListWritten{Source: delivery.GrantsUserListSource, Revision: sealedRev + uint64(i+1), Wanted: g.Digest,
Digest: g.Digest, Users: g.Users, Follows: delivery.FollowGrants, At: time.Date(2026, 10, 28, 0, 16, i, 0, time.UTC)})
if res := p.Compose(); res.Written {
t.Errorf("pass %d: the shadow list is written again for the bus node saying the same list again (revision %d)", i, res.Revision)
}
if _, rev, _ := p.Sealed(); rev != sealedRev {
t.Errorf("pass %d: the sealed key is written again with nothing changed: revision %d, was %d", i, rev, sealedRev)
}
}
withKey(loadedBefore)
// After take-over a list composed past a failed read, or one dropping a user of the list in force for
// want of a hash, is not sealed: the list in force stays, and the pass says why.
@@ -1872,269 +1823,6 @@ func TestGrants(t *testing.T) {
t.Errorf("the refusal was not said: %v", logged)
}
})
// 774/AC4 (novox/hq ADR 0561 §2): every account the vault mints that the records compose is written to the state
// accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement
// no longer names its user, and never in a pass whose reads failed.
t.Run("wanted accounts: written once each, read by the vault alone, retired only when placement drops them", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"}
p.Compose()
p.Compose()
wrote := 0
for _, w := range b.writes {
if w == grants.StateAccounts {
wrote++
}
}
held := slices.Sorted(maps.Keys(b.own[grants.StateAccounts]))
if !slices.Equal(held, []string{"a.mod-x", "a.nats", "b.mod-y"}) || wrote != 3 {
t.Fatalf("accounts %v written %d times in two passes, want the three modules' once each", held, wrote)
}
nats, err := buscompose.ReadWantedAccount("a.nats", b.own[grants.StateAccounts]["a.nats"])
if err != nil || nats.URL != "nats://a.mesh:4222" || nats.Fingerprint != "ab" || len(nats.Claims) != 1 ||
nats.Claims[0].Seat != catalogue.BrokerSeat || nats.At.IsZero() {
t.Errorf("a.nats is wanted as %+v (%v)", nats, err)
}
var readers []string
for _, s := range grants.Announced() {
if s.Name == grants.StateAccounts {
if s.Readable {
t.Error("the wanted accounts are announced readable by anyone")
}
readers = s.Readers
}
}
if !slices.Equal(readers, []string{"mesh-vault"}) || buscompose.ReaderMay("mesh-delivery-view", buscompose.GrantsAccounts) {
t.Errorf("the wanted accounts are read by %v", readers)
}
// A read that fails retires nothing; once read, the user placement dropped is retired.
delete(b.states[grants.ReadAssignments], "b.mod-y")
b.refused[grants.ReadResolved] = true
if res := p.Compose(); res.Accounts == nil || len(res.Accounts.Deleted) != 0 || b.own[grants.StateAccounts]["b.mod-y"] == nil {
t.Fatalf("a pass whose read failed deleted %+v", res.Accounts)
}
delete(b.refused, grants.ReadResolved)
if res := p.Compose(); res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) {
t.Fatalf("the user placement dropped is not retired: %+v", res.Accounts)
}
// The tool reads them, one and all, each with when it was written.
one, err := p.Accounts("a.nats")
all, err2 := p.Accounts("")
if err != nil || err2 != nil || one.(map[string]any)["count"] != 1 || all.(map[string]any)["count"] != 2 ||
one.(map[string]any)["accounts"].([]buscompose.WantedAccount)[0].At.IsZero() {
t.Errorf("the tool reads %v and %v (%v %v)", one, all, err, err2)
}
if _, err := p.Accounts("b.mod-y"); err == nil {
t.Error("the tool reads an account no longer wanted")
}
p.Pass()
if all, _ := p.Accounts(""); all.(map[string]any)["consumers"] == nil {
t.Errorf("the tool does not say the account consumers in shadow: %v", all)
}
// With no bus address no account is written, and the pass says why.
bare := partOn(fixture())
if res := bare.Compose(); res.Accounts != nil || !strings.Contains(strings.Join(res.Errors, " "), "no bus address") {
t.Errorf("a part with no bus address wrote %+v: %v", res.Accounts, res.Errors)
}
})
// 774/AC5 (novox/hq ADR 0561 §1): the vault's account-issued wakes a pass, so the next list carries the new user's hash
// within one pass; an event of another kind does not.
t.Run("the vault's account-issued composes the list again", func(t *testing.T) {
b := fixture()
var handle func(string, []byte)
p := partOn(b)
f := &grants.Follower{Part: p, Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(string, ...any) {},
Hear: func(h func(string, []byte)) error { handle = h; return nil }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { f.Run(ctx); close(done) }()
defer func() { cancel(); <-done }()
waitFor(t, func() bool { return b.count(grants.CallUsers) > 0 && handle != nil })
held := func() bool {
for _, c := range p.Last().UserList.Credentials.Users {
if c.User == "a.mod-x" {
return c.HashHeld
}
}
return false
}
if held() {
t.Fatal("a.mod-x has a hash before the vault made one")
}
b.mu.Lock()
b.states[grants.ReadAccounts] = map[string][]byte{"a.mod-x": []byte(`{"user":"a.mod-x","hash":"$2a$10$x","generation":1}`)}
b.mu.Unlock()
handle(buscompose.AccountIssuedSubject, []byte(`{"user":"a.mod-x","kind":"module","node":"a","module":"mod-x","generation":1}`))
waitFor(t, held)
if !slices.Contains(grants.Consumes, grants.EventAccountIssued) || !grants.IsAccountIssued(grants.EventAccountIssued) ||
grants.IsAccountIssued("mesh.mod.mesh-vault.event.secret-changed") {
t.Errorf("the part hears %v", grants.Consumes)
}
})
// 774/AC6 (novox/hq ADR 0561 §6): the durable consumers an account needs, with the controller's configuration, are
// compared in shadow while the controller holds the lease, and created, only where the bus lacks them, from the write
// that hands the lease away; one the bus holds is never changed.
t.Run("account consumers: compared while the controller holds the lease, created once it is handed away", func(t *testing.T) {
const cManifest = `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["mod-y.done"]}`
b := fixture()
b.manifestOf("mod-c", cManifest)
b.assign("b", "mod-c")
b.loaded = shadowOf(t, b)
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57})
p := partOn(b)
p.Consumers = b
res := p.Compose()
if res.Consumers == nil || res.Consumers.On || !slices.Contains(res.Consumers.Missing, "EVENTS.b_mod-c") || len(b.consumerCreates) != 0 {
t.Fatalf("while the controller holds the lease: %+v, created %v", res.Consumers, b.consumerCreates)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
b.consumers = map[string][]map[string]any{"EVENTS": {{"name": "b_other", "filters": []string{"x"}, "max_deliver": 5}}}
res = p.Compose()
if !res.Consumers.On || !slices.Equal(res.Consumers.Created, []string{"EVENTS.b_mod-c"}) {
t.Fatalf("once handed away: %+v", res.Consumers)
}
var made map[string]any
for _, c := range b.consumers["EVENTS"] {
if c["name"] == "b_mod-c" {
made = c
}
}
if made == nil || made["max_deliver"] != 5 || !slices.Contains(made["filters"].([]string), "mesh.mod.mod-y.event.done") {
t.Errorf("b_mod-c was made as %v, not as the controller makes it", made)
}
res = p.Compose()
if len(res.Consumers.Created) != 0 || len(res.Consumers.Missing) != 0 || len(b.consumerCreates) != 1 {
t.Errorf("a consumer the bus holds was asked again: %+v, %v", res.Consumers, b.consumerCreates)
}
// A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to
// the composed configuration only by hand (grants_consumer_match), never while the controller holds the lease.
var said []asks.Condition
p.Say = func(c asks.Condition) error { said = append(said, c); return nil }
made["max_deliver"] = 9
res = p.Compose()
if len(res.Consumers.Differ) != 1 || !strings.Contains(res.Consumers.Differ[0], "EVENTS.b_mod-c: max deliver 9") ||
len(said) != 1 || said[0].Key != grants.ConsumerDiffersCondition || said[0].State != asks.StateRaised ||
!strings.Contains(said[0].Explanation, "EVENTS.b_mod-c") {
t.Fatalf("a differing consumer: %+v, said %+v", res.Consumers, said)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/2/2026-10-27", "epoch": 58})
if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err == nil || !strings.Contains(err.Error(), "controller holds the lease") {
t.Errorf("matched by hand under the controller's lease: %v", err)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
if _, err := p.MatchConsumer("EVENTS", "b_nothing"); err == nil {
t.Error("a consumer the records do not compose was asked to match")
}
if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err != nil {
t.Fatal(err)
}
res = p.Compose()
if !slices.Contains(b.consumerCreates, "match EVENTS.b_mod-c") || made["max_deliver"] != 5 || len(res.Consumers.Matched) != 1 {
t.Errorf("the ask to match: %+v, %v", res.Consumers, b.consumerCreates)
}
res = p.Compose()
if len(res.Consumers.Differ) != 0 || said[len(said)-1].State != asks.StateCleared {
t.Errorf("once matched: %+v, said %+v", res.Consumers, said)
}
})
// 774/AC13 (novox/hq ADR 0561 §7): after take-over a list in which more than one loaded user's hash changes, with no
// rotation by the vault naming each, is held: the list in force stays, user-list-held is raised naming those users. One
// user's change, or changes the vault's rotation names, are written.
t.Run("a list changing several loaded users' hashes is held unless the vault's rotation names each", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
hash := func(user, h string, generation int, rotating bool) {
v := map[string]any{"user": user, "hash": h, "generation": generation, "fingerprint": fmt.Sprintf("sha256:%d", generation)}
if rotating {
v["rotating"] = map[string]any{"login": user + "--b", "generation": generation, "revision": 7}
}
b.put(grants.ReadAccounts, user, v)
}
for _, u := range shadowOf(t, b) {
hash(u.User, "$2a$10$first", 1, false)
}
busKey, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Digest: "sha256:aa", Users: 6, Key: busKey.PublicKey}})
p := partOn(b)
p.Objects = &fakeObjects{}
var said []asks.Condition
p.Say = func(c asks.Condition) error { said = append(said, c); return nil }
sw, _ := json.Marshal(grants.Switch{V: 1, State: grants.TookOver, Acts: []grants.Act{}})
if _, err := (ownStore{b}).Put(grants.StateTakeover, grants.KeySwitch, sw); err != nil {
t.Fatal(err)
}
p.Compose()
inForce, _, _ := p.Sealed()
if inForce.Digest == "" {
t.Fatal("no list written after take-over")
}
// One user's hash changes: written.
hash("a.mod-x", "$2a$10$second", 2, false)
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest {
t.Fatal("a list changing one loaded user's hash was not written")
}
inForce, _, _ = p.Sealed()
// Two change at once, no rotation names them: held, said naming both.
hash("a.mod-x", "$2a$10$third", 3, false)
hash("a.nats", "$2a$10$third", 2, false)
said = nil
res := p.Compose()
if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x, a.nats") {
t.Fatalf("a list changing two loaded users' hashes: %+v", res.Sealed)
}
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "a.mod-x, a.nats") {
t.Errorf("the hold was said as %+v", said)
}
// The hold is its own guard, beside take-over's refusals (ADR 0562): the switch stays taken over.
if p.SwitchState() != grants.TookOver {
t.Errorf("the hold moved the switch to %s", p.SwitchState())
}
// A person accepts exactly the named users' changes of the held list, with why: refused for another set, with no
// why, or once the list changed since; accepted, the next pass writes it once, said as a hand act.
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x"}, "rotated by hand"); err == nil || !strings.Contains(err.Error(), "a.mod-x, a.nats") {
t.Errorf("an acceptance of another set than the held one: %v", err)
}
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, ""); err == nil {
t.Error("an acceptance with no why")
}
if _, err := p.AcceptHashes("person.jochen", []string{"a.nats", "a.mod-x"}, "the vault's store was restored"); err != nil {
t.Fatal(err)
}
// The list changed since the acceptance (a.mod-x's hash again): the acceptance is bound to the held list, refused.
hash("a.mod-x", "$2a$10$fourth", 4, false)
res = p.Compose()
if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "held") {
t.Fatalf("an acceptance of a list that changed since released it: %+v", res.Sealed)
}
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, "the vault's store was restored"); err != nil {
t.Fatal(err)
}
b.events = nil
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared ||
!slices.Contains(b.events, grants.EventHashesAccepted) {
t.Fatalf("an accepted hold was not released once and said: %+v, events %v", said, b.events)
}
inForce, _, _ = p.Sealed()
// The vault's rotation names each: written, the warning cleared.
hash("a.mod-x", "$2a$10$fifth", 5, true)
hash("a.nats", "$2a$10$fifth", 3, true)
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared {
t.Errorf("changes the vault's rotation names were held: %+v", said)
}
})
}
type fakeAsker struct {
@@ -1,217 +0,0 @@
package grants
import (
"encoding/json"
"fmt"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The accounts the mesh wants, said to the vault** (novox/hq ADR 0561 §2, issue 774). The bus composer derives every
// user from placement, the manifests and the wants; for each account the vault mints (a module's, a process's, a
// machine's provisioner client's), the part writes one key of its state `accounts`, keyed by the bus user, with the
// fields the controller's credential carries beside the password. A key is written only when it differs, and deleted
// only when placement no longer names its user and every input of the pass was read: a refused read never retires an
// account. The vault alone reads the state (buscompose.NamedReaders); it mints each user it holds no value for.
// StateAccounts is the part's state of wanted accounts.
const StateAccounts = buscompose.AccountsState
// AccountsReaders are the only modules that may read StateAccounts.
var AccountsReaders = buscompose.NamedReaders[buscompose.GrantsAccounts]
// Announced is what the part says it keeps: every state readable by any reader, but the wanted accounts, by the vault
// alone (ADR 0495 §2-§3, ADR 0561 §2).
func Announced() []announce.State {
var out []announce.State
for _, s := range States {
if s == StateAccounts {
out = append(out, announce.State{Name: s, Readers: slices.Clone(AccountsReaders)})
continue
}
out = append(out, announce.State{Name: s, Readable: true})
}
return out
}
// BusAddress is where every node on the private network reaches the bus, and its certificate's fingerprint: the part's
// own credential says both, since the controller gave every node's account the bus holder's overlay name (its
// brokerReachableAt).
type BusAddress struct {
URL string
Fingerprint string
}
// WantedAccounts is every account the vault mints that the records compose, keyed by user, with the bus's address. A
// node, the controller, an enrolment user or a person is never one (ADR 0561 §4, §5). Pure.
func WantedAccounts(in Inputs, recs buscompose.Records, bus BusAddress) (map[string]buscompose.WantedAccount, error) {
principals, err := buscompose.Users(recs)
if err != nil {
return nil, err
}
out := map[string]buscompose.WantedAccount{}
for _, p := range principals {
w := buscompose.WantedAccount{User: p.Username(), Kind: p.Kind, Node: p.Node, Module: p.Module, Process: p.Process,
URL: bus.URL, Fingerprint: bus.Fingerprint}
switch p.Kind {
case buscompose.KindModule, buscompose.KindNodeTools:
if m, ok := manifestOn(in, p.Node, p.Module); ok {
w.Claims = ClaimsOf(m)
}
case buscompose.KindProcess, buscompose.KindProvisioner:
default:
continue
}
out[w.User] = w
}
return out, nil
}
// manifestOn is the manifest a node runs of module: the build its want names, else the trunk's.
func manifestOn(in Inputs, node, module string) (catalogue.Manifest, bool) {
if m, ok := in.Running[node+"."+module]; ok {
return m, true
}
m, ok := in.Manifests[module]
return m, ok
}
// ClaimsOf is a module's claims as its credential carries them: the controller's claimsFor, over the mesh's own seats
// (its seat table is seeded from them): each claim's seat and scope, and the verbs the runtime serves for it, the claim's
// own or every verb the seat promises; a seat the mesh does not define keeps the claim's scope and no verbs.
func ClaimsOf(m catalogue.Manifest) []buscompose.ClaimedSeat {
if len(m.Claims) == 0 {
return nil
}
byName := map[string]catalogue.Seat{}
for _, s := range catalogue.DefaultSeats() {
byName[s.Name] = s
}
var out []buscompose.ClaimedSeat
for _, c := range m.Claims {
claimed := buscompose.ClaimedSeat{Seat: c.Name, Scope: c.At()}
if s, known := byName[c.Name]; known {
claimed.Scope = s.Scope
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
}
out = append(out, claimed)
}
return out
}
// Accounts answers the tool accounts (ADR 0561 §9): the wanted accounts as the part last wrote them, one user's or all,
// each with when it was written; and what the last pass did to them. Never a password: the state holds none.
func (p *Part) Accounts(user string) (any, error) {
users := []string{user}
if user == "" {
keys, err := p.Store.Keys(StateAccounts)
if err != nil {
return nil, fmt.Errorf("its own %s cannot be listed: %w", StateAccounts, err)
}
users = keys
}
out := []buscompose.WantedAccount{}
for _, u := range users {
raw, _, found, err := p.Store.Get(StateAccounts, u)
if err != nil {
return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateAccounts, u, err)
}
if !found {
if user != "" {
return nil, fmt.Errorf("no account %s is wanted: placement composes no module, process or provisioner client of that user", user)
}
continue
}
w, err := buscompose.ReadWantedAccount(u, raw)
if err != nil {
return nil, err
}
out = append(out, w)
}
answer := map[string]any{"accounts": out, "count": len(out), "read_by": AccountsReaders}
if raw, _, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
var res Result
if json.Unmarshal(raw, &res) == nil && res.Accounts != nil {
answer["last_pass"] = res.Accounts
}
// And the durable consumers the accounts need, as the last pass found them: missing and differing ones are read
// here in shadow too, where no condition is raised (ADR 0561 §6, §9).
if res.Consumers != nil {
answer["consumers"] = res.Consumers
}
}
return answer, nil
}
// AccountsWritten is what one pass did to the state of wanted accounts.
type AccountsWritten struct {
Wanted int `json:"wanted"`
Written []string `json:"written,omitempty"`
Deleted []string `json:"deleted,omitempty"`
// Kept are keys placement no longer names that this pass did not delete, with why.
Kept []string `json:"kept,omitempty"`
}
// writeAccounts writes each wanted account whose key differs, and deletes the key of a user no longer placed, but only
// when every input of the pass was read.
func (p *Part) writeAccounts(res *Result, in Inputs, recs buscompose.Records) {
if p.Bus.URL == "" {
res.Errors = append(res.Errors, "the wanted accounts are not written: the part knows no bus address to give them (its own credential names none)")
return
}
wanted, err := WantedAccounts(in, recs, p.Bus)
if err != nil {
res.Errors = append(res.Errors, "the wanted accounts could not be composed: "+err.Error())
return
}
done := &AccountsWritten{Wanted: len(wanted)}
res.Accounts = done
for _, user := range sortedKeys(wanted) {
w := wanted[user]
if raw, _, found, err := p.Store.Get(StateAccounts, user); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateAccounts, user, err))
continue
} else if found {
if was, err := buscompose.ReadWantedAccount(user, raw); err == nil && was.Same(w) {
continue
}
}
w.At = p.now()
data, _ := json.Marshal(w)
if _, err := p.Store.Put(StateAccounts, user, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateAccounts, user, err))
continue
}
done.Written = append(done.Written, user)
}
held, err := p.Store.Keys(StateAccounts)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be listed, so no key of a user gone is deleted: %v", StateAccounts, err))
return
}
failed := in.ReadFailures()
placed := Placed(in)
for _, user := range held {
if _, still := wanted[user]; still {
continue
}
switch {
case placed(user):
done.Kept = append(done.Kept, user+": placement still names it (composed with no account this pass)")
continue
case len(failed) > 0:
done.Kept = append(done.Kept, user+": a read of this pass failed, so no account is retired")
continue
}
if err := p.Store.Delete(StateAccounts, user); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateAccounts, user, err))
continue
}
done.Deleted = append(done.Deleted, user)
}
sort.Strings(done.Kept)
}
@@ -1,242 +0,0 @@
package grants
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
)
// **The durable consumers an account needs** (novox/hq ADR 0561 §6, issue 774). The controller creates a module's
// consumer on EVENTS and each process's on its work queue when it issues the account. From the write that hands its
// lease away the grants part creates them, with the configuration the bus composer derives (buscompose.AccountConsumers),
// only where the bus lacks one; one the bus holds is never changed or deleted, a difference is only said. Before that
// write they are compared in shadow alone, so two parts never create one consumer.
// ReadLease is the controller's lease as the part reads it (buscompose.NamedReaders names the vault and the part), and
// LeaseKey its one key.
const (
ReadLease = buscompose.ControllerLease
LeaseKey = buscompose.ControllerLeaseKey
)
// ConsumerCreator creates one durable consumer the bus does not hold: the part's process alone, whose account is granted
// the create of each account's consumer by stream and name. One the bus holds answers ErrHeld.
type ConsumerCreator interface {
CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error
// MatchConsumer brings a held consumer to cfg, keeping where it delivers from: only on a person's ask
// (grants_consumer_match), never by a pass.
MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error
}
// ConsumersState is what one pass found and did of the account consumers.
type ConsumersState struct {
// On says the lease is handed away, so the part creates what the bus lacks; Why says why it is on or off.
On bool `json:"on"`
Why string `json:"why"`
// Wanted is how many the records compose; Missing those the bus lacks (`<stream>.<name>`), Differ those it holds
// otherwise, each with how.
Wanted int `json:"wanted"`
Missing []string `json:"missing,omitempty"`
Differ []string `json:"differ,omitempty"`
Created []string `json:"created,omitempty"`
// Matched is a consumer brought to the composed configuration on a person's ask, or why not.
Matched []string `json:"matched,omitempty"`
NotCreated []string `json:"not_created,omitempty"`
// Unread is a stream whose consumers could not be read: nothing on it is compared or created.
Unread []string `json:"unread,omitempty"`
}
// heldConsumer is one consumer as the bus module's nats_streams reads it for one stream.
type heldConsumer struct {
Name string `json:"name"`
Filters []string `json:"filters"`
MaxDeliver int `json:"max_deliver"`
}
// heldConsumers reads the consumers the bus holds on stream, by name.
func heldConsumers(c Caller, stream string) (map[string]heldConsumer, error) {
raw, err := c.Call(CallStreams, map[string]any{"stream": stream})
if err != nil {
return nil, fmt.Errorf("%s %s: %w", CallStreams, stream, err)
}
var got struct {
Detail []struct {
Name string `json:"name"`
Consumers []heldConsumer `json:"consumer_detail"`
} `json:"detail"`
}
if err := json.Unmarshal(unwrap(raw), &got); err != nil {
return nil, fmt.Errorf("%s %s answered no consumers: %w", CallStreams, stream, err)
}
out := map[string]heldConsumer{}
for _, d := range got.Detail {
if d.Name != stream {
continue
}
for _, c := range d.Consumers {
out[c.Name] = c
}
}
return out, nil
}
// leaseHandedAway reads the lease as the part's reads give it.
func leaseHandedAway(r Reader) (bool, string) {
raw, found, err := r.Get(ReadLease, LeaseKey)
return buscompose.LeaseHandedAway(raw, found, err)
}
// consumers compares the account consumers the records compose with those the bus holds, and creates the missing ones
// once the lease is handed away and the part holds a creator.
func (p *Part) consumers(res *Result, recs buscompose.Records, match string) {
users, err := buscompose.Users(recs)
if err != nil {
res.Errors = append(res.Errors, "the account consumers could not be composed: "+err.Error())
return
}
st := &ConsumersState{}
st.On, st.Why = leaseHandedAway(p.Read)
res.Consumers = st
wanted := buscompose.AccountConsumers(users)
st.Wanted = len(wanted)
byStream := map[string][]buscompose.Consumer{}
for _, mc := range wanted {
byStream[mc.Consumer.Stream] = append(byStream[mc.Consumer.Stream], mc.Consumer)
}
for _, stream := range sortedKeys(byStream) {
held, err := heldConsumers(p.Call, stream)
if err != nil {
st.Unread = append(st.Unread, stream+": "+err.Error())
continue
}
for _, c := range byStream[stream] {
key := stream + "." + c.Name
h, ok := held[c.Name]
if ok {
why := consumerDiffers(c, h)
if why != "" && key == match && st.On && p.Consumers != nil {
if err := p.Consumers.MatchConsumer(stream, buscompose.ConsumerConfig(c)); err != nil {
st.Matched = append(st.Matched, key+": not matched: "+err.Error())
} else {
st.Matched = append(st.Matched, key+": matched, asked by hand")
continue
}
}
if why != "" {
st.Differ = append(st.Differ, key+": "+why)
}
continue
}
if !st.On || p.Consumers == nil {
st.Missing = append(st.Missing, key)
continue
}
switch err := p.Consumers.CreateConsumer(stream, buscompose.ConsumerConfig(c)); {
case err == nil:
st.Created = append(st.Created, key)
p.say(res, EventAsserted, map[string]any{"consumer": c.Name, "stream": stream})
case errors.Is(err, ErrHeld):
default:
st.Missing = append(st.Missing, key)
st.NotCreated = append(st.NotCreated, key+": "+err.Error())
}
}
}
p.sayDiffer(st)
}
// sayDiffer raises the warning condition mesh-grants.consumer-differs while an account consumer the part owns (from the
// lease write) differs from its composed configuration, naming each with the fields that differ, and clears it when they
// match. Said once per set of differences, again when it changes. Before the lease write the consumers are the
// controller's, and a difference is only said in the pass.
func (p *Part) sayDiffer(st *ConsumersState) {
if p.Say == nil {
return
}
why := ""
if st.On && len(st.Differ) > 0 {
why = strings.Join(st.Differ, "; ")
}
p.holdMu.Lock()
defer p.holdMu.Unlock()
if p.differKnown && why == p.differSaid {
return
}
c := asks.Condition{Key: ConsumerDiffersCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
if why == "" {
c.State = asks.StateCleared
} else {
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
c.Summary = fmt.Sprintf("%d durable consumers differ from their composed configuration", len(st.Differ))
c.Headline, c.Needs = "A module's consumer is not as composed", "a look, and grants_consumer_match for each"
c.Explanation = asks.NeedsYou + "a look. The bus holds these consumers otherwise than the bus composer composes them, " +
"and the grants part never changes a held one by itself (its delivery position would be lost): " + why
c.Resolved = "every account consumer matches, by grants_consumer_match or otherwise"
}
if p.Say(c) == nil {
p.differSaid, p.differKnown = why, true
}
}
// ConsumerDiffersCondition is the key of the warning raised while an account consumer differs (ADR 0561 §6).
const ConsumerDiffersCondition = "mesh-grants.consumer-differs"
// MatchConsumer is the tool consumer-match (ADR 0561 §6, §9): it asks the part's process to bring one account consumer
// the records compose to its composed configuration, keeping where it delivers from. Refused while the controller holds
// the lease (the consumers are the controller's then), and for a consumer the records do not compose.
func (p *Part) MatchConsumer(stream, name string) (any, error) {
if stream == "" || name == "" {
return nil, errors.New("consumer-match names a stream and a consumer")
}
if on, why := leaseHandedAway(p.Read); !on {
return nil, fmt.Errorf("not asked: %s; the controller makes and matches the consumers while it holds the lease", why)
}
in := ReadInputs(p.Read, &p.cache)
recs, _ := Records(in)
users, err := buscompose.Users(recs)
if err != nil {
return nil, err
}
found := false
for _, mc := range buscompose.AccountConsumers(users) {
if mc.Consumer.Stream == stream && mc.Consumer.Name == name {
found = true
}
}
if !found {
return nil, fmt.Errorf("the records compose no account consumer %s on %s: the part brings only those to their configuration", name, stream)
}
ask, _ := json.Marshal(Ask{By: "grants_consumer_match", At: p.now(), Consumer: stream + "." + name})
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
if err != nil {
return nil, fmt.Errorf("the ask could not be written: %w", err)
}
return map[string]any{"asked": rev, "says": "the part's process brings " + stream + "." + name + " to its composed " +
"configuration at this ask, keeping where it delivers from; the last pass's consumers.matched says what became of it"}, nil
}
// consumerDiffers says how a held consumer differs from the composed one in what nats_streams reads back, or "".
func consumerDiffers(c buscompose.Consumer, h heldConsumer) string {
var why []string
want, have := slices.Sorted(slices.Values(c.Filters)), slices.Sorted(slices.Values(h.Filters))
if !slices.Equal(want, have) {
why = append(why, fmt.Sprintf("filters %v, composed %v", have, want))
}
maxDeliver := c.MaxDeliver
if maxDeliver == 0 {
maxDeliver = -1
}
if h.MaxDeliver != maxDeliver {
why = append(why, fmt.Sprintf("max deliver %d, composed %d", h.MaxDeliver, maxDeliver))
}
sort.Strings(why)
return strings.Join(why, "; ")
}
+5 -17
View File
@@ -6,7 +6,6 @@ import (
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -19,7 +18,7 @@ type Watcher interface {
// Followed are the states whose change composes again: placement, the indexer's manifests by module, the resolved
// config values, the node agents' haves (a machine's first have, and the bus node's loaded list) and the assigner's
// wants (the manifest each machine is wanted to run), and the vault's hashes of the bus accounts.
var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease}
var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement}
// Follower composes again on every change it hears, never on a timer. It never stops for a refusal: a watch it cannot
// open is said, composed without, and opened again at the next wake (any change it does hear, or a reconnect).
@@ -29,9 +28,9 @@ type Follower struct {
Logf func(string, ...any)
// Asked watches the part's own ask to compose now (a tool's compose-now), waking at each; nil watches none.
Asked func(wake func()) error
// Hear hears the controller seat's condition events and the vault's account-issued (Consumes) and hands each, by event
// name, with its body, to handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass:
// a machine's root-free word changed; and so does an account the vault minted (ADR 0561 §1), whose hash the list carries.
// Hear hears the controller seat's condition events (Consumes) and hands each, by event name, with its body, to
// handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass: a machine's
// root-free word changed.
Hear func(handle func(event string, body []byte)) error
wake chan struct{}
@@ -47,8 +46,6 @@ type Follower struct {
held string
notAsserted string
refusedMans string
// leaseOn is whether the lease was handed away at its last change heard, once leaseSeen.
leaseOn, leaseSeen bool
}
func (f *Follower) init() {
@@ -88,15 +85,6 @@ func (f *Follower) logf(format string, args ...any) {
// heard says whether a change composes again: every change but a have's, and of the haves a machine's first and the
// bus module's loaded list moving.
func (f *Follower) heard(state, key string, value []byte) bool {
if state == ReadLease {
// Renewed every few seconds by its holder: only the write that hands it away, or back, composes again (ADR 0561 §6).
on, _ := buscompose.LeaseHandedAway(value, len(value) > 0, nil)
f.mu.Lock()
defer f.mu.Unlock()
was, seen := f.leaseOn, f.leaseSeen
f.leaseOn, f.leaseSeen = on, true
return !seen || was != on
}
if state != ReadHave {
return true
}
@@ -136,7 +124,7 @@ func (f *Follower) open() {
}
if f.Hear != nil && !f.heard_ {
err := f.Hear(func(event string, body []byte) {
if IsAccountIssued(event) || IsRootNotFree(body) {
if IsRootNotFree(body) {
f.Wake()
}
})
+1 -17
View File
@@ -41,7 +41,7 @@ const (
// Reads is every state the part reads, in the manifest's order.
var Reads = []string{ReadAssignments, ReadControllerGrant, ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds,
ReadAccounts, ReadPlacement, ReadLease}
ReadAccounts, ReadPlacement}
// The bus module's tools the part calls, as its manifest's invokes name them: read-only, each.
const (
@@ -139,22 +139,6 @@ type HeldHash struct {
Generation int `json:"generation,omitempty"`
At time.Time `json:"at"`
Adopted bool `json:"adopted,omitempty"`
// Fingerprint is the vault's fingerprint of the value the hash is of: with the generation, what says the password
// changed, never the hash itself (a hash of the same password differs by its salt).
Fingerprint string `json:"fingerprint,omitempty"`
// Rotating is the vault's rotation of the account under way: a change of its hash it announces (ADR 0561 §4, §7).
Rotating *struct {
Login string `json:"login"`
} `json:"rotating,omitempty"`
}
// credentialOf is what says which password a held hash is of, never the hash: adopted, or the vault's generation and the
// fingerprint of its value.
func credentialOf(h HeldHash) string {
if h.Adopted {
return "adopted"
}
return fmt.Sprintf("generation %d %s", h.Generation, h.Fingerprint)
}
// readHashes reads the vault's bus-accounts; a refusal is a gap, and every user then has no hash held.
+19 -39
View File
@@ -31,7 +31,7 @@ const (
)
// States is every state the part keeps.
var States = []string{StateAccounts, StateAssertions, StateMemberships, StateTakeover, StateUserList}
var States = []string{StateAssertions, StateMemberships, StateTakeover, StateUserList}
// The events the part says.
const (
@@ -42,15 +42,12 @@ const (
)
// Emits is every event the part says.
var Emits = []string{EventAsserted, EventHashesAccepted, EventMembership, EventUserList}
var Emits = []string{EventAsserted, EventMembership, EventUserList}
// Store is the part's own state, by local name: the only thing the part writes.
type Store interface {
Get(state, key string) ([]byte, uint64, bool, error)
Put(state, key string, value []byte) (uint64, error)
// Keys and Delete are for the wanted accounts alone (accounts.go): every other state is only put.
Keys(state string) ([]string, error)
Delete(state, key string) error
}
// Emitter says one of the part's events.
@@ -80,6 +77,13 @@ type UserListState struct {
func (s UserListState) inputs() string {
s.At, s.Inputs = time.Time{}, ""
// When the bus node said it loaded a list is no input: the same list said again, at a later time and the key's later
// revision, composes nothing new (issue 1126: written again every 3 s, 2026-10-28).
if s.Loaded != nil {
l := *s.Loaded
l.Revision, l.At = 0, time.Time{}
s.Loaded = &l
}
raw, _ := json.Marshal(s)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
@@ -107,13 +111,9 @@ type Result struct {
// free, <why>`: a verified sender is composed as from no root-free machine there (issue 1945).
RootNotFree []string `json:"root_not_free,omitempty"`
// Sealed is the list sealed to the bus node as the key `user-list.sealed` names it (ADR 0547 clause 7).
Sealed *SealedState `json:"sealed,omitempty"`
// Consumers is what the pass found and created of the durable consumers an account needs (ADR 0561 §6).
Consumers *ConsumersState `json:"consumers,omitempty"`
// Accounts is what the pass wrote of the wanted accounts the vault mints from (ADR 0561 §2).
Accounts *AccountsWritten `json:"accounts,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
Sealed *SealedState `json:"sealed,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.
@@ -136,12 +136,6 @@ type Part struct {
Logf func(string, ...any)
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
Objects Objects
// Consumers creates an account's durable consumer from the lease write (consumers.go): the process's connection;
// nil in the tools, which compare only.
Consumers ConsumerCreator
// Bus is where the nodes reach the bus, which each wanted account carries (accounts.go): the process's own credential's;
// empty in the tools, which write no account.
Bus BusAddress
// Say says a condition to the operator: the process's; nil in the tools.
Say func(asks.Condition) error
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
@@ -163,11 +157,6 @@ type Part struct {
holdMu sync.Mutex
heldSaid string
heldKnown bool
// differSaid is the account consumers' differences last said as a condition, once differKnown (consumers.go).
differSaid string
differKnown bool
// hashHold are the users whose hash changes held the pass's list, or nil (sealed.go).
hashHold []string
}
// Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing
@@ -176,21 +165,18 @@ type Ask struct {
By string `json:"by"`
At time.Time `json:"at"`
Assert string `json:"assert,omitempty"`
// Consumer is, from grants_consumer_match, one account consumer `<stream>.<name>` to bring to the composed
// configuration by hand (ADR 0561 §6).
Consumer string `json:"consumer,omitempty"`
}
// asked is a new ask, read once per ask; the zero Ask when none.
func (p *Part) asked() Ask {
// 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 Ask{}
return ""
}
p.askSeen = rev
var a Ask
_ = json.Unmarshal(raw, &a)
return a
return a.Assert
}
func (p *Part) now() time.Time {
@@ -254,10 +240,6 @@ func (p *Part) Compose() Result {
}
}
}
// The accounts the vault mints from, said in the state only it reads (ADR 0561 §2).
if p.Store != nil {
p.writeAccounts(&res, in, recs)
}
// The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7).
if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil {
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
@@ -292,11 +274,10 @@ func (p *Part) Compose() Result {
}
}
held, heldErr := HeldStreams(p.Call)
var ask Ask
if p.Create != nil || p.Consumers != nil {
ask = p.asked()
forced := ""
if p.Create != nil {
forced = p.askedAssert()
}
forced := ask.Assert
for _, module := range sortedKeys(in.Manifests) {
if !assigned(in, module) {
continue
@@ -318,7 +299,6 @@ func (p *Part) Compose() Result {
res.AssertionsWritten++
}
}
p.consumers(&res, recs, ask.Consumer)
if _, _, found, err := p.Store.Get(StateTakeover, KeySwitch); err == nil && !found {
data, _ := json.Marshal(Switch{V: 1, State: Shadow, Acts: []Act{}})
if _, err := p.Store.Put(StateTakeover, KeySwitch, data); err != nil {
@@ -23,17 +23,8 @@ const (
KindRootNotFree = "root-not-free"
)
// EventAccountIssued is the vault's word that it minted an account (novox/hq ADR 0561 §1): its hash is in the vault's
// bus-accounts, so a pass composes the list with it.
const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent
// Consumes is every event the part hears, as its manifest's consumes name them.
var Consumes = []string{EventConditionRaised, EventConditionCleared, EventAccountIssued}
// IsAccountIssued says an event heard is the vault's account-issued, by its name or its bus subject.
func IsAccountIssued(event string) bool {
return event == EventAccountIssued || event == buscompose.AccountIssuedSubject
}
var Consumes = []string{EventConditionRaised, EventConditionCleared}
// EventSubject is the bus subject of a controller seat event named `mesh-controller.<event>`.
func EventSubject(event string) string {
+15 -172
View File
@@ -2,12 +2,10 @@ package grants
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
@@ -93,12 +91,11 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
why = "no node runs the bus's module, so the list is sealed to nobody"
case key == "":
why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)"
case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost, digest) != "":
case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost) != "":
// After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user
// of it for want of a hash: that user's module would be locked out of the bus (review of the switch).
why = p.holdsBack(in, leftOut, bus, lost, digest)
why = p.holdsBack(in, leftOut, bus, lost)
held = why
p.keepHashHold(res, digest)
case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode:
object, err := delivery.SealUserList(list, key)
if err == nil {
@@ -136,8 +133,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
return
}
if next.Digest == digest && cur.Digest != digest {
p.keepWrittenUsers(res, list, in.Hashes)
p.acceptedWritten(res, digest)
p.keepWrittenUsers(res, list)
}
// The object the key named before is taken away once the key names the new one: the bucket keeps the list in force.
if had && cur.Object != "" && cur.Object != next.Object {
@@ -181,7 +177,16 @@ func (p *Part) sayHeld(why string) {
const HeldCondition = "mesh-grants.user-list-held"
func sameSealed(a, b delivery.GrantsUserList) bool {
// Its time is no change, and no user left out is the same written as [] or null: the key read back says [] for the
// nil a pass composes, which read as a change wrote the key again at every pass, and each write moved the bus node's
// report and woke the next pass (issue 1126: the shadow list's revision 99270 to 99357 in 90 s, 2026-10-28).
a.At, b.At = b.At, a.At
if len(a.LeftOut) == 0 {
a.LeftOut = nil
}
if len(b.LeftOut) == 0 {
b.LeftOut = nil
}
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
@@ -283,50 +288,11 @@ func (p *Part) followReasons(res Result, in Inputs) []string {
// composed after take-over must keep.
const KeyWrittenUsers = "sealed-users"
// KeyWrittenCredentials is the key of the state user-list naming, per user of the list in force, which password its hash
// is of (credentialOf: adopted, or the vault's generation and fingerprint), never the hash: what tells a list that changes
// several loaded users' hashes at once (ADR 0561 §7).
const KeyWrittenCredentials = "sealed-credentials"
func (p *Part) keepWrittenUsers(res *Result, list []byte, hashes map[string]HeldHash) {
users := listUsers(list)
data, _ := json.Marshal(users)
func (p *Part) keepWrittenUsers(res *Result, list []byte) {
data, _ := json.Marshal(listUsers(list))
if _, err := p.Store.Put(StateUserList, KeyWrittenUsers, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenUsers, err))
}
creds := map[string]string{}
for _, u := range users {
if h, ok := hashes[u]; ok {
creds[u] = credentialOf(h)
}
}
data, _ = json.Marshal(creds)
if _, err := p.Store.Put(StateUserList, KeyWrittenCredentials, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenCredentials, err))
}
}
// changedHashes are the users of the list in force whose hash the list composed now would change with no rotation by the
// vault naming them, sorted; none when the part kept no credentials of the list in force (a list written before).
func (p *Part) changedHashes(hashes map[string]HeldHash) ([]string, error) {
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenCredentials)
if err != nil || !found {
return nil, err
}
var had map[string]string
if err := json.Unmarshal(raw, &had); err != nil {
return nil, err
}
var changed []string
for user, was := range had {
h, ok := hashes[user]
if !ok || credentialOf(h) == was || h.Rotating != nil {
continue
}
changed = append(changed, user)
}
slices.Sort(changed)
return changed, nil
}
var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`)
@@ -346,8 +312,7 @@ func listUsers(list []byte) []string {
// loaded list not read holds it too, since that cannot be ruled out). The list in force is the one the part wrote, by the users
// it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when
// they could not be read). Empty when it may.
func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string, digest string) string {
p.hashHold = nil
func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string {
if f := in.ReadFailures(); len(f) > 0 {
return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")"
}
@@ -377,127 +342,5 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string, digest string)
if len(unhashed) > 0 {
return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault"
}
// More than one loaded user's hash changing at once, with no rotation by the vault naming each (ADR 0561 §7): a new
// writer of hashes must not lock several holders out in one list, as nine seats were cut on 2026-10-27 at 09:21.
changed, err := p.changedHashes(in.Hashes)
if err != nil {
return "held: the credentials of the list in force cannot be read: " + err.Error()
}
if len(changed) > 1 && !p.accepted(digest, changed) {
p.hashHold = changed
return "held: the list in force is kept, since the hashes of " + fmt.Sprint(len(changed)) + " loaded users would change at " +
"once with no rotation by the vault naming them: " + strings.Join(changed, ", ")
}
return ""
}
// **A person accepts a held list's hash changes** (ADR 0561 §7, §9; tools-are-how-a-module-is-spoken-to): the hold of
// several loaded users' hashes changing at once is released only by grants_accept_hashes, naming exactly the users held,
// with why, bound to the held list's digest. The acceptance releases this guard alone, once: the other holds (a failed
// read, a user dropped for want of a hash, a lost right) stand whatever it says; and a list composed otherwise since is
// held again, for a new acceptance.
// KeyHashHold names the list held for its hash changes (its digest and the users), and KeyHashAccept a person's acceptance.
const (
KeyHashHold = "hash-hold"
KeyHashAccept = "hash-accept"
)
// EventHashesAccepted is said when a list held for its hash changes is written on a person's acceptance: a hand act.
const EventHashesAccepted = "hashes-accepted"
// HashHold is the value of KeyHashHold, and HashAccept of KeyHashAccept.
type HashHold struct {
Digest string `json:"digest"`
Users []string `json:"users"`
At time.Time `json:"at"`
}
type HashAccept struct {
Digest string `json:"digest"`
Users []string `json:"users"`
By string `json:"by"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// keepHashHold writes which list is held for its hash changes, when that is the hold.
func (p *Part) keepHashHold(res *Result, digest string) {
if p.hashHold == nil {
return
}
data, _ := json.Marshal(HashHold{Digest: digest, Users: p.hashHold, At: p.now()})
if raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold); err == nil && found {
var was HashHold
if json.Unmarshal(raw, &was) == nil && was.Digest == digest && slices.Equal(was.Users, p.hashHold) {
return
}
}
if _, err := p.Store.Put(StateUserList, KeyHashHold, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyHashHold, err))
}
}
// accepted says a person accepted exactly these users' hash changes of the list of this digest.
func (p *Part) accepted(digest string, users []string) bool {
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
if err != nil || !found {
return false
}
var a HashAccept
return json.Unmarshal(raw, &a) == nil && a.Digest == digest && slices.Equal(a.Users, users)
}
// acceptedWritten says, once, that the list a person accepted is written, and forgets the acceptance and the hold.
func (p *Part) acceptedWritten(res *Result, digest string) {
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
if err != nil || !found {
return
}
var a HashAccept
if json.Unmarshal(raw, &a) != nil || a.Digest != digest {
return
}
p.say(res, EventHashesAccepted, map[string]any{"digest": digest, "users": a.Users, "by": a.By, "why": a.Why})
for _, k := range []string{KeyHashAccept, KeyHashHold} {
if err := p.Store.Delete(StateUserList, k); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, k, err))
}
}
}
// AcceptHashes is the tool accept-hashes: a person accepts the hash changes of the list held for them, naming exactly its
// users, with why. Refused when no list is held for its hashes, or for another set of users. Bound to the held list's
// digest: a list composed otherwise since is held again.
func (p *Part) AcceptHashes(caller string, users []string, why string) (any, error) {
if strings.TrimSpace(why) == "" {
return nil, errors.New("accept-hashes says why (`why`)")
}
raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold)
if err != nil {
return nil, fmt.Errorf("the held list cannot be read: %w", err)
}
var h HashHold
if !found || json.Unmarshal(raw, &h) != nil || h.Digest == "" {
return nil, errors.New("no list is held for its users' hashes changing: there is nothing to accept")
}
named := slices.Sorted(slices.Values(users))
if !slices.Equal(named, h.Users) {
return nil, fmt.Errorf("the list held (%s) changes the hashes of %s: an acceptance names exactly those, and %s is not that",
short(h.Digest), strings.Join(h.Users, ", "), strings.Join(named, ", "))
}
data, _ := json.Marshal(HashAccept{Digest: h.Digest, Users: h.Users, By: someoneOr(caller), Why: strings.TrimSpace(why), At: p.now()})
rev, err := p.Store.Put(StateUserList, KeyHashAccept, data)
if err != nil {
return nil, fmt.Errorf("the acceptance could not be written: %w", err)
}
return map[string]any{"accepted": rev, "digest": h.Digest, "users": h.Users, "says": "the part's process writes the held " +
"list at its next pass, once, unless another hold stands or the list composed then is another; it says hashes-accepted"}, nil
}
func someoneOr(caller string) string {
if caller == "" {
return "a caller the runtime did not prove"
}
return caller
}
+1 -5
View File
@@ -21,14 +21,10 @@ const (
ToolTakeOver = "grants_take_over"
ToolTakeOverState = "grants_take_over_state"
ToolHandBack = "grants_hand_back"
ToolAccounts = "grants_accounts"
ToolConsumerMatch = "grants_consumer_match"
ToolAcceptHashes = "grants_accept_hashes"
)
// Tools is every tool, in the manifest's order.
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState,
ToolAccounts, ToolConsumerMatch, ToolAcceptHashes}
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState}
// WritesNoGrant is what grants says of the part's own grants in this milestone.
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
+3 -13
View File
@@ -12,13 +12,11 @@
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.placement",
"node-engine.have",
"mesh-vault.bus-accounts",
"mesh-controller.lease"
"mesh-vault.bus-accounts"
],
"consumes": [
"mesh-controller.condition-raised",
"mesh-controller.condition-cleared",
"mesh-vault.account-issued"
"mesh-controller.condition-cleared"
],
"uses": [
"operator-channel"
@@ -31,7 +29,6 @@
],
"emits": [
"asserted",
"hashes-accepted",
"membership-changed",
"user-list-changed"
],
@@ -43,16 +40,9 @@
"grants_assert",
"grants_take_over",
"grants_take_over_state",
"grants_hand_back",
"grants_accounts",
"grants_consumer_match",
"grants_accept_hashes"
"grants_hand_back"
],
"state": [
{
"name": "accounts",
"history": 5
},
{
"name": "assertions",
"history": 5