Compare commits

..
Author SHA1 Message Date
jschoubben c77d17fb42 A follower joining a watched state hears its current values, and a templates archive is fetched once
A read could start a state's watch before its follower joined, and a change landing in that gap woke nothing (review
of ADR 0580 §2): a joining follower now runs over the copy's current values before any later change. The watched
reader keeps each archive once per URL, and both caches are bounded.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 03:51:53 +01:00
jschoubben 8311ecb849 The hand-act recorder records through the seat alone, and the watched reader's reads are proved
The controller's hand-act and warranted are gone (issue 1953 L5b) and the bus refuses them, so the recorder no longer
falls back to them: an act the seat does not take is an error saying nothing was recorded. And ADR 0580 clause 1's
reader is shown reading a build by key each time and a manifest once per digest.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 03:33:17 +01:00
jschoubben ba8b8f28a3 One watched copy of the states a module reads, and the composer's reader over it
A whole state read key by key took the provisioner's composition of one node past 30 s and the assigner's compare past
its call bound (ADR 0580 clause 1): each state is now one watch's current values, kept by its changes; a build is read
by key and a manifest once per digest.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 03:29:05 +01:00
117 changed files with 4274 additions and 4021 deletions
+5 -2
View File
@@ -24,12 +24,15 @@ const Event = "states"
// account may already publish to and the events stream keeps.
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
// SubjectAll is every module's announcement.
// SubjectAll is every module's announcement, what the controller consumes.
const SubjectAll = "mesh.mod.*.event." + Event
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
const KeptBucket = "mesh-controller_states-announced"
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// Key is module's key where the last announcement of each module is kept: the module itself, which must be one plain name, since a module's name is part of
// Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of
// its buckets' names (the indexer's judge, novox/hq ADR 0578 §1).
func Key(module string) (string, error) {
if !name.MatchString(module) {
+3
View File
@@ -42,6 +42,9 @@ func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
}
if KeptBucket != "mesh-controller_states-announced" {
t.Fatalf("the kept bucket is %q", KeptBucket)
}
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
t.Fatalf("Key = %q, %v", k, err)
}
+26 -34
View File
@@ -25,7 +25,7 @@ type StepFunc func(ctx context.Context, args map[string]any) (any, error)
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
type AskSteps struct {
Fetch, Build, Copy, Digest, Test StepFunc
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
// (an image or an archive names none).
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
@@ -107,32 +107,19 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
return r, f, nil
}
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) {
if want == nil || s.Toolchain == nil {
return nil, nil
}
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != want.Version || tc.Recipe != want.Recipe {
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
want.Version, want.Recipe, tc.Version, tc.Recipe)}
}
return &tc, nil
}
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
answer := delivery.BuildAnswer{}
tc, err := s.onToolchain(ctx, job.Toolchain)
if err != nil {
return nil, err
if job.Toolchain != nil && s.Toolchain != nil {
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
}
answer.Toolchain = &tc
}
answer.Toolchain = tc
var f fetched
defer func() { s.release(f.Workspace) }()
for _, a := range job.Artifacts {
@@ -194,9 +181,6 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
}
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
return nil, err
}
r, f, err := s.fetch(ctx, job.Fetch)
if err != nil {
return nil, err
@@ -213,15 +197,16 @@ func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error)
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
runner = job.Runner
}
// Every level runs, also after one that did not pass: one check names every failure, not only the first (the operator,
// 2026-11-02: "A failed check shows only its first fault").
for _, level := range job.Levels {
la := delivery.LevelAnswer{Level: level}
_, err := s.level(ctx, job, level, f, runner, &la)
passed, err := s.level(ctx, job, level, f, runner, &la)
answer.Levels = append(answer.Levels, la)
if err != nil {
return nil, err
}
if !passed {
return answer, nil // the first level that did not pass ends the check (ADR 0484 clause 13)
}
}
return answer, nil
}
@@ -260,9 +245,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
_ = json.Unmarshal(r, &t)
return t.Passed, nil
}
// The packages (or Node directories) whose tests the change touches run first (issue 1289; ADR 0444), and then every
// other test step of the level, also after one that did not pass, so one check names every failing test of the level
// and not only the first (issue 1921; the operator, 2026-11-02); the level passes only when each of its steps did.
// The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level
// there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every
// test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of
// the level and not only the first (issue 1921); the level passes only when each of its steps did.
all := true
if s.Digest == nil {
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
@@ -277,6 +263,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
}
all = all && passed
}
if !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
}
return all, nil
}
@@ -312,7 +301,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
if err != nil {
return false, fmt.Errorf("the %s level: %w", level, err)
}
all = all && passed // the others still run after a failure (issue 1921)
all = all && passed // past the change's own packages the others still run (issue 1921)
if priority && !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
}
return all, nil
}
+29 -32
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
@@ -104,7 +103,7 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
}
}
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again.
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
s := r.steps()
@@ -113,21 +112,15 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
}
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
var dec *Declining
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 {
var ref *Refusal
if !errors.As(err, &ref) || len(r.calls) != 0 {
t.Fatalf("err %v, calls %v", err, r.calls)
}
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
if !errors.As(err, &dec) || len(r.calls) != 0 {
t.Fatalf("a check ask: err %v, calls %v", err, r.calls)
}
}
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
// verdicts, tests the packages whose tests changed first and then the others, also after one of them failed, and runs
// every level, also after one that did not pass, so one check names every failure (the operator, 2026-11-02).
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no
// level after one that did not pass (ADR 0484 clause 13).
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
@@ -142,20 +135,16 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
got := strings.Join(r.calls, "\n")
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
digest {"level":"unit","workspace":"/w"}
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}
digest {"level":"integration","workspace":"/w"}
test {"level":"integration","packages":["x/pkg/b"],"workspace":"/w"}
test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}`
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 2 {
t.Fatalf("levels %+v, want both levels", a.Levels)
if len(a.Levels) != 1 {
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
}
u := a.Levels[0]
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
t.Fatalf("the unit level %+v", u)
}
@@ -270,17 +259,17 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
}
}
// Issue 1289: the packages whose tests changed run first. Since 2026-11-02 (the operator: "A failed check shows only its
// first fault") a failure among them ends nothing: every package of the level and every level still runs, so one check
// names every failing test (issue 1921). A Node check does the same with its module directories.
func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) {
// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the
// change touched are two test results of the one level. A Node check does the same with its module directories.
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) {
for _, row := range []struct {
name string
changed []string
tests map[string]bool
want []string // the packages tested at each level, in order
want []string // the packages tested, in order
}{
{"the change's own test fails: the rest still runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b", "x/pkg/a"}},
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
{"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}},
{"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}},
} {
@@ -302,11 +291,19 @@ func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) {
got = append(got, strings.Join(a.Packages, " "))
}
}
if want := append(slices.Clone(row.want), row.want...); fmt.Sprint(got) != fmt.Sprint(want) {
t.Errorf("%s: tested %v, want %v", row.name, got, want)
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
}
if a := out.(delivery.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) {
t.Errorf("%s: levels %+v, want both, each with every test result", row.name, a.Levels)
a := out.(delivery.CheckAnswer)
failed := false
for _, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
_ = json.Unmarshal(raw, &res)
failed = failed || !res.Passed
}
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
}
}
@@ -319,7 +316,7 @@ func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 {
t.Fatalf("calls %v, levels %+v: want b then a at each of the two levels", r.calls, a.Levels)
if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 {
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels)
}
}
-27
View File
@@ -538,28 +538,6 @@ func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
p.mu.Unlock()
}
// declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does
// before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left
// alone until the next pull's wait.
func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) {
result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks})
step.Ended = now().UTC()
step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result
if err := writeEnd(p.Put, key, step); err != nil {
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
_ = msg.Nak()
return
}
p.logf("the ask %s is declined on %s: %s", ask.ID, p.Node, d.Text)
_ = msg.Term()
p.mu.Lock()
if p.declined == nil {
p.declined = map[string]bool{}
}
p.declined[msg.Kind] = true
p.mu.Unlock()
}
func withKind(msg *Pulled, kind string) *Pulled {
if msg != nil {
msg.Kind = kind
@@ -647,11 +625,6 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
})
step = ph.end() // its end keeps the phase it ended in
close(done)
var dec *Declining
if errors.As(err, &dec) && ctx.Err() == nil {
p.declineRun(msg, ask, key, step, dec)
return
}
if ctx.Err() != nil {
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
return
-15
View File
@@ -230,21 +230,6 @@ func TestPullDropsWhatIsNoAsk(t *testing.T) {
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
go q.Pull(ctx)
waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 })
// A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds.
sit, sitSteps := NewFakeAsks(), newSteps()
toolchain := testAsk(delivery.AskDelivery)
_ = sit.PublishAsk(toolchain)
s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}}
}}
go s2.Pull(ctx)
waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 })
sk, _ := delivery.AskStepKey("ace", toolchain.ID)
ss, _ := sitSteps.get(sk)
if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") {
t.Fatalf("the sitting-out step = %+v", ss)
}
}
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
-11
View File
@@ -16,14 +16,3 @@ func (r *Refusal) Error() string { return r.Text }
// Refuse is a Refusal with its text formatted.
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined),
// the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a
// toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority
// sits out and never fails a build.
type Declining struct {
Text string
Lacks []string // what this node lacks, in words the operator reads
}
func (d *Declining) Error() string { return d.Text }
+61
View File
@@ -186,3 +186,64 @@ func HeldCredential(w WantedAccount, password string) ([]byte, error) {
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
Claims: w.Claims})
}
// **The lease write** (novox/hq ADR 0561 §3, §6). The controller's lease is the one key `holder` of its bucket
// (LeaseBucket). The operator's credential hands it away by writing a value that names its new holder in `handed-to`;
// while the key holds that, the grants part creates the durable consumers an account needs, the assigner reads the grants
// part's grants (delivery.GrantSource), and the vault retires and rotates accounts. A controller's value, a key deleted or
// expired, or one that cannot be read or parsed, turn those off. No clock and no setting turns it.
//
// **Minting is no longer on the lease** (issue 774, research 072's gap "vault mints after the lease"): the vault is the
// one issuer of every account the grants part wants, whoever holds the lease, and the controller mints none once a vault
// is installed (it forwards `module issue` to the vault's issue-account). One issuer at every moment holds by who mints,
// not by the lease: an account whose value the vault holds is never minted anew, and a value the controller hands or
// moves never replaces one the vault minted.
// ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it
// (NamedReaders), the controller's announcement marking it readable by nobody.
const ControllerLease = ControllerSeat + ".lease"
// ControllerLeaseKey is the lease's one key.
const ControllerLeaseKey = leaseKey
// LeaseHolder is the lease key's value: a controller instance's (mesh-controller internal/lease Holder), or, once handed
// away, one naming its holder in HandedTo.
type LeaseHolder struct {
Instance string `json:"instance,omitempty"`
Host string `json:"host,omitempty"`
Epoch uint64 `json:"epoch,omitempty"`
Taken time.Time `json:"taken,omitzero"`
Renewed time.Time `json:"renewed,omitzero"`
// HandedTo is who holds the lease once the controller handed it away: never a controller instance.
HandedTo string `json:"handed-to,omitempty"`
// HandedBy is the credential that wrote it (the operator's), and Why its reason.
HandedBy string `json:"handed-by,omitempty"`
Why string `json:"why,omitempty"`
}
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
// says whether it is handed to a holder that is no controller, with why either way.
func LeaseHandedAway(raw []byte, found bool, readErr error) (bool, string) {
switch {
case readErr != nil:
return false, "the controller's lease cannot be read (" + readErr.Error() + "), so it is not handed away"
case !found || len(raw) == 0 || string(raw) == "null":
return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so it is not handed away"
}
var h LeaseHolder
if err := json.Unmarshal(raw, &h); err != nil {
return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so it is not handed away"
}
if strings.TrimSpace(h.HandedTo) == "" {
who := h.Instance
if who == "" {
who = "an instance that names itself not"
}
return false, "the controller holds the lease (" + who + "), so it is not handed away (ADR 0561 §3)"
}
if h.Instance != "" {
return false, "the lease names both a controller instance (" + h.Instance + ") and a holder it is handed to (" +
h.HandedTo + "), so it is not handed away"
}
return true, "the lease is handed to " + h.HandedTo + " (ADR 0561 §3)"
}
+32 -3
View File
@@ -9,11 +9,39 @@ import (
)
// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the composer is
// given the last word of each (Records.Announced) and grants a reader's `reads: <owner>.<name>` only when the owner
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the controller
// keeps the last word of each (announce.KeptBucket) and grants a reader's `reads: <owner>.<name>` only when the owner
// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads`
// (the transition, until every module announces), and is named as not yet announced.
// StatesAnnouncedBucket is the controller's bucket of the last announcement of each module (announce.KeptBucket).
const StatesAnnouncedBucket = announce.KeptBucket
// controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares
// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, and the grants issued and the
// compositions' digests, which the assigner reads (ADR 0528 clause 2, ADR 0539 clause 6).
var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts,
MirrorDeclaredPackages, ControllerGrants, ControllerComposed}
// ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local
// name, the ones others may read marked readable. It announces itself, as any module does from its code.
func ControllerAnnouncement(node string) announce.States {
s := announce.States{Module: ControllerSeat, Node: node}
readable := map[string]bool{}
for _, n := range controllerReadable {
readable[n] = true
}
for _, bucket := range ControllerBuckets() {
local, mine := strings.CutPrefix(bucket, ControllerSeat+"_")
if !mine {
continue
}
s.States = append(s.States, announce.State{Name: local, Readable: readable[local]})
}
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
return s
}
// ReadsAnnouncedBy splits one reader module's `reads` (`<owner>.<name>`) by what each owner announced: granted when the
// owner announced the state readable (by this reader, where it named its readers: announce.State.Readers), when the
// composer names the reader among the state's readers (NamedReaders, ADR 0547 clause 9), or when the owner announced
@@ -28,7 +56,8 @@ func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announ
case reader != "" && !ReaderMay(reader, r):
refused = append(refused, r)
case reader != "" && namedReader(reader, r):
// A state the composer names its readers for is theirs whatever its owner announced.
// A state the composer names its readers for is theirs whatever its owner announced (ADR 0561 §3: the
// controller's lease, which its announcement marks readable by nobody).
granted = append(granted, r)
case reader != "" && ok && said && s.Keeps(name):
if s.ReadableBy(name, reader) {
+328 -239
View File
@@ -2,10 +2,11 @@ package buscompose
import (
"encoding/json"
"maps"
"errors"
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
"strings"
"testing"
@@ -14,31 +15,173 @@ import (
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/nats-io/nats.go/jetstream"
)
// Issue 1126 B3a (novox/hq ADR 0590): the composed user lists and the server configuration are golden files this
// composer wrote (testdata/user-lists.json, testdata/composed.conf), first from the lists the ended controller composed
// with its user and the lease's grants taken out. A change to what any user is granted shows here as a diff to read;
// MESH_SDK_UPDATE_GOLDEN=1 writes them anew.
var updateGolden = os.Getenv("MESH_SDK_UPDATE_GOLDEN") == "1"
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
// byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own
// internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the
// controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller,
// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy
// gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and
// only those more.
// createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it.
var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `)
// golden compares got with the golden file name, or writes it.
func golden(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join("testdata", name)
if updateGolden {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatal(err)
// beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3,
// issue 1126), by user: a node's account reads every node's own facts, `<node>.node` of the have bucket where the node's
// record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
// (issue 1790) is the other row beyond, matched by createdAdvisory.
//
// And the watchman's dead letters and watchdogs (novox/hq ADR 0564 clauses 4, 5, issue 1952 S5): its own consumer on
// DEAD_LETTER_NOTICES, a read of DEAD_LETTERS, and the words its watchdogs hear, the controller's jobs until the lease
// write.
func beyondTheControllers(user string) map[string]bool {
out := map[string]bool{}
if strings.HasSuffix(user, "."+WatchmanModule) {
for _, s := range append(WatchmanDeadLetterGrants(nil), WatchmanHears()...) {
out[s] = true
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
node, ok := strings.CutPrefix(user, "node.")
if !ok {
return out
}
if string(got) != string(want) {
t.Errorf("%s differs from what is composed now; read the diff and, if it is meant, write it with MESH_SDK_UPDATE_GOLDEN=1", path)
for _, s := range append(append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...), NodeComposeAdvisories()...) {
out[s] = true
}
return out
}
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
func mayBeyondTheControllers(user string) map[string]bool {
// A runtime's read of its membership from the grants part's state (novox/hq ADR 0571 clause 7): its own key, or the
// tool runner's node's.
if parts := strings.Split(user, "."); len(parts) == 2 && parts[0] != "node" {
key := user
if parts[1] == RuntimeModule {
key = parts[0] + ".*"
}
out := map[string]bool{}
if membership.GrantsBucket != GrantsMembershipsBucket || membership.GrantsKey("a", "b") != MembershipKey("a", "b") {
panic("the membership package's grants bucket does not agree with buscompose's") // FollowMembership reads it
}
for _, s := range readGrants(GrantsMembershipsBucket, key) {
out[s] = true
}
return out
}
if _, ok := strings.CutPrefix(user, "node."); !ok {
return nil
}
return map[string]bool{VaultOwnCall(Principal{Kind: KindNode, Node: strings.TrimPrefix(user, "node.")}): true,
VaultSecretChanged: true}
}
// engineBeyond is a node's engine holding its node seat and asking the operator in its own name (novox/hq ADR 0563 §1,
// §3), beyond the controller's on purpose: its own node's verbs, events and discovery, the bus's discovery asked, and
// the router's seat traffic under node-engine-<node>.
func engineBeyond(user, g string) bool {
node, ok := strings.CutPrefix(user, "node.")
if !ok {
return false
}
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
// And its own health report (issue 774).
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
strings.Contains(g, "KV_"+provisions.ProvisionsBucket) ||
// And its node's placed modules' data items, its own node's keys (novox/hq ADR 0577 clause 1).
strings.Contains(g, "KV_"+EngineDataBucket) || strings.HasPrefix(g, "$KV."+EngineDataBucket+"."+node+".")
}
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
// and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone.
func createsAnAccountsConsumer(user, g string) bool {
return strings.HasSuffix(user, "."+GrantsModule) &&
(strings.HasPrefix(g, "$JS.API.CONSUMER.CREATE.") || strings.HasPrefix(g, "$JS.API.CONSUMER.INFO."))
}
// quotedToken is a quoted token of a composed list.
var quotedToken = regexp.MustCompile(`"[^"]*"`)
// grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its
// password's hash, every subject it may publish or subscribe to), by user.
func grantsByUser(list string) (string, map[string]map[string]bool) {
var outside strings.Builder
users := map[string]map[string]bool{}
user := ""
for _, line := range strings.SplitAfter(list, "\n") {
trimmed := strings.TrimSpace(line)
if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok {
user, _, _ = strings.Cut(name, `"`)
users[user] = map[string]bool{}
} else if user != "" && trimmed == "]" {
user = ""
}
if user == "" {
outside.WriteString(line)
continue
}
for _, q := range quotedToken.FindAllString(line, -1) {
users[user][strings.Trim(q, `"`)] = true
}
}
return outside.String(), users
}
// grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it
// names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and
// the copy's beyond them are exactly those, every one of them there.
func grantsAsTheControllers(t *testing.T, got, want string) {
t.Helper()
gotOutside, gotUsers := grantsByUser(got)
wantOutside, wantUsers := grantsByUser(want)
if gotOutside != wantOutside {
t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside)
}
for user, was := range wantUsers {
now, ok := gotUsers[user]
if !ok {
t.Errorf("the copy has no user %s", user)
continue
}
intended := beyondTheControllers(user)
for g := range was {
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
}
}
for g := range now {
if strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".") && !strings.HasSuffix(user, "."+GrantsModule) {
t.Errorf("%s: the copy grants %s, a read of the assignments stream, which goes at the lease write", user, g)
}
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) &&
!createsAnAccountsConsumer(user, g) && !engineBeyond(user, g) {
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
}
}
for g := range intended {
if !now[g] {
t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g)
}
}
}
for user := range gotUsers {
if _, ok := wantUsers[user]; !ok {
t.Errorf("the copy has a user %s the controller's has not", user)
}
}
}
@@ -59,33 +202,63 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" {
t.Errorf("a node hears the creation of %v", got)
}
type list struct {
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
if err != nil {
t.Fatal(err)
}
var want []struct {
Name, List string
Missing []string
Err string
Memberships json.RawMessage
}
var lists []list
for _, f := range UserListFixtures() {
l, missing, err := ComposedFor(f)
row := list{Name: f.Name, List: l, Missing: missing}
if err != nil {
row.Err = err.Error()
}
if row.Memberships, err = json.Marshal(MembershipsFor(f)); err != nil {
t.Fatal(err)
}
// B3a/AC1: no list holds a user for the ended controller.
if strings.Contains(l, `user: "controller"`) {
t.Errorf("%s: the list holds the controller's user", f.Name)
}
lists = append(lists, row)
}
raw, err := json.MarshalIndent(lists, "", " ")
if err != nil {
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
golden(t, "user-lists.json", append(raw, '\n'))
fixtures := UserListFixtures()
if len(want) != len(fixtures) {
t.Fatalf("%d fixtures, %d lists the controller composed", len(fixtures), len(want))
}
for i, f := range fixtures {
t.Run(f.Name, func(t *testing.T) {
if want[i].Name != f.Name {
t.Fatalf("fixture %q against the controller's %q", f.Name, want[i].Name)
}
list, missing, err := ComposedFor(f)
got := ""
if err != nil {
got = err.Error()
}
if got != want[i].Err {
t.Fatalf("error %q, the controller's %q", got, want[i].Err)
}
if strings.Join(missing, ",") != strings.Join(want[i].Missing, ",") {
t.Errorf("left out %v, the controller %v", missing, want[i].Missing)
}
// The one row the copy composes beyond the controller's, on purpose (novox/hq issue 1790, 2026-10-26, after
// the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state
// missing at its start is bound when it appears. The controller's composer is frozen and its list no longer
// loaded; while it is not, this row is left out of the comparison and nothing else.
grantsAsTheControllers(t, list, want[i].List)
// And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the
// grants part publishes them.
raw, err := json.Marshal(MembershipsFor(f))
if err != nil {
t.Fatal(err)
}
var gotM, wasM any
if err := json.Unmarshal(raw, &gotM); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil {
t.Fatal(err)
}
heldEventsBeyond(gotM, wasM)
if !reflect.DeepEqual(gotM, wasM) {
t.Errorf("the memberships differ from the controller's:\n%s", raw)
}
})
}
// An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters
// (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever).
t.Run("an asker reads its seat's dead letters", func(t *testing.T) {
@@ -200,12 +373,13 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
}
// L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's
// `signers`, never writing it; the ended controller's lease it reads no more (ADR 0590).
// `signers` and the lease key, never writing either.
signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true})
signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key"
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true,
"$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder": false,
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false} {
leaseGet := "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder"
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true, leaseGet: true,
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false,
"$KV." + LeaseBucket + ".holder": false} {
if MayPublish(signs, subject) != want {
t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want)
}
@@ -239,159 +413,14 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}
}
})
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
// is the one above (AC2: the controller's golden lists, unchanged).
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
users, err := Users(Records{Nodes: []string{"ace", "g14"},
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
NodeLogins: map[string]delivery.NodeLogin{
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
// A node that rotated before and rotates no more logs in as its second login alone.
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
if err != nil {
t.Fatal(err)
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Kind == KindNode {
p, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
perms[u.Username()] = p
}
}
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
}
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
own := func(p Permissions, login string) Permissions {
swap := func(in []string) []string {
out := make([]string, len(in))
for i, s := range in {
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
}
return out
}
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
AllowResponses: p.AllowResponses}
}
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
}
for login, p := range perms {
node, _ := delivery.LoginNode(login)
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
t.Errorf("%s's inbox: %v", login, p.Subscribe)
}
}
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
get := func(key string) string {
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
}
confirm := func(login string) string {
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
}
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
confirm("node.g14--b"): true, confirm("node.g14"): false} {
if MayPublish(perms["node.g14--b"], subject) != want {
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
}
}
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
t.Error("a node reads node-logins while the vault does not rotate")
}
// And nothing but the grants part names node-logins among its reads.
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
t.Error("node-logins' named readers")
}
})
// 774/S40/AC1 (ADR 0587 §4, §6, ADR 0590): an enrolling node's user may publish the redeem call in its own name and
// nothing else, and subscribe its own inbox alone.
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
{
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"},
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
if err != nil {
t.Fatal(err)
}
var lab *Principal
for i := range users {
if users[i].Username() == "enrol.lab" {
lab = &users[i]
}
}
if lab == nil {
t.Fatal("no user enrol.lab")
}
perms, err := PermissionsFor(*lab)
if err != nil {
t.Fatal(err)
}
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
perms.AllowResponses {
t.Errorf("enrol.lab publishes %v, subscribes %v, answers %v", perms.Publish, perms.Subscribe, perms.AllowResponses)
}
}
// No holder serving redeem: the user may enrol nowhere, refused by name.
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab"}); err == nil {
t.Error("an enrolment user that may enrol nowhere was composed")
}
})
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
// however wide its grant; and its bucket is read by the grants part alone besides it.
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
{Module: "mesh-vault", Holds: []Seat{vault}},
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
{Module: "shop", Invokes: calls[:2]},
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
if err != nil {
t.Fatal(err)
}
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
name := u.Username()
for _, verb := range []string{"enrolment-token", "node-account"} {
enrols := name == "ace.mesh-enrolment"
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
t.Errorf("%s may call %s: %v", name, verb, !enrols)
}
}
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
}
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
t.Errorf("%s writes the enrolments", name)
}
}
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
t.Error("the enrolments' named readers")
}
})
t.Run("the golden server configuration", func(t *testing.T) {
t.Run("the controller's golden server configuration", func(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
got, err := Compose(
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
[]Principal{
{Kind: KindEnrolment, Node: "one", Redeems: true, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
{Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"},
{Kind: KindEnrolment, Node: "one", PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
@@ -406,7 +435,11 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
if err != nil {
t.Fatal(err)
}
golden(t, "composed.conf", []byte(got))
want, err := os.ReadFile(filepath.Join("testdata", "composed.conf"))
if err != nil {
t.Fatal(err)
}
grantsAsTheControllers(t, got, string(want))
})
// 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by
// its user, at the subject `$KV.mesh-grants_accounts.<user>`, read by the vault alone; and the vault's account-issued
@@ -442,7 +475,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"},
{User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"},
{User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"},
{User: "controller", Kind: "controller", URL: "nats://a:1"},
{User: "controller", Kind: KindController, URL: "nats://a:1"},
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"},
} {
if bad.Valid() == nil {
@@ -512,18 +545,49 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Error("an account was held with no password")
}
})
// 774 subtask 8 (novox/hq ADR 0561 §6): a module's consumer is created as the grants part creates it, and every read is
// judged for its reader.
t.Run("every read is judged for its reader; a consumer is created as the grants part creates it", func(t *testing.T) {
announced := map[string]announce.States{}
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: a
// reader-less split refused the vault the grants part's accounts it is named for.
// 774 subtasks 6 and 8 (novox/hq ADR 0561 §3, §6): minting and the grants part's consumers turn on only while the
// lease is handed to a holder that is no controller; the vault and the grants part alone read the lease, whatever the
// controller announced; a module's consumer is created as the controller's EnsureConsumer creates it.
t.Run("the lease turns minting on only when handed away; a consumer is created as the controller's", func(t *testing.T) {
for _, row := range []struct {
name string
raw string
found bool
err error
on bool
}{
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
{"expired or deleted", ``, false, nil, false},
{"unreadable", ``, false, errors.New("refused"), false},
{"not a holder", `[1]`, true, nil, false},
{"both a controller and a handed holder", `{"instance":"novox/1","handed-to":"x"}`, true, nil, false},
} {
if on, why := LeaseHandedAway([]byte(row.raw), row.found, row.err); on != row.on || why == "" {
t.Errorf("%s: on %v (%s), want %v", row.name, on, why, row.on)
}
// The assigner's grants source follows it: the grants part's bucket only for a handed-away lease (issue 774).
on, _ := LeaseHandedAway([]byte(row.raw), row.found, row.err)
if bucket, _ := delivery.GrantSource(on); (bucket == delivery.PartGrantsBucket) != row.on {
t.Errorf("%s: the grants source is %s", row.name, bucket)
}
}
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox")}
for reader, want := range map[string]bool{VaultSeat: true, GrantsModule: true, "mesh-delivery-assigner": true, "mesh-delivery-view": false} {
granted, _ := ReadsAnnouncedBy(reader, []string{ControllerLease}, announced)
if (len(granted) == 1) != want {
t.Errorf("%s reads the lease: %v, want %v", reader, granted, want)
}
}
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: the
// controller's own reader-less copy refused the vault the lease and the grants part's accounts it is named for.
announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}}
announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}}
judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": {
{Module: VaultSeat, ReadsRefused: []string{GrantsAccounts}},
{Module: VaultSeat, ReadsRefused: []string{ControllerLease, GrantsAccounts}},
{Module: "mesh-config", ReadsRefused: []string{ControllerLease}},
{Module: "restic", ReadsRefused: []string{"disk-load.readings"}},
{Module: "messenger", Reads: []string{GrantsAccounts}}}}})
{Module: "messenger", Reads: []string{ControllerLease, GrantsAccounts}}}}})
if err != nil {
t.Fatal(err)
}
@@ -533,7 +597,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
reads[u.Module] = strings.Join(u.Reads, ",")
}
}
if reads[VaultSeat] != GrantsAccounts || reads["restic"] != "" || reads["messenger"] != "" {
if reads[VaultSeat] != ControllerLease+","+GrantsAccounts || reads["mesh-config"] != ControllerLease || reads["restic"] != "" || reads["messenger"] != "" {
t.Errorf("the reads granted for their readers: %v", reads)
}
// AccountsEntry is the very lines ComposeAccounts writes for a user, so a writer of kept users repeats none.
@@ -582,12 +646,18 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
func healthChecks(t *testing.T) {
t.Helper()
announced := map[string]announce.States{
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"),
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
for _, c := range []struct {
reader, read string
may bool
}{
{"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true},
{"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, {ArtifactStoreTools, ControllerLease, true},
{"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false},
{"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false},
// ADR 0565 clauses 10, 11 (issue 1953 L5-S4): mesh-hand-acts reads the controller's log beside its own.
{"mesh-hand-acts", ControllerHandActs, true},
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
// ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read.
{"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false},
@@ -600,7 +670,7 @@ func healthChecks(t *testing.T) {
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts"}},
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts", ControllerConditions}},
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}},
// Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them.
{Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}},
@@ -631,7 +701,7 @@ func healthChecks(t *testing.T) {
{Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}},
Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}},
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools",
Removes: []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}}, {Process: "other"}}},
Removes: []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs, catalogue.RemovesAssignmentsStream}}, {Process: "other"}}},
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
}}})
if err != nil {
@@ -670,18 +740,7 @@ func healthChecks(t *testing.T) {
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
// Issue 1126 B5 (ADR 0590 clause 6): every leftover of the ended controller, each by its name, read and deleted.
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
"$JS.API.STREAM.DELETE.KV_mesh-controller_rollouts", "$JS.API.STREAM.DELETE.KV_mesh-controller_states-announced",
"$JS.API.STREAM.DELETE.CONTROL", "$JS.API.STREAM.DELETE.OBJ_declaration-files-a",
"$JS.API.CONSUMER.DELETE.EVENTS.controller", "$JS.API.CONSUMER.DELETE.EVENTS.controller-merges",
"$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-delivery", "$JS.API.CONSUMER.DELETE.CONTROL.controller",
// And the consumers no part composes that the controller made before it ended: mesh-catalog's of the
// controller's built-before, and each node's engine's of restic's backed-up (issue 1126).
"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-catalog", "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-catalog",
"$JS.API.CONSUMER.INFO.EVENTS.a_mesh-host", "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-host",
"$JS.API.CONSUMER.INFO.CONTROL.controller"},
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
}
mayNot := map[string][]string{
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
@@ -724,14 +783,8 @@ func healthChecks(t *testing.T) {
// outside the bus module).
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
// It updates nothing, purges nothing, deletes no stream but the leftovers' and writes no key of them (ADR 0590).
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease", "$JS.API.STREAM.PURGE.KV_mesh-controller_lease",
"$JS.API.STREAM.DELETE.KV_mesh-controller_other", "$JS.API.STREAM.DELETE.OBJ_declaration-files-zz",
"$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-delivery", "$KV.mesh-controller_lease.holder"},
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X"},
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
}
@@ -762,25 +815,12 @@ func healthChecks(t *testing.T) {
MayPublish(perms, "$KV.mesh-host_health.b")) {
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
}
// ADR 0565 clause 6, ADR 0590: and the ended controller's mirror it reads no more.
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a")) {
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
}
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
t.Errorf("%s writes the assigner's policies", u.Username())
}
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
// the grants part's state.
if MaySubscribe(perms, "mesh.assignment.a.restic") {
t.Errorf("%s hears mesh.assignment", u.Username())
}
}
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
}
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
// long as the log (ADR 0297).
@@ -878,3 +918,52 @@ func healthChecks(t *testing.T) {
}
}
}
// heldEventsBeyond takes out of the copy's memberships the one row they list beyond the controller's on purpose (issue
// 1952): a held seat's own events in its holder's seat traffic, so a module the tool runner carries may say them. The
// controller's composer is frozen; any other difference is still a fault.
func heldEventsBeyond(got, was any) {
gotAll, _ := got.(map[string]any)
wasAll, _ := was.(map[string]any)
for key, m := range gotAll {
gm, _ := m.(map[string]any)
traffic, _ := gm["seat-traffic"].(map[string]any)
if traffic == nil {
continue
}
listedBefore := map[string]bool{}
if wm, _ := wasAll[key].(map[string]any); wm != nil {
if wt, _ := wm["seat-traffic"].(map[string]any); wt != nil {
for _, p := range asStrings(wt["publish"]) {
listedBefore[p] = true
}
}
}
var kept []any
for _, p := range asStrings(traffic["publish"]) {
if parts := strings.Split(p, "."); !listedBefore[p] && len(parts) == 5 && parts[0] == "mesh" && parts[1] == "seat" && parts[3] == "event" {
continue
}
kept = append(kept, p)
}
if kept == nil {
delete(traffic, "publish")
} else {
traffic["publish"] = kept
}
if len(traffic) == 0 {
delete(gm, "seat-traffic")
}
}
}
func asStrings(v any) []string {
list, _ := v.([]any)
out := make([]string, 0, len(list))
for _, x := range list {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
+128 -3
View File
@@ -1,14 +1,91 @@
package buscompose
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
)
// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-<node>`, holding the
// compositions the ended controller made for that node, which only that node's node-engine reads. Nothing writes them
// since the controller ended (ADR 0590); a node composes its own modules (ADR 0547 §2).
// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-<node>`, holding that node's
// compositions, which only that node's node-engine reads, since a composition can carry a sealed value (ADR 0488 §2's
// reason); and `mesh-controller_composed`, holding each composition's digest and the judgement of its move, which the
// assigner reads. The controller writes both, by compare-and-set while it holds the lease; each keeps five revisions
// of a key. The names and layouts are mesh-sdk's (delivery.ComposedBucketFor, ControllerComposedBucket, ComposedKey).
// ControllerComposed is the local name of the compositions' digests, as a reader names it in `reads` after
// `mesh-controller.`: delivery.ControllerComposedBucket.
const ControllerComposed = "composed"
// ComposedValueBytes is the most a composition may hold: a file over 64 KiB travels by digest, so a module's
// composition is far smaller; just under the server's largest message, which also carries the subject and headers.
const ComposedValueBytes = 1<<20 - 8<<10
// ComposedBucketBytes is a node's composition bucket's size: five revisions of every module's compositions of the node.
const ComposedBucketBytes = 256 << 20
// ComposedDigestsBytes is the size of the bucket of digests: a value of a few hundred bytes per node, module and
// fingerprint kept.
const ComposedDigestsBytes = 64 << 20
// ComposedConfig is a node's composition bucket as the controller asserts it.
func ComposedConfig(node string) (jetstream.KeyValueConfig, error) {
bucket, err := delivery.ComposedBucketFor(node)
if err != nil {
return jetstream.KeyValueConfig{}, err
}
return jetstream.KeyValueConfig{
Bucket: bucket,
Description: "the compositions of " + node + " (novox/hq ADR 0539): each module's resources for this node at one " +
"fingerprint, and the node's own, signed; written by the controller alone, read by this node's node-engine " +
"alone; deleted when outside the module's five newest fingerprints and named by no want",
History: delivery.ComposedHistory,
MaxValueSize: ComposedValueBytes,
MaxBytes: ComposedBucketBytes,
Storage: jetstream.FileStorage,
}, nil
}
// ComposedDigestsConfig is the bucket of the compositions' digests as the controller asserts it.
func ComposedDigestsConfig() jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: delivery.ControllerComposedBucket,
Description: "each composition's digest and the judgement of its move from what the node runs (novox/hq ADR 0539 " +
"clauses 2 and 14): written by the controller alone, read by the assigner",
History: delivery.ControllerComposedHistory,
MaxValueSize: 4 << 10,
MaxBytes: ComposedDigestsBytes,
Storage: jetstream.FileStorage,
}
}
// EnsureComposed creates the bucket of digests and each named node's composition bucket if absent and brings their
// options to match. An update, never a delete.
func EnsureComposed(ctx context.Context, js jetstream.JetStream, nodes ...string) error {
if _, err := js.CreateOrUpdateKeyValue(ctx, ComposedDigestsConfig()); err != nil {
return fmt.Errorf("asserting bucket %s: %w", delivery.ControllerComposedBucket, err)
}
for _, node := range nodes {
cfg, err := ComposedConfig(node)
if err != nil {
return err
}
if _, err := js.CreateOrUpdateKeyValue(ctx, cfg); err != nil {
return fmt.Errorf("asserting bucket %s: %w", cfg.Bucket, err)
}
}
return nil
}
// ComposedWrites is what the controller publishes to write a node's compositions: the bucket's own subjects.
func ComposedWrites(node string) (string, error) {
bucket, err := delivery.ComposedBucketFor(node)
if err != nil {
return "", err
}
return "$KV." + bucket + ".>", nil
}
// ComposedReads is what a node's node-engine publishes to follow its wants and compositions (ADR 0539 clauses 1, 2 and
// 9): every key of its own composition bucket, and its own wants (`want.<node>.<module>` and `want.<node>.node`) in the
@@ -49,6 +126,42 @@ var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-
// A second holder of the seat is a change of this grant.
const BackupHolderModule = "restic"
// administers says a person may invoke every tool (`*`): the operator.
func administers(invokes []string) bool {
for _, i := range invokes {
if i == "*" {
return true
}
}
return false
}
// HandPathWrites is what the operator's account publishes for release 6's hand path (ADR 0539 clauses 2 and 10): a want
// of any node, written and read back, in the assigner's bucket; each named node's compositions; the digests; and each
// node's object bucket for a file over 64 KiB, whose put reads an object's metadata and purges a replaced one's chunks.
func HandPathWrites(nodes []string) ([]string, error) {
out := append([]string{"$KV." + delivery.AssignerWantsBucket + ".want.>"}, readGrants(delivery.AssignerWantsBucket, "want.>")...)
out = append(out, "$KV."+delivery.ControllerComposedBucket+".>")
out = append(out, readGrants(delivery.ControllerComposedBucket, "")...)
for _, node := range nodes {
w, err := ComposedWrites(node)
if err != nil {
return nil, err
}
bucket, _ := delivery.ComposedBucketFor(node)
out = append(out, w)
out = append(out, readGrants(bucket, "")...)
files, err := DeclarationFilesWrites(node)
if err != nil {
return nil, err
}
stream := "OBJ_" + DeclarationFilesBucket + node
out = append(out, files, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream+".>",
"$JS.API.STREAM.MSG.GET."+stream, "$JS.API.STREAM.PURGE."+stream)
}
return out, nil
}
// DeclarationsStream is the stream of each node's last declaration, `mesh.node.<node>.declare` (issue 107). Release 6
// sends none (ADR 0539 clauses 5 and 11): the stream is kept, with each node's consumer, only so a node away during the
// switch finds its last declaration and with it the new node-engine; the controller deletes it once every node's have
@@ -69,3 +182,15 @@ func RaiseAfterDeclarations(r Raiser) error {
}
return AssertMeshConsumers(r)
}
// TakeInConsumer is the durable consumer the controller takes done keys in from, on the stream of each of TakeInBuckets
// (novox/hq ADR 0544 §1).
const TakeInConsumer = "mesh-controller-take-in"
// TakeInBuckets are the buckets the take-in consumes: the indexer's modules (each module's trunk value) and the builder's
// builds (each build's key).
var TakeInBuckets = []string{delivery.ModulesBucket, delivery.BuilderBuildsBucket}
// ConfigFollowConsumer is the durable consumer the controller follows mesh-config's resolved values with, on that
// bucket's stream (novox/hq ADR 0545).
const ConfigFollowConsumer = "mesh-controller-config-follow"
+247
View File
@@ -0,0 +1,247 @@
package buscompose
import (
"context"
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
)
// The controller's own key-value buckets (novox/hq to-be 45 §1, §6, §7).
//
// **What the controller must remember across its own restart, it keeps on the bus.** A call's
// outcome lived in the memory of the process that served it (novox/hq issue 265), so a controller
// replaced while a push ran answered "no such call" for the one thing its caller had been told to
// ask about. The bus already outlives the controller and is the shape ADR 0201 gives a module's
// current state: one value per key, written by one owner, read by anybody granted it. These are the
// controller's, written by it alone — the writers table of to-be 45 §1 — and asserted on every start
// like the streams, so a bus raised from nothing has them before the first call is served.
// CallsBucket keeps every call of the mesh's own verbs and what came of it; HandActsBucket every act
// a person did by hand, with why; ConditionsBucket every condition open now (to-be 45 §2), one key
// each, and ConditionHistoryBucket every transition of one — raised, changed, silenced, cleared —
// for ninety days.
//
// **The history is a bucket of its own** because its keys expire and an open condition's must not:
// a bucket has one age for every key, and a condition open longer than the history is kept would
// otherwise vanish from the store while still true.
var (
CallsBucket = BucketName(ControllerSeat, "calls")
HandActsBucket = BucketName(ControllerSeat, "hand-acts")
ConditionsBucket = BucketName(ControllerSeat, "conditions")
ConditionHistoryBucket = BucketName(ControllerSeat, "condition-history")
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
LeaseBucket = BucketName(ControllerSeat, "lease")
// AskedBucket keeps what the controller asked the operator about its conditions (novox/hq ADR 0259):
// each ask by its id, its options and the actions they stand for, how it ended and whether the
// controller acted on its warrant — so a restart neither asks twice nor acts twice.
AskedBucket = BucketName(ControllerSeat, "asked")
// DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged
// on (novox/hq ADR 0420 §7, issue 1025): per merge a key `<owner>/<repo>/<number>/<head sha>` holding the
// announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new
// repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of
// those heads. Written by the controller alone, from the announcement, when it holds the lease.
DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges")
)
// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are.
const DeliveryMergesKeptFor = 7 * 24 * time.Hour
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
// record, one key per record, written from the store by the lease holder only when the record changed, with
// one event on the controller's seat per write. Read by whatever names them in `reads` as
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
// assignment's value names its module and node; a module's declared config is what its manifest, which is
// public, declares.
//
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4); and PredefinedBucket the
// list of predefined keys the mesh published (the newest trunk build's of the catalogue, ADR 0348 §7), one key
// `list`: what mesh-config publishes as mesh-config_predefined (design 57 K3, issue 662).
var (
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined)
// DeclaredPackagesBucket holds, keyed by the node, each package the modules assigned to it declare, present or absent,
// with the declaring modules: what the pacman module's verbs read to say a package is declared (ADR 0507 §5).
DeclaredPackagesBucket = BucketName(ControllerSeat, MirrorDeclaredPackages)
)
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
const (
MirrorAssignments = "assignments"
MirrorDeclaredConfig = "declared-config"
MirrorPredefined = "predefined"
// MirrorDeclaredPackages is the local name of the packages each node's modules declare (ADR 0507 §5, issue 1840/S3).
MirrorDeclaredPackages = "declared-packages"
)
// The chain's buckets (novox/hq ADR 0408, issue 1089): builds, rollouts and assignments are one chain kept on the bus
// alone, one bucket each, one key per record, written by the lease holder by compare-and-set. The assignments bucket is
// AssignmentsBucket above, which keeps its name and its readers (ADR 0408 §3). Nothing is removed from them (§4), they keep
// ChainHistory earlier revisions of every key (§5), and a value is never cut (§6).
var (
BuildsBucket = BucketName(ControllerSeat, ChainBuilds)
RolloutsBucket = BucketName(ControllerSeat, ChainRollouts)
)
// **The assignments bucket keeps the marker age (MirrorMarkerTTL) it was made with as a mirror.** A stream that allows
// messages an age cannot be made to disallow them (the server refuses the update: "message TTL status can not be
// disabled", nats-server stream.go), so asserting the bucket without the age would fail the controller's start on a
// mesh that has the bucket. The chain never purges or removes a key (ADR 0408 §4), so no marker is ever written and the
// age ends nothing; it is kept only so the stream's configuration stays one the server accepts.
//
// The chain buckets' local names, as a reader names them in `reads` after `mesh-controller.`.
const (
ChainBuilds = "builds"
ChainRollouts = "rollouts"
)
// The chain buckets' bounds. ChainHistory is the bus's largest history (ADR 0408 §5). A value is at most ChainValueBytes,
// just under the server's largest message of a mebibyte, which also carries the subject and the headers (ADR 0408 §6):
// the controller writes none larger and the move refuses to. The buckets' size is the arithmetic of the survey of
// issue 981: 8585 builds of 1 to 40 KB, 1049 rollouts of about 2 KB a revision and 64 revisions, so 512 MiB for the two
// that grow, and the assignments, 285 of under a kilobyte, 64 MiB.
const (
ChainHistory = 64
ChainValueBytes = 1<<20 - 8<<10
ChainBuildsBytes = 512 << 20
ChainRolloutsBytes = 512 << 20
ChainAssignmentBytes = 64 << 20
)
// GrantsBucket holds, keyed `<module>.<fingerprint hex>`, each fingerprint of a module whose bus credential the controller
// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's
// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner,
// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's
// (delivery.ControllerGrantsBucket, ControllerGrantKey).
var GrantsBucket = BucketName(ControllerSeat, ControllerGrants)
// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`.
const ControllerGrants = "grants"
// ChainBuckets are the three buckets of the chain: builds, rollouts and assignments.
func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} }
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
// that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no
// other.
var MirrorsWrittenByAnother = map[string]string{}
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
const (
MirrorValueBytes = 64 << 10
MirrorBytes = 64 << 20
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
// per-message age for it, and the writer gives each marker a day.
MirrorMarkerTTL = 24 * time.Hour
)
// PlanStateBuckets are the buckets of the plans' state that release 6 deletes (novox/hq ADR 0539 clause 13): the merge
// window's clock, the silent sends' waits and the building plans' hold. The controller deletes each at its first start
// and says it once (composed_start.go); none is asserted, granted or written any more.
var PlanStateBuckets = []string{BucketName(ControllerSeat, "merge-window"), BucketName(ControllerSeat, "rollout-silent"),
BucketName(ControllerSeat, "plans-building")}
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
const AskedKeptFor = 30 * 24 * time.Hour
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
const LeaseTTL = 15 * time.Second
// The bounds to-be 45 §6 sets for calls: the last thousand, or fourteen days, whichever is fewer.
// A call is two keys — its record, and its answer apart so a listing does not read every answer —
// so the stream holds twice as many messages as it keeps calls.
const (
KeptCallsDurably = 1000
CallsKeptFor = 14 * 24 * time.Hour
// CallAnswerBytes is the most of one answer kept: a whole declaration is far smaller, and an
// answer larger is cut and says so.
CallAnswerBytes = 64 << 10
// HandActsKeptFor is as long as a condition's history (to-be 45 §2): an act by hand is read
// back beside what it addressed.
HandActsKeptFor = 90 * 24 * time.Hour
// ConditionHistoryKeptFor is how long a condition's transitions are kept (to-be 45 §2).
ConditionHistoryKeptFor = 90 * 24 * time.Hour
)
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
func IsControllerBucket(bucket string) bool {
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
bucket == DeclaredPackagesBucket ||
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket ||
bucket == GrantsBucket || bucket == delivery.ControllerComposedBucket
}
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
func ControllerBuckets() []string {
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket,
DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket,
delivery.ControllerComposedBucket}
}
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
type ControllerBucketsAsserter interface {
EnsureControllerBuckets() error
}
// chainBucket is the options of a bucket of the chain: sixty-four revisions of each key, no age, a value up to
// ChainValueBytes, on disk.
func chainBucket(bucket, why string, bytes int64, markers time.Duration) jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: bucket,
Description: why,
History: ChainHistory,
MaxValueSize: ChainValueBytes,
MaxBytes: bytes,
Storage: jetstream.FileStorage,
LimitMarkerTTL: markers,
}
}
// EnsureLeaseBucket creates the lease's bucket if absent and brings its options to match (to-be 45
// §6). **Before the lease is taken, by any candidate**: it is the lease's own precondition, and asserting
// a bucket that exists changes nothing. File storage, so its revisions — the epochs — outlive a restart of
// the bus; one raised again from nothing is moved past the highest epoch issued when the lease is taken.
func EnsureLeaseBucket(ctx context.Context, js jetstream.JetStream) error {
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: LeaseBucket,
Description: "the controller's lease (novox/hq to-be 45 §6): the key `holder`, written by compare-and-set " +
"by the one controller instance that may act and renewed every five seconds; its revision when taken " +
"is the epoch every declaration and plan write carries",
History: 1,
TTL: LeaseTTL,
MaxValueSize: 4 << 10,
MaxBytes: 1 << 20,
Storage: jetstream.FileStorage,
}); err != nil {
return fmt.Errorf("asserting bucket %s: %w", LeaseBucket, err)
}
return nil
}
// HaveBucketConfig is the options of the have bucket (novox/hq ADR 0429 §1): delivery.HaveHistory revisions of each
// key, no age, 64 MiB on disk like the assignments. A module's have is JSON of about 500 bytes, but a node's own key
// carries its whole facts (its profile among them), several kilobytes on a desktop: a value is as large as the chain's
// buckets take, since a value is never cut (novox/hq ADR 0408 §6, issue 1162).
func HaveBucketConfig() jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: delivery.HaveBucket,
Description: "what each machine has of each module, one key `<node>.<module>` (and `<node>.node` for the machine's " +
"own facts) (novox/hq ADR 0429, design 47): written by each machine's node-engine under its own node's keys " +
"alone, never by the controller; read by the delivery view and the watchman",
History: delivery.HaveHistory,
MaxValueSize: ChainValueBytes,
MaxBytes: 64 << 20,
Storage: jetstream.FileStorage,
}
}
+46 -75
View File
@@ -10,94 +10,64 @@ import (
"git.novox.be/novox/mesh-sdk/go/health"
)
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5; ADR 0590).
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5).
//
// - Nobody reads mesh-healers' `would` (clause 8): what the healers would have done reaches no condition. Every
// observer's `findings`, the healers' `attempts`, the wants and the haves are read through `reads` as any announced
// state is; and no module writes another's state, so an observer can never write the keeper's buckets (clause 2).
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the ended
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5).
// - The condition keeper reads the controller's open conditions until the lease write, for what clauses 8 and 9 carry
// from them, and mesh-healers the controller's hand-act log, for S15 (Limits 2): both buckets the controller
// announces readable by nobody, so each is read by its named reader alone. The keeper, mesh-healers and the
// watchman read the controller's lease (Limits 8). Nobody reads mesh-healers' `would` (clause 8): what the healers
// would have done reaches no condition. Every observer's `findings`, the healers' `attempts`, the wants and the
// haves are read through `reads` as any announced state is; and no module writes another's state, so an observer
// can never write the keeper's buckets (clause 2).
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). It
// lists and shows DEAD_LETTERS, which is all it does before the lease write.
// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes`
// (catalogue.BusAccounts), which may remove only keys of the node-engine's acts bucket (Limits 4) and the ended
// controller's leftovers (ADR 0590 clause 6), never the backup's account `broker`, which only reads (ADR 0235).
// (catalogue.BusAccounts), which may remove only the consumer `controller` on DEAD_LETTER_NOTICES and keys of the
// node-engine's acts bucket (Limits 4), never the backup's account `broker`, which only reads (ADR 0235).
// HealersWould is what mesh-healers would have done while a controller was placed: read by nobody (ADR 0564 clause 8).
var HealersWould = health.HealersModule + "." + health.WouldState
// The states the composer names its readers for, beside the lease (NamedReaders).
var (
// ControllerConditions is the controller's open conditions, read by the keeper alone (ADR 0564 clauses 8, 9).
ControllerConditions = ControllerSeat + ".conditions"
// ControllerHandActs is the controller's hand-act log, read by mesh-healers alone of the four health holders (ADR
// 0564 Limits 2).
ControllerHandActs = ControllerSeat + ".hand-acts"
// HealersWould is what mesh-healers would have done while the lease names a controller: read by nobody (clause 8).
HealersWould = health.HealersModule + "." + health.WouldState
)
// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module
// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before,
// which its manifest no longer consumes and no part composes; each `<stream>.<consumer>`.
var ControllerLeftoverConsumers = []string{
"EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog",
"CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName,
}
// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller
// made for the node's engine (`EVENTS.<node>_mesh-host`, of restic's backed-up): the engine hears that event without a
// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126).
func ControllerLeftoverConsumersOn(nodes []string) []string {
out := slices.Clone(ControllerLeftoverConsumers)
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "EVENTS."+n+"_"+EngineModule)
}
}
return out
}
// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_<name>`).
var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed",
"condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts",
"lease", "predefined", "rollouts", "states-announced"}
// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets'
// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2).
func ControllerLeftoverStreams(nodes []string) []string {
out := []string{"CONTROL", AssignmentsStream}
for _, b := range ControllerLeftoverBuckets {
out = append(out, "KV_"+BucketName(catalogue.EndedControllerName, b))
}
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "OBJ_"+DeclarationFilesBucket+n)
}
}
return out
}
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause
// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and
// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the
// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a
// manifest (novox/hq ADR 0578 §1).
func BusRemovalGrants(removes, nodes []string) ([]string, error) {
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, issue 1952
// notes 6 and 7), each removal its grant: the controller's consumer on DEAD_LETTER_NOTICES read and deleted (the lease
// write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; the ASSIGNMENTS stream read and deleted whole (the lease
// write, research 072 row 33). A
// removal outside the set is refused, as the indexer's judge refuses it in a manifest (novox/hq ADR 0578 §1).
func BusRemovalGrants(removes []string) ([]string, error) {
var out []string
for _, r := range removes {
switch r {
case catalogue.RemovesControllerDeadLetterConsumer:
out = append(out, "$JS.API.CONSUMER.INFO."+DeadLetterNoticesStream+"."+ControllerName,
"$JS.API.CONSUMER.DELETE."+DeadLetterNoticesStream+"."+ControllerName)
case catalogue.RemovesEngineActs:
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
case catalogue.RemovesControllerLeftovers:
for _, c := range ControllerLeftoverConsumersOn(nodes) {
out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c)
}
for _, s := range ControllerLeftoverStreams(nodes) {
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s)
}
case catalogue.RemovesAssignmentsStream:
out = append(out, "$JS.API.STREAM.INFO."+AssignmentsStream, "$JS.API.STREAM.DELETE."+AssignmentsStream)
default:
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r,
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r,
strings.Join([]string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs,
catalogue.RemovesAssignmentsStream}, ", "))
}
}
return out, nil
}
// removesLeftover is an account declared to remove the ended controller's leftovers deleting one of its streams: the
// writers table's one exemption from stream definitions being the grants part's (ADR 0590 clause 6).
func removesLeftover(p Principal, pattern string) bool {
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.DELETE.")
return ok && p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesControllerLeftovers) &&
slices.Contains(ControllerLeftoverStreams(p.FilesFor), name)
// removesAssignmentsStream is an account declared to remove the stream ASSIGNMENTS: the writers table's one exemption from
// stream definitions being the controller's, for deleting that one stream at the lease write (research 072 row 33).
func removesAssignmentsStream(p Principal, pattern string) bool {
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesAssignmentsStream) &&
pattern == "$JS.API.STREAM.DELETE."+AssignmentsStream
}
// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one
@@ -144,7 +114,7 @@ func WatchmanDeadLetterGrants(sources []string) []string {
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
// connections (S9), the provisioner clients' reports (S21), and the dead letters kept. Never the bus's API audit
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
// them (the lead, 2026-10-29).
func WatchmanHears() []string {
@@ -208,5 +178,6 @@ func deadLetterSourcesOf(r Records) []string {
// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the
// controller's in L4.
func keepsDeadLetters(p Principal, pattern string) bool {
return p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">"
return isController(p, pattern) ||
(p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">")
}
+3
View File
@@ -53,6 +53,9 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
}
for _, s := range d.Holds {
if servedOnlyByTheController(s) {
continue // answered by the serving controller alone, never through a membership
}
for _, verb := range s.Serves {
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
}
+465 -150
View File
@@ -15,13 +15,13 @@ package buscompose
import (
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/announce"
"regexp"
"slices"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -31,9 +31,10 @@ import (
type Kind string
const (
KindModule Kind = "module"
KindNode Kind = "node"
KindEnrolment Kind = "enrolment"
KindModule Kind = "module"
KindNode Kind = "node"
KindController Kind = "controller"
KindEnrolment Kind = "enrolment"
// KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its
// authority is a list of tools and nothing else — not control, not declarations, not builds,
// and no ability to answer anything, because a person asks.
@@ -106,9 +107,8 @@ type Principal struct {
// Username and inbox are <node>.<module>.<process id>.
Process string
// FilesFor are the nodes whose declaration-file buckets (novox/hq ADR 0486 §2) this principal may remove, for the bus
// module's tools removing the ended controller's leftovers alone (ADR 0590 clause 6): every node of the mesh, one
// bucket each.
// FilesFor are the nodes whose declaration-file buckets this principal writes, for the controller alone (novox/hq ADR 0486 §2):
// every node of the mesh, one bucket each.
FilesFor []string
Emits []string
@@ -144,15 +144,30 @@ type Principal struct {
// permission and nothing beside it.
Invokes []string
// AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software
// issued, each `<module>.<tool>` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller,
// so the tool knows the vault asks.
// AsksIssued are the tools the controller asks in its own name for a credential a provider's software
// issued, each `<module>.<tool>` (novox/hq ADR 0305): on the subject that names its caller, so the tool
// knows the controller asks. The vault's holder is granted the same tools in its own name (issue 723,
// vaultCalls); the controller's grant goes with its path in R1 (issue 682). Composed from the records, so the installer's first user list — a controller
// with no records — carries none, and moves with issue 464 like the rest of the controller's caller-named
// grants.
AsksIssued []string
// AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault,
// issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
AsksTheVault []string
// AsksTheConfig are the mesh-config verbs the controller asks in its own name (VerbsTheControllerAsksTheConfig,
// issue 925), those mesh-config's holder serves: on the subject that names its caller, so mesh-config takes a
// value as the controller carries it (ADR 0348 §8). Composed from the records like AsksTheVault, so the
// installer's first user list carries none.
AsksTheConfig []string
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10).
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10). The controller
// keeps its own grant to both until R1 (issue 682), for the modules still filled the old way (clause 17).
AsksTheDesk bool
// State is the local names of the state this principal's module keeps, and Reads the state of
@@ -190,6 +205,11 @@ type Principal struct {
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
DeadLetterSources []string
// WitnessesController is a machine principal whose node-engine witnesses the controller's upgrades:
// the machine runs the controller (novox/hq ADR 0236, lease/witness.go). It may read the lease's one
// key, and nothing else of the bucket, so it can judge a new controller build and put the previous
// one back.
WitnessesController bool
// Checks are the tools a machine principal's node-engine asks as a declared health check, each
// `<module>.<tool>` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and
// nowhere else.
@@ -230,26 +250,6 @@ type Principal struct {
// stream and name. Created only, never updated or deleted.
CreatesConsumers []ModuleConsumer
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
// vault tells the login that confirms a rotation from the one it replaces (§3).
Login string
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
// §3). Set while the vault's holder serves rotate-node.
RotatesItsLogin bool
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
// enrolment subject from it, which is served no more from the lease write (§6).
Redeems bool
LeaseHandedAway bool
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
VaultEnrols bool
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
// and never appears here: this file is written to a node's disk and read by a server, and a
// secret that can be read from a configuration file is a secret with a wider blast radius
@@ -278,18 +278,17 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb:
// takes before it asks its first tier (novox/hq ADR 0404).
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
// VerbsTheVaultAsksForASecret are the seat verbs the vault's holder calls for a secret typed at a desk (novox/hq ADR
// 0259 §10, ADR 0349 clause 9): the operator's desk opens a prompt that does not show what is typed, and answers it
// sealed to the vault's call.
var VerbsTheVaultAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's
// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call.
var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
// DeskOnly are the subjects the vault's holder alone may publish in its own name, however wide another's grant (the
// review of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module subjects. A
// grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would otherwise reach it,
// and the prompt says the vault asks: only the bus makes that true.
func DeskOnly() []string {
// ControllerOnly are the subjects the controller alone may publish, however wide another's grant (the review
// of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module
// subjects. A grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would
// otherwise reach it, and the prompt says the controller asks: only the bus makes that true.
func ControllerOnly() []string {
var out []string
for _, v := range VerbsTheVaultAsksForASecret {
for _, v := range VerbsTheControllerAsksForASecret {
for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} {
out = append(out, base, base+".*")
}
@@ -301,22 +300,6 @@ func DeskOnly() []string {
return out
}
// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587
// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired.
var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb},
{Seat: VaultSeat, Verb: delivery.NodeAccountVerb}}
// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the
// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them).
func EnrolmentOnly() []string {
var out []string
for _, v := range VerbsTheEnrolmentAsksTheVault {
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>",
"mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>")
}
return out
}
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
func MayPublish(perms Permissions, subject string) bool {
for _, d := range perms.PublishDeny {
@@ -342,6 +325,42 @@ func MaySubscribe(perms Permissions, subject string) bool {
return false
}
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
// through `close`. A mesh seat's verb is flat: no machine in the subject.
//
// And, since novox/hq ADR 0259, `release` and `stop`: the controller asks the operator for them about a
// delivery held past its bound, and calls them on the operator's warrant, with its why.
//
// And, since novox/hq ADR 0282, `times`: the self-check reads the delivery times to say a delivery over its budget.
//
// And, since novox/hq ADR 0306, `groups`: a merge heard from a head waits for the window while its delivery group
// has a member still to merge.
var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", Verb: "stalled"},
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
{Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}}
// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq
// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk
// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a
// rotation. The vault takes these from the controller alone, which it knows from the subject that names its
// caller; granted as Principal.AsksTheVault says.
//
// And `move-asks` (design 57's V3, issue 676): the controller copies its record of asks at a desk, which holds no
// value, into the vault's.
//
// And `get` (novox/hq ADR 0349 §16, issue 755): the controller asks for a core module's own credential sealed to its
// machine's key, which it carries in that machine's declaration and cannot open (mesh-vault's core.go).
var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"},
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"},
{Seat: "mesh-vault", Verb: "move-asks"}, {Seat: "mesh-vault", Verb: "get"}}
// VerbsTheControllerAsksTheConfig are the mesh-config seat's verbs the controller calls in its own name (issue 925):
// `config`, the one verb mesh-config serves, which move-config uses to set each value and the settings verb to set,
// clear and propose through mesh-config. mesh-config takes a value as the controller carries it only from the
// subject that names the controller as caller (ADR 0348 §8); granted as Principal.AsksTheConfig says.
var VerbsTheControllerAsksTheConfig = []SeatVerb{{Seat: "mesh-config", Verb: "config"}}
// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its
// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says.
var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"}
@@ -358,31 +377,122 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node
// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a
// node-engine confirms a switch with, in its new login's name (§3).
var (
VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb}
VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb}
)
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
// sent by its own `upgrade release-backlog` (novox/hq issue 1732). Named one by one; a node seat's on any machine, a
// mesh seat's flat.
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
{Seat: ControllerSeat, Verb: "plans"}, {Seat: ControllerSeat, Verb: "push"}, {Seat: ControllerSeat, Verb: "upgrade"}}
// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol.<node> (novox/hq ADR 0587 §4,
// §6).
var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem}
// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq
// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named,
// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject.
var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}}
// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its
// own name.
func EnrolmentRedeemCall(p Principal) string {
return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username())
// DegradableGrant is a publish grant whose absence from the installer's carried first-user list is not a
// bug in the template: a fallback covers the refusal.
type DegradableGrant struct {
Subject string
Reason string
}
// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched,
// and no other (novox/hq ADR 0583 §1 insight).
func NodeLoginsReads(node string) []string {
if !safeSubject.MatchString(node) {
return nil
}
return readGrants(delivery.NodeLoginsBucket, node)
// DegradableGrants are the only publish grants TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose
// may find missing from the installer's carried list without failing (novox/hq issue 835, the forge check
// of mesh-controller#345): a change that only edits mesh-host's examples can never satisfy a check that
// reads the mesh-host commit the machines actually run, so the carried list lags behind the controller's
// derived one until mesh-host's own module next builds. operator-channel.tell is the one grant the
// controller already has a fallback for: refused (no holder, or the bus refuses it), the controller raises
// the secret-given condition exactly as before (ADR 0380 §9), so an older carried list still comes up and
// works. Every other grant stays required — missing one of those is a controller that comes up and is
// refused on the first thing it tries, with no fallback that covers it.
var DegradableGrants = []DegradableGrant{
{
Subject: announce.Subject(ControllerSeat),
Reason: "refused: the controller's own announcement waits for its first push, and until it is kept every reader of " +
"its states is granted from its `reads` as before (novox/hq ADR 0495, issue 1790)",
},
{
Subject: "$KV." + StatesAnnouncedBucket + ".>",
Reason: "refused: no announcement is kept until the controller's first push, and until then every reader is " +
"granted from its `reads` as before (novox/hq ADR 0495, issue 1790)",
},
{
Subject: "mesh.seat." + ControllerSeat + ".tool.push",
Reason: "refused: the ask of a held move (Release) is answered, and the refusal told, while the operator's own " +
"push at the terminal still sends the machine (issue 1732; ADR 0259)",
},
{
Subject: "mesh.seat." + ControllerSeat + ".tool.upgrade",
Reason: "refused: the ask of the builds held after a failed release is answered, and the refusal told, while the " +
"operator's own `upgrade release-backlog` at the terminal still releases them (issue 1732; ADR 0259)",
},
{
Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb,
Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)",
},
{
Subject: "$JS.ACK.EVENTS." + MergesConsumerName + ".>",
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at " +
"its first push, which is when it acknowledges a merge (ADR 0407 decision 3, issue 973)",
},
{
Subject: "$KV." + DeclaredPackagesBucket + ".>",
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
"first push, and keeps the packages each node declares only after that, holding the lease (ADR 0507 §5, issue 1840)",
},
{
Subject: "mesh.seat." + ControllerSeat + ".event.artifacts-gone",
Reason: "refused: the take-in is refused all the same and says why; only the builder's rebuild waits for the " +
"controller's first push, and the builder also asks held when a module's value or its own start comes (novox/hq " +
"issue 1937)",
},
{
Subject: "mesh.seat." + ControllerSeat + ".event.sent",
Reason: "refused: the controller says a tier sent to a machine only for outsiders, and a send that could not " +
"be said is printed and goes on (ADR 0406 §5, issue 974)",
},
{
Subject: "$KV." + DeliveryMergesBucket + ".>",
Reason: "refused: the controller then keeps no merge mesh-delivery announced, so the build of a moved module is " +
"refused as it is without the operator's Approve — failing closed (ADR 0420 §7, issue 1025)",
},
{
Subject: "$KV." + BuildsBucket + ".>",
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
"first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)",
},
{
Subject: "mesh.seat." + ControllerSeat + ".event.bucket-deleted",
Reason: "refused: nothing is deleted at genesis; the controller composes its own permissions into the bus module's " +
"composition before a later start's deletions (novox/hq ADR 0539 clause 13)",
},
{
Subject: "$KV." + delivery.ControllerComposedBucket + ".>",
Reason: "carried only at genesis: the controller composes its own permissions into the bus module's composition, " +
"and writes a composition's digest only after the bus applied it (novox/hq ADR 0539 clauses 2 and 7)",
},
{
Subject: "$JS.ACK.KV_" + delivery.ModulesBucket + "." + TakeInConsumer + ".>",
Reason: "refused: an acknowledgement refused leaves the key delivered again after its wait, and the take-in's first " +
"batch with something new composes the bus's node, which grants it (novox/hq ADR 0544 §1)",
},
{
Subject: "$JS.ACK.KV_" + delivery.BuilderBuildsBucket + "." + TakeInConsumer + ".>",
Reason: "refused: an acknowledgement refused leaves the key delivered again after its wait, and the take-in's first " +
"batch with something new composes the bus's node, which grants it (novox/hq ADR 0544 §1)",
},
{
Subject: "$KV." + GrantsBucket + ".>",
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
"first push, and writes a grant only after a send of the bus's user list, which comes after that (ADR 0528 " +
"clause 2, issue 1394)",
},
{
Subject: "$KV." + RolloutsBucket + ".>",
Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " +
"first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)",
},
}
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
@@ -415,10 +525,9 @@ func (p Principal) Username() string {
// never the module's own name alone, which is a different account with a different secret.
return p.Node + "." + p.Module + "." + p.Process
case KindNode:
if p.Login != "" {
return p.Login
}
return "node." + p.Node
case KindController:
return "controller"
case KindProvisioner:
// Named for the machine, as a node is: one client per machine, whichever module runs it, and never
// `<node>.<module>`, which is the module's own account or the tool runner's.
@@ -475,19 +584,12 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
// inbox is derived from its own identity and its permissions name that prefix and no other.
//
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
func (p Principal) inbox() string {
if p.Kind == KindNode {
return "_INBOX.node." + p.Node + ".>"
}
return "_INBOX." + p.Username() + ".>"
}
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
// Permissions is what a principal may publish and subscribe, and whether it may answer.
type Permissions struct {
Publish []string
// PublishDeny are subjects refused although an allow covers them: the vault's holder's alone (DeskOnly),
// PublishDeny are subjects refused although an allow covers them: the controller's alone (ControllerOnly),
// denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow.
PublishDeny []string
Subscribe []string
@@ -562,7 +664,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
if p.Kind == KindProcess && len(p.Removes) > 0 {
pub, err := BusRemovalGrants(p.Removes, p.FilesFor)
pub, err := BusRemovalGrants(p.Removes)
if err != nil {
return Permissions{}, err
}
@@ -576,6 +678,157 @@ func PermissionsFor(p Principal) (Permissions, error) {
var pub, sub []string
switch p.Kind {
case KindController:
// The controller owns the mesh's own traffic and the streams. It is the only writer of
// stream definitions (design 25 §3), so it alone reaches the JetStream API — and it alone
// issues memberships (novox/hq ADR 0160), which it publishes into the assignments stream
// after each push; refused by the server on 2026-10-01 until this line named them.
//
// **Not what the machines say** (novox/hq to-be 45 §1): `mesh.control.>` is subscribed, never
// published — a machine's report has one writer, its node-engine, and the controller granted
// that subject would be a second (the writers table refuses it). It was granted from the first
// composition and nothing ever published under it.
pub = []string{"mesh.node.>", "mesh.assignment.>", "$JS.API.>"}
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
// and a client bound to it subscribes exactly that; the server refused it for every
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
// its own consumers' delivery subjects and no other's.
sub = []string{"mesh.control.>", "$JS.API.>", "_DELIVER." + ControllerName, "_DELIVER." + ControllerName + ".>"}
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
// build is the one today: the controller asks, and reads the answer from the seat's event
// like the catalogue does — which is why no holder needs to publish into anybody's inbox.
// A node-scoped seat's work subject carries no node (novox/hq ADR 0190): the ask goes to
// the role, and whichever machine holding it is idle takes it.
// **And what the mesh says it did** (novox/hq ADR 0134). The control plane states its own
// facts under the seat it holds, because a role's events belong to the role and keep their
// address while the holder is replaced. Named one by one rather than as a whole namespace:
// least authority, and a fact nothing states is authority nobody uses.
for _, event := range ControllerStates {
pub = append(pub, seatEventSubject(ControllerSeat, event))
}
// Every module's tools: **the control plane is the way in** (novox/hq ADR 0095). A person
// or an agent asks through it and every question passes one process where an audit
// belongs — so it, alone among principals, may call any tool by name. The first `ask` on
// the new bus was refused the publish (2026-09-28).
pub = append(pub, "mesh.mod.*.tool.>")
// **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154):
// `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The
// whole verb namespace of its own seat rather than a list: the list is the seat's protocol,
// which this package mirrors rather than reads, and a verb the seat does not declare is a
// subject nothing publishes.
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
// And says so (novox/hq ADR 0197): it answers discovery for the seat it serves.
sub = append(sub, announcing(ControllerSeat)...)
// And the verbs the self-check reads with, of any machine's holder (novox/hq to-be 45 §4).
for _, v := range VerbsTheSelfCheckAsks {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
}
// And the bus's planned step: a snapshot taken now, before the bus is replaced (ADR 0236).
for _, v := range VerbsTheBusStepAsks {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
}
// And the operator's desk, for a secret given there (ADR 0259 §10).
for _, v := range VerbsTheControllerAsksForASecret {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
}
// And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380).
for _, v := range VerbsTheControllerAsksTheOperatorChannel {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
}
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
}
// And the verbs a warrant chooses (novox/hq ADR 0259): a node seat's on any machine, its own flat.
for _, v := range VerbsTheControllerActsOnAWarrant {
if v.Seat == ControllerSeat {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
continue
}
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
}
// And asking the operator (novox/hq ADR 0259): an ask and its cancel under its own name, its warrants
// heard under its own name, the record of its asks read under its own name — as any user of the seat,
// derived the same way, from the seat its holder declares.
tp, ts := seatTrafficGrants(SeatTrafficOf(ControllerSeat, nil, p.Uses, nil))
pub = append(pub, tp...)
sub = append(sub, ts...)
// And asks who answers (novox/hq to-be 45 §4, D3): the self-check finds every seat's holder by
// the same discovery the console reads. The question only; the answers come to its own inbox.
pub = append(pub, "$SRV.INFO")
// The events it reacts to, and its ack subject on the stream they arrive from
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
// controller a subscriber to every event in the mesh, and its permission list would stop
// saying what it is for. The one wildcard is the emitter of a provider's standing (ADR
// 0224) — still two named events, from whichever module provides. The ack grant below is scoped per stream because the controller's
// consumer name is the same on both and `$JS.ACK.CONTROL.controller.>` does not cover a
// delivery from EVENTS — a consumer that cannot ack has every message redelivered for
// ever, refused by the list it already has.
sub = append(sub, ControllerFollows...)
pub = append(pub, "$JS.ACK.EVENTS."+ControllerName+".>", "$JS.ACK.EVENTS."+MergesConsumerName+".>")
// And the take-in's two durable consumers on the indexer's and the builder's buckets (novox/hq ADR 0544 §1):
// acknowledged after each key is taken in, and no other consumer of those streams.
for _, bucket := range TakeInBuckets {
pub = append(pub, "$JS.ACK.KV_"+bucket+"."+TakeInConsumer+".>")
}
// **Where an enrolment's answer goes**, and `allow_responses` does not cover it. That
// permits one reply to the reply subject of a message the user received — and a message a
// JetStream consumer delivers has had that field claimed for the consumer's own ack address
// (design 25 §2), so the address the controller actually answers is the one the request
// carried in its payload, which is not a reply subject as the server understands it.
//
// Verified against a real server before this line existed: the answer was refused with
// "Permissions Violation for Publish to _INBOX.enrol.anchor…", and every enrolment on the
// mesh would have timed out while the controller logged success.
//
// **The enrolment inbox space, not a blanket `_INBOX.>`.** Design 25 §4 refuses that, and
// this is not it: nothing but an enrolling node ever subscribes under this prefix, each
// scoped to its own token's, so the controller publishing here is the mesh answering
// enrolments and can reach nothing else.
pub = append(pub, "_INBOX."+enrolmentPrefix+".>")
// **And its own buckets** (novox/hq to-be 45 §1): the calls it served and the acts done by
// hand, which it alone writes. A put is a publish to the bucket's subject, which `$JS.API.>`
// does not cover; each bucket named, not `$KV.>`, which would let it write any module's state.
// And says what it keeps, as every module does (novox/hq ADR 0495 clause 2, issue 1790).
pub = append(pub, announce.Subject(ControllerSeat))
for _, bucket := range ControllerBuckets() {
if bucket == delivery.HaveBucket {
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
}
pub = append(pub, "$KV."+bucket+".>")
}
// **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the
// controller alone writes it, as a publish to the bucket's own subjects, which `$JS.API.>` does not cover.
for _, node := range p.FilesFor {
writes, err := DeclarationFilesWrites(node)
if err != nil {
return Permissions{}, err
}
pub = append(pub, writes)
// And each node's compositions, which it alone writes (novox/hq ADR 0539 clause 2).
composed, err := ComposedWrites(node)
if err != nil {
return Permissions{}, err
}
pub = append(pub, composed)
}
// **And what the bus says about itself, read-only** (novox/hq to-be 45 §3, S9): a durable
// consumer that gave up on a message, or one that was deleted. The server already publishes
// both in the mesh's own account; the controller says each as a condition in the mesh's words.
// Named, not `$JS.EVENT.>`: the other advisories are every API call the mesh makes.
sub = append(sub, BusAdvisories...)
// **And what a consumer gave up on, kept and delivered again** (novox/hq issue 330): the notice
// acknowledged once the message is copied, the copy kept, and an event delivered again to the one
// consumer that gave it up. An ask to a seat is not delivered again, so no seat's queue is granted.
pub = append(pub, "$JS.ACK."+DeadLetterNoticesStream+"."+ControllerName+".>", deadLetterPrefix+">",
againPrefix+">")
case KindProvisioner:
var err error
if pub, sub, err = provisionerGrants(p); err != nil {
@@ -599,6 +852,15 @@ func PermissionsFor(p Principal) (Permissions, error) {
// And may ask what answers (novox/hq ADR 0197): a question every service answers about
// itself, its replies to the asker's own inbox.
pub = append(pub, discovering()...)
// And, for an administrator (`*`, the operator), release 6's hand path (ADR 0539 clauses 2 and 10): a want written
// at the terminal, and a composition made by hand with mesh-hand-compose.
if administers(p.Invokes) {
hand, err := HandPathWrites(p.FilesFor)
if err != nil {
return Permissions{}, err
}
pub = append(pub, hand...)
}
case KindEnrolment:
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
@@ -618,13 +880,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
"enrolment: a token is issued for a node record, and that record's name is " +
"the token's id")
}
// **A node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6, ADR 0590): its user calls
// redeem in its own name, while the seat's holder serves it.
if !p.Redeems {
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: no holder of %s serves %s",
p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
}
pub = []string{EnrolmentRedeemCall(p)}
pub = []string{"mesh.control.enrol"}
sub = []string{p.inbox()}
case KindNode:
@@ -730,18 +986,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
// on it rather than at the next unrelated change (ADR 0547 §2).
sub = append(sub, VaultSecretChanged)
}
// And, while the vault serves `sign`, its own node's certificate in its own name, and the vault's public signing
// halves, read and watched (ADR 0550 §2). Read only.
// And, while the vault serves `sign`, its own node's certificate in its own name, the vault's public signing
// halves, read and watched, and the controller's lease key, read and watched, which says when the controller's key
// is no longer trusted (ADR 0550 §2). Read only.
if p.AsksTheVaultToSign {
pub = append(pub, VaultSignCall(p))
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
}
// And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other
// key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3).
if p.RotatesItsLogin {
pub = append(pub, NodeLoginsReads(p.Node)...)
pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+
CallerToken(p.Username()))
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
}
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
ep, es := engineGrants(p)
@@ -810,10 +1061,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
sub = append(sub, announcing(announced...)...)
// Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause
// 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33),
// and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause
// 10, issue 774).
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
sub = append(sub, MembershipSubject(p.Node, p.Module))
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it,
// since the assignments stream's direct get went with the stream (research 072 row 33).
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
@@ -868,8 +1119,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
"$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p),
"$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p))
// 3. Seats it holds: full participation.
// 3. Seats it holds: full participation — but the controller's own seat, whose verbs only the serving
// controller answers, on its own connection (servedOnlyByTheController).
for _, s := range p.Holds {
if servedOnlyByTheController(s) {
continue
}
if s.isNewTraffic() {
// Composed by SeatTrafficOf below, worker and all; only its tools are served here.
for _, t := range s.Serves {
@@ -963,15 +1218,18 @@ func PermissionsFor(p Principal) (Permissions, error) {
// And its own streams' events (novox/hq ADR 0363 §2).
pub = append(pub, ownStreamGrants(d.Module, d.Streams)...)
for _, s := range d.Holds {
if servedOnlyByTheController(s) {
continue
}
for _, t := range s.Serves {
sub = append(sub, seatToolSubject(s, t, p.Node))
}
}
}
// Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the
// grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants
// part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the
// one token that varies is the module, so the pattern is the machine's own assignments.
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
// that varies is the module, so the pattern is the machine's own assignments.
sub = append(sub, "mesh.assignment."+p.Node+".*")
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
// name and no other's, on core NATS like the host's.
@@ -1080,7 +1338,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
sub = append(sub, p.inbox())
}
if p.Kind == KindModule || p.Kind == KindNode {
if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
// Its own reply space, and nothing wider.
sub = append(sub, p.inbox())
@@ -1093,14 +1351,52 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
// **And every tool grant again, naming its caller** (novox/hq issue 365): a call in this principal's own
// name, and an answer to a call naming anybody. The `tool` grants above stay beside these, so callers and runtimes
// move without a gap; their retirement is hq issue 464.
// name, and an answer to a call naming anybody. The `tool` grants above stay for one release beside these,
// so callers and runtimes move without a gap; their retirement is hq issue 464.
//
// **Not the controller's, this release.** Its grants are also the installer's first user list
// (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), judged against the node-engine the mesh
// runs, so a change to them waits on a node-engine delivery. It calls and serves on the subjects that name
// no caller meanwhile — a caller-named call to its seat reaches nobody and is asked again on those at once —
// and moves with issue 464.
// The vault's calls in its own name (issue 723), kept apart so the desk's grant is not denied below.
var vaultsOwn []string
pub, sub = callerNamed(p.Username(), pub, sub)
if p.Kind == KindModule {
vaultsOwn = vaultCalls(p)
pub = append(pub, vaultsOwn...)
if p.Kind != KindController {
pub, sub = callerNamed(p.Username(), pub, sub)
if p.Kind == KindModule {
vaultsOwn = vaultCalls(p)
pub = append(pub, vaultsOwn...)
}
} else {
// **But the tools that answer a software-issued credential** (novox/hq ADR 0305 §2): those answer the
// controller alone, and know it only from the subject's last token, so it asks them in its own name —
// those tools, on any machine, and nothing else under `call`.
for _, key := range p.AsksIssued {
module, tool, ok := strings.Cut(key, ".")
if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") {
continue
}
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
}
// **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known
// from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat.
for _, v := range VerbsTheControllerAsksTheVault {
if slices.Contains(p.AsksTheVault, v.Verb) {
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
}
}
// **And mesh-config's verb** (issue 925): it takes a trusted value from the controller alone, known from the
// subject's last token.
for _, v := range VerbsTheControllerAsksTheConfig {
if slices.Contains(p.AsksTheConfig, v.Verb) {
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
}
}
// And its consumer of mesh-config's resolved values, acknowledged once a change is composed (novox/hq ADR 0545):
// where mesh-config serves, as its verb, so genesis grants nothing of mesh-config.
if len(p.AsksTheConfig) > 0 {
pub = append(pub, "$JS.ACK.KV_"+ConfigResolvedBucket+"."+ConfigFollowConsumer+".>")
}
}
sort.Strings(pub)
@@ -1110,24 +1406,18 @@ func PermissionsFor(p Principal) (Permissions, error) {
if err := CheckWriters(p, pub); err != nil {
return Permissions{}, err
}
// What the vault's holder alone may publish is denied to everybody else whose grant reaches it.
// What the controller alone may publish is denied to everybody else whose grant reaches it.
var deny []string
for _, only := range DeskOnly() {
for _, a := range pub {
if slices.Contains(vaultsOwn, a) {
continue
}
if SubjectsOverlap(a, only) {
deny = append(deny, only)
break
}
}
}
// And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1).
if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) {
for _, only := range EnrolmentOnly() {
if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) {
deny = append(deny, only)
if p.Kind != KindController {
for _, only := range ControllerOnly() {
for _, a := range pub {
if slices.Contains(vaultsOwn, a) {
continue
}
if SubjectsOverlap(a, only) {
deny = append(deny, only)
break
}
}
}
}
@@ -1136,13 +1426,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
PublishDeny: deny,
Subscribe: sub,
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
// authority is bounded by having been asked. A node is asked one
// authority is bounded by having been asked — and so does the controller. A node is asked one
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its
// machine's engine, root there already, and it is delivered only its own subjects. What it answers is
// never trusted for being an answer. A
// never trusted for being an answer — the controller reads the engine's words and records nothing. A
// provisioner client is asked by its own machine's tool runner alone, on its own machine's subject (novox/hq
// ADR 0370 §3), and answers that. A person is never asked anything, and is granted nothing here.
AllowResponses: p.Kind == KindModule || p.Kind == KindNodeTools || p.Kind == KindNode ||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode ||
p.Kind == KindProvisioner,
}, nil
}
@@ -1158,7 +1448,7 @@ func vaultCalls(p Principal) []string {
}
var out []string
if p.AsksTheDesk {
for _, v := range VerbsTheVaultAsksForASecret {
for _, v := range VerbsTheControllerAsksForASecret {
out = append(out, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".*."+token)
}
}
@@ -1191,6 +1481,13 @@ func seatSubject(s Seat, kind, verb string) string {
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
// servedOnlyByTheController says a seat's verbs are answered by the serving controller alone, on its own
// connection (the KindController grant), never by a module claiming the seat or a runtime carrying it: the
// controller's own seat. Its verbs decide what the mesh is — and `root-free` decides whether the router believes
// a verified sender (novox/hq ADR 0259 §8) — so a machine's runtime, whose credential an agent on that machine
// may hold, answering one would be an agent answering it (the confirmation review of 2026-10-09).
func servedOnlyByTheController(s Seat) bool { return s.Name == ControllerSeat }
func seatToolSubject(s Seat, verb, node string) string {
base := seatSubject(s, "tool", verb)
if s.Scope == "node" && node != "" {
@@ -1264,6 +1561,8 @@ func consumerStream(p Principal) string {
return "EVENTS"
case KindNode:
return "NODES"
case KindController:
return "CONTROL"
}
return ""
}
@@ -1274,6 +1573,8 @@ func consumerDurable(p Principal) string {
return p.Node + "_" + p.Module
case KindNode:
return p.Node
case KindController:
return "controller"
}
return ""
}
@@ -1301,7 +1602,7 @@ func Compose(s Server, principals []Principal) (string, error) {
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() })
var b strings.Builder
b.WriteString("# Composed by the mesh. Do not edit: the next composition overwrites it.\n")
b.WriteString("# Composed by the mesh controller. Do not edit: the next composition overwrites it.\n")
b.WriteString("# Accounts and permissions are derived from what each module declares and nothing\n")
b.WriteString("# else (novox/hq ADR 0043, design 29 §2).\n\n")
@@ -1355,7 +1656,7 @@ func ComposeAccounts(principals []Principal) (string, error) {
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Username() < sorted[j].Username() })
var b strings.Builder
b.WriteString("# The mesh's users, composed by the grants part. Do not edit: the next\n")
b.WriteString("# The mesh's users, composed by the controller. Do not edit: the next\n")
b.WriteString("# composition overwrites it. Permissions are derived from what each module\n")
b.WriteString("# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\n")
@@ -1570,9 +1871,15 @@ func discovering() []string {
}
// WitnessSubjects are the subjects a machine's node-engine publishes to witness the core builds it
// places (novox/hq ADR 0236): its own node tools' PING. The answers come to the machine's own inbox.
// places (novox/hq ADR 0236, lease/witness.go): its own node tools' PING, and the lease's key where it
// runs the controller. Reads only: a write to the bucket is `$KV.<bucket>.>`, the controller's alone
// (the writers table). The answers come to the machine's own inbox.
func WitnessSubjects(p Principal) []string {
return append([]string{pingSubject(p.Node)}, CheckSubjects(p)...)
out := []string{pingSubject(p.Node)}
if p.WitnessesController {
out = append(out, leaseReadSubject(LeaseBucket))
}
return append(out, CheckSubjects(p)...)
}
// CheckSubjects are the tools a machine's node-engine asks as declared health checks (novox/hq ADR 0240,
@@ -1596,10 +1903,18 @@ func CheckSubjects(p Principal) []string {
return out
}
// pingSubject is a node-engine's witness's ping of its own node's tools (novox/hq ADR 0236).
// pingSubject and leaseReadSubject are the controller's lease witness's subjects (mesh-controller internal/lease), copied
// with the composer so the grants it composes name them without the lease's code.
func pingSubject(node string) string { return "$SRV.PING." + nodeToolsService + "." + node }
const nodeToolsService = "node-tools"
func leaseReadSubject(bucket string) string {
return "$JS.API.DIRECT.GET.KV_" + bucket + ".$KV." + bucket + "." + leaseKey
}
const (
nodeToolsService = "node-tools"
leaseKey = "holder"
)
// VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name.
func VaultOwnCall(p Principal) string {
+5 -6
View File
@@ -41,15 +41,14 @@ var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
var ProvisionerReads = []string{ConfigResolvedBucket}
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the seat mesh-placement's placement,
// where a provider's consumers are placed on their own machines, which the provider machine's client reads to fill
// ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679; ADR 0567, issue 1126: the
// controller's assignments mirror it read before is gone). It holds placement, never a secret's value; the client
// writes none of it. **The seat's plans are read whole for the same
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
// and plans, never a secret's value; the client writes none of it. **The seat's plans are read whole for the same
// reason** (2026-10-30 recovery, issue 774): the client V3 watches every machine's plans to bind a provider's
// consumers, and granted only its own machine's keys it read none. ADR 0570 clause 3 has providers act on the seat's
// provision keys instead; once the client does, plans go back to its own machine's keys.
var ProvisionerReadsWhole = []string{delivery.PlacementBucket, provisions.PlansBucket}
var ProvisionerReadsWhole = []string{AssignmentsBucket, provisions.PlansBucket}
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
+1 -1
View File
@@ -16,7 +16,7 @@ type Raiser interface {
Ensurer
}
// Raise asserts the mesh's streams, its own consumers, and one consumer per node.
// Raise asserts the mesh's streams, the controller's own consumers, and one consumer per node.
//
// **The order is not a preference.** A consumer on a stream that does not exist is refused, and the
// refusal names the stream rather than the order — so somebody reading it goes looking for a deleted
+3
View File
@@ -63,6 +63,9 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
// no way to, so the self-check's `checked` was never said. Listed here, the membership names them for the
// runtime and the runtime is granted them. A machine's event on a node seat carries the machine, which
// only the holder's own account is composed with, so it is left to that rule.
if servedOnlyByTheController(s) {
continue
}
for _, e := range s.Emits {
if s.Scope == "node" && perMachineEvents[e] {
continue
+3 -3
View File
@@ -309,9 +309,9 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err
return nil, fmt.Errorf("listing the bus's state: %w", err)
}
for _, n := range names {
// A seat's cancelled set is the mesh's own (novox/hq ADR 0219), not a module's state. The ended controller's
// buckets are undeclared like any other until they are removed (ADR 0590 clause 6).
if !declared[n] && !IsCancelledSet(n) {
// A seat's cancelled set is the mesh's own (novox/hq ADR 0219), not a module's state; so are
// the controller's own buckets (novox/hq to-be 45 §1).
if !declared[n] && !IsCancelledSet(n) && !IsControllerBucket(n) {
undeclared = append(undeclared, n)
}
}
+266 -13
View File
@@ -144,14 +144,26 @@ func OriginalOfAgain(subject string) (string, bool) {
// MeshStreams is the foundation set, in the order a person reads it.
//
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
// under that prefix and must not be persisted: a lost heartbeat is the next heartbeat, and a
// stream of them is a stream of the least valuable messages the mesh sends, competing for the
// same retention as the ones that matter.
//
// **EVENTS filters on the `event` token**, which is the reason that token exists. A module's
// namespace carries both its events and its tool calls; a filter of `mesh.mod.*.>` would persist
// every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps
// tools on core NATS, where a lost call is a timeout the caller already handles).
func MeshStreams() []Stream {
return []Stream{
// CONTROL, the ended controller's work queue of what nodes reported, is no longer the mesh's (novox/hq ADR 0590
// clause 6): not declared here, which would make it again; the bus module's tools remove it.
{
Name: "CONTROL",
// A build's outcome is no longer here: it is the build-machine seat's own event, so one
// publish reaches whoever asked, the controller and the catalogue (novox/hq ADR 0121).
Subjects: []string{"mesh.control.*.report", "mesh.control.enrol"},
Retention: RetentionWorkQueue,
Why: "the store-window guarantee (ADR 0083): the controller naks with a delay while its " +
"store is away and the message is redelivered; nothing is dropped",
},
{
Name: "NODES",
Subjects: []string{"mesh.node.*.declare"},
@@ -223,6 +235,18 @@ const ProvAppliedStream = "PROV_APPLIED"
// all (ProvisionerAppliedSubject).
const ProvAppliedSubjects = "prov.applied.>"
// eventsTheControllerFollows are the events the controller's events consumer takes: all it follows but the forge's
// merges, which the merges consumer takes (MergesConsumerName).
func eventsTheControllerFollows() []string {
var out []string
for _, subject := range ControllerFollows {
if subject != ControllerFollows[3] {
out = append(out, subject)
}
}
return append(out, ControllerHears...)
}
// DeliverSubjectFor is where a push consumer's messages land.
//
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
@@ -297,17 +321,147 @@ func Overlaps() []string {
return clashes
}
// The ended controller's consumers' names (novox/hq ADR 0590 clause 6): kept only so its leftovers are named for their
// removal (ControllerLeftoverConsumers) and the watchman can read whether `controller` on DEAD_LETTER_NOTICES still
// stands.
const (
ControllerName = "controller"
MergesConsumerName = "controller-merges"
)
// The mesh's own consumers.
//
// A seat's streams and a module's consumers are derived from declarations (derived.go). These two
// are not: **the controller is not a module and files no manifest**, so its authority and its
// subscriptions cannot come from a declaration that does not exist. They are named here, where the
// mesh's own streams are named, and narrowly — a controller subscribing `mesh.mod.*.event.>` would
// hear every event in the mesh, which it has no business doing and which would make its permission
// list stop explaining anything.
// ControllerName is the controller's durable consumer on each stream it reads, and the name its
// ack subject is derived from (nats.go: `$JS.ACK.<stream>.controller.>`).
const ControllerName = "controller"
// MergesConsumerName is the controller's durable consumer of the forge's merged event on EVENTS, apart from the
// events consumer (novox/hq ADR 0407 decision 3, issue 973). A merge is never dropped, so this consumer has no
// max-deliver, which would send a merge held five times to DEAD_LETTERS to wait for a person, and no healer
// resets it, which would drop the merges it holds. A consumer of its own because both of those are properties of
// a consumer, not of a subject.
const MergesConsumerName = "controller-merges"
// ControllerSeat is the role the control plane holds, and ControllerStates are the facts it states
// under it (novox/hq ADR 0134).
//
// **Written here as well as in `link`, and a test keeps them agreeing.** `link` imports `broker`, so
// `broker` cannot import `link`; a grant naming a subject the controller never publishes is authority
// nobody uses, and a controller publishing one the grant omits is refused at the moment it has
// something to say.
const ControllerSeat = "mesh-controller"
var ControllerStates = []string{"applied", "refused", "built-before",
// What is wrong, said as it changes (novox/hq to-be 45 §2): a condition raised, changed in
// severity, resolver or silence, and cleared. The operator-channel's holder and any other surface
// consume them; the controller tells nobody itself.
"condition-raised", "condition-changed", "condition-cleared",
// And the self-check's heartbeat, at the end of every run (to-be 45 §4, S10): watched from a
// machine that is not the control node, so the controller going quiet is itself said.
"doctor-heartbeat",
// And a value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
"secret-replaced",
// And every act a healer takes on a condition (novox/hq to-be 45 §7, Phase 3): a repair the mesh
// made by itself is said like one a person made, never quietly.
"healer-acted",
// And a build put back after its gate failed on its first machine (novox/hq ADR 0236, to-be 45 §8).
"rolled-back",
// And a pull request's merge check, judged (novox/hq to-be 45 §9): what the forge's holder sets as
// the pull request's status, and anybody else may read.
"checked",
// (A walk kept in a new state and a tier sent to a machine are gone with the plans, novox/hq ADR 0539.)
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and
// each registered module's declared config (design 57 F3).
"assignment-changed", "declared-config-changed", "predefined-changed",
// And a builder's key not taken in because the artifact store does not hold what it names (novox/hq issue 1937).
"artifacts-gone",
// And a bucket of the plans' state deleted at the controller's first start, and the declarations' stream once no node
// reads one (novox/hq ADR 0539 clauses 11 and 13).
"bucket-deleted"}
// BusAdvisories are what the bus server says about the mesh's own account that the controller
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
// may and gave up on it, and one that was deleted. Read-only: an advisory is the server's to
// publish, and the controller's subscription changes nothing on the bus.
var BusAdvisories = []string{
"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>",
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
}
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's
// current version moved, and a catalogue that has just started saying it may have missed builds.
//
// **Derived the same way a module's subscription is**, from the emitter and the bare local event
// name, rather than written out. They were written out while the catalogue still spelled its events
// as the old bus's routing keys, and the moment those were converted (novox/hq 04-ISSUES/127) a
// hard-coded pair became a controller listening to a subject nothing publishes — the same fault, from
// the other side. Deriving them means the conversion could not leave these behind.
var ControllerFollows = []string{
moduleEventSubject("mesh-catalog", "upgraded"),
moduleEventSubject("mesh-catalog", "catching-up"),
// A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
// catalogue.
seatEventSubject("node-build-agent", "built"),
// The forge's merges: what moved a source, so the mesh builds what that source produces
// without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name.
moduleEventSubject("gitea", "pull.merged"),
// The retired build role's outcome too, while the handover runs (novox/hq ADR 0190): the one
// build machine keeps answering on its seat until build-agent replaces it, and the outcome that
// registers build-agent itself comes from there. Appended, for the same reason as above; goes
// with the retired seat row.
seatEventSubject("mesh-build-machine", "built"),
// **Every provider's standing** (novox/hq ADR 0224): a consumer it has failed for minutes, and
// that consumer recovered. The one pattern on this list, and a narrow one — two named events,
// from whichever module provides — because the rule is about every provider, and a list of
// providers here would be a list somebody forgets to extend. On 2026-10-05 the identity provider
// failed every consumer for a day and only its journal said so (issue 179). Appended, because
// the index is a name.
moduleEventSubject("*", ProvisionerFailing),
moduleEventSubject("*", ProvisionerRecovered),
// **What becomes of a consumer the mesh stopped asking for** (novox/hq ADR 0230): retired, waiting
// for a person, re-enabled, deleted — a provider's third word, from whichever module provides.
// Appended, because the index is a name.
moduleEventSubject("*", ProvisionerRetirement),
// **A pull request's head, announced** (novox/hq to-be 45 §9): what the controller asks the build
// seat to check before it merges — every machine of the facts snapshot composed with the change.
// Appended, because the index is a name.
moduleEventSubject("gitea", "pull.updated"),
// **The operator's answers to what the controller asked** (novox/hq ADR 0259): the router's warrant, or
// the end of an ask without one, said to the controller alone under its own name. On the stream, so a
// controller that was away hears what was decided meanwhile. Appended, because the index is a name.
DecidedSubject,
}
// ControllerHears are events the controller's events consumer takes beside the ones it follows, and that need no
// subscribe grant of their own. **A push consumer delivers onto its deliver subject** (`_DELIVER.controller.>`, already
// granted), so the subject a consumer filters on is never one the controller subscribes: listing this one in
// ControllerFollows would add a subscribe grant the installer's carried user list, which lives in mesh-host, would
// have to carry too (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), for nothing the consumer uses.
//
// A merge mesh-delivery made, announced (novox/hq ADR 0420 §7, issue 1025): with the ask whose Approve it merged on and
// the heads that Approve covered, kept a week to read the warrant a moved module's registration needs.
var ControllerHears = []string{DeliveryMergedSubject}
// DeliveryMergedEvent is the event a module says when it merged a pull request.
const DeliveryMergedEvent = "merged"
// DeliveryMergedSubject is where the delivery's owner says it merged a pull request: one wildcard subject, the
// module that claims the delivery seat being whichever the catalogue says (novox/hq ADR 0437 §3). Who said it is
// read from the subject, and whether it claims the seat is the controller's to check.
var DeliveryMergedSubject = moduleEventSubject("*", DeliveryMergedEvent)
// DeliveryMergedSubjectOf is where one module says it merged a pull request.
func DeliveryMergedSubjectOf(module string) string {
return moduleEventSubject(module, DeliveryMergedEvent)
}
// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's.
const AsksSeat = "operator-channel"
// DecidedSubject is where the router says the controller's warrants: the seat's event named by the
// controller as its caller.
var DecidedSubject = seatEventSubject(AsksSeat, "decided."+ControllerSeat)
// The provider standing events, by their local names. Written here as well as in the catalogue
// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing.
const (
@@ -330,9 +484,108 @@ func seatEventSubject(seat, verb string) string {
// EventsStream holds every module's and every role's events, a build's log among them.
const EventsStream = "EVENTS"
// MeshConsumers are the mesh's own durable consumers, beside those derived from declarations: none since the controller
// ended (novox/hq ADR 0590), whose consumers they were.
func MeshConsumers() []Consumer { return nil }
// MeshConsumers is what the controller consumes, in the order a person reads it.
//
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
// not the server's (window.go): a message is held with a nak-and-delay until the controller either
// takes it or gives up and says so. A max-deliver here would give up on a push that was being
// held through a store restart — the exact message the stream exists to protect — some minutes
// before the controller had finished deciding about it.
func MeshConsumers() []Consumer {
return ControllerConsumers()
}
// ControllerConsumers are the consumers the controller's own event loop takes from: MeshConsumers without the build
// seat's check workers, which the builders take from and the loop never does (issue 1063). A consumer added to the
// controller is counted here without a change elsewhere.
func ControllerConsumers() []Consumer {
return []Consumer{
{
Name: ControllerName,
Stream: "CONTROL",
Push: true,
AckWaitSeconds: 30,
Why: "the controller is the single consumer of what nodes say; explicit ack and no " +
"max-deliver, because the store window's bound is the controller's own",
},
// The forge's merges (novox/hq ADR 0407 decision 3, issue 973): never dropped, so no max-deliver and no
// reset; a merge the controller cannot record yet is held with a growing delay until it can. **Asserted before
// the events consumer**, which stops filtering merges: the order of this list is the order of assertion.
{
Name: MergesConsumerName,
Stream: "EVENTS",
// Within what the controller may subscribe already (`_DELIVER.controller.>`).
DeliverSubject: "_DELIVER." + ControllerName + ".merges",
Filters: []string{ControllerFollows[3]},
Push: true,
AckWaitSeconds: 30,
MaxAckPending: 1,
FromNow: true,
TakesOverFrom: ControllerName,
Why: "the forge's merges, one at a time; no max-deliver and never reset, because a merge is never " +
"dropped and no person releases one",
},
{
Name: ControllerName,
Stream: "EVENTS",
Filters: append(eventsTheControllerFollows(), AgainFilter(ControllerName)),
Push: true,
AckWaitSeconds: 30,
MaxDeliver: 5,
// One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
// announcement handed over behind it must wait on the server, not time out on the
// client and come back to be acted on again.
MaxAckPending: 1,
FromNow: true,
// **The one consumer the mesh resets by itself** (healer H4, issue 248): it fell a week
// behind once and held every merge after it. What a reset drops is caught up elsewhere — a
// build's outcome from the build records a plan settles from (issue 214), a provider's failing
// word by the provider saying it again every quarter of an hour (ADR 0224). **Not the merges**:
// they are the merges consumer's below, which a reset leaves alone (ADR 0407 decision 3).
Resettable: "what it drops is caught up: build outcomes from the build records (issue 214), a " +
"provider's failing word said again (ADR 0224); the forge's merges are not its, they have a " +
"consumer of their own that no reset touches",
Why: "the events the mesh's own controller reacts to, one at a time; after " +
"max-deliver it gives the event up, and the controller keeps it in DEAD_LETTERS until " +
"a person delivers it again or drops it",
},
// What the server said a consumer gave up on (novox/hq issue 330): copied into DEAD_LETTERS and
// acknowledged. No max-deliver: a notice the controller could not copy is offered again, and said.
{
Name: ControllerName,
Stream: DeadLetterNoticesStream,
Push: true,
AckWaitSeconds: 30,
Why: "the controller copies each message a consumer gave up on into DEAD_LETTERS; no max-deliver, " +
"because a notice it gave up on would lose the message it is about",
},
}
}
// NoticesConsumer is the controller's consumer on DEAD_LETTER_NOTICES (novox/hq issue 330).
func NoticesConsumer() Consumer {
for _, c := range MeshConsumers() {
if c.Stream == DeadLetterNoticesStream {
return c
}
}
panic("the mesh's consumers carry none on " + DeadLetterNoticesStream)
}
// AssertServingConsumers are the controller's own consumers its serving cannot go without: all but the
// one on DEAD_LETTER_NOTICES, which the keeper of dead letters asserts and retries by itself, so a fault
// there never stops the controller serving (novox/hq issue 330).
func AssertServingConsumers(e Ensurer) error {
for _, c := range MeshConsumers() {
if c.Stream == DeadLetterNoticesStream {
continue
}
if err := e.EnsureConsumer(c); err != nil {
return fmt.Errorf("asserting consumer %s on %s: %w", c.Name, c.Stream, err)
}
}
return nil
}
// Ensurer is the part of a JetStream connection consumer assertion needs, narrow for the reason
// Asserter is.
@@ -340,7 +593,7 @@ type Ensurer interface {
EnsureConsumer(c Consumer) error
}
// AssertMeshConsumers brings the mesh's own consumers into being, and says which one failed.
// AssertMeshConsumers brings the controller's own consumers into being, and says which one failed.
//
// After the streams, necessarily: a consumer on a stream that does not exist is refused, and the
// refusal names the stream rather than the order.
+18 -13
View File
@@ -1,4 +1,4 @@
# Composed by the mesh. Do not edit: the next composition overwrites it.
# Composed by the mesh controller. Do not edit: the next composition overwrites it.
# Accounts and permissions are derived from what each module declares and nothing
# else (novox/hq ADR 0043, design 29 §2).
@@ -15,7 +15,7 @@ jetstream {
store_dir: "/data"
}
# The mesh's users, composed by the grants part. Do not edit: the next
# The mesh's users, composed by the controller. Do not edit: the next
# composition overwrites it. Permissions are derived from what each module
# declares and nothing else (novox/hq ADR 0043, design 29 §2).
@@ -28,18 +28,23 @@ accounts {
{ service: { account: "$SYS", subject: "$SYS.REQ.ACCOUNT.MESH.CONNZ" }, to: "mesh.bus.connz" }
]
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.>", "$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_composed.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.bucket-deleted", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
publish: { allow: ["mesh.seat.mesh-enrolment.call.redeem.enrol~one"] }
publish: { allow: ["mesh.control.enrol"] }
subscribe: { allow: ["_INBOX.enrol.one.>"] }
} }
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.one", "$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.one", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_placement.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_data.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_health.>", "$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_placement.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.>", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.one.>", "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.one", "$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.one", "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_placement", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests", "$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules", "$JS.API.STREAM.INFO.KV_mesh-host_acts", "$JS.API.STREAM.INFO.KV_mesh-host_data", "$JS.API.STREAM.INFO.KV_mesh-host_health", "$JS.API.STREAM.INFO.KV_mesh-host_packages", "$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-delivery-assigner_candidates.>", "$JS.FC.KV_mesh-delivery-assigner_placement.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_mesh-delivery-builder_builds.>", "$JS.FC.KV_mesh-delivery-indexer_manifests.>", "$JS.FC.KV_mesh-delivery-indexer_modules.>", "$JS.FC.KV_mesh-host_acts.>", "$JS.FC.KV_mesh-host_data.>", "$JS.FC.KV_mesh-host_health.>", "$JS.FC.KV_mesh-host_packages.>", "$JS.FC.KV_mesh-provisioner_provisions.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.mesh-host_acts.one.>", "$KV.mesh-host_data.one.>", "$KV.mesh-host_health.one", "$KV.mesh-host_packages.one", "$KV.node-engine_have.one.>", "$SRV.INFO", "$SRV.INFO.>", "$SRV.PING", "$SRV.PING.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one", "mesh.seat.node-engine.event.*.one"] }
subscribe: { allow: ["$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement", "$SRV.INFO", "$SRV.INFO.node-engine", "$SRV.INFO.node-engine.>", "$SRV.PING", "$SRV.PING.node-engine", "$SRV.PING.node-engine.>", "$SRV.STATS", "$SRV.STATS.node-engine", "$SRV.STATS.node-engine.>", "_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare", "mesh.seat.node-engine.call.*.one.*", "mesh.seat.node-engine.tool.*.one"] }
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.node-engine_have.one.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one"] }
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: {
publish: { allow: ["$JS.ACK.DEAD_LETTER_NOTICES.mesh-delivery-watchman.>", "$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES.mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.mesh-delivery-watchman", "$JS.API.DIRECT.GET.PROV_APPLIED.>", "$JS.API.STREAM.INFO.DEAD_LETTERS", "$JS.API.STREAM.INFO.DEAD_LETTER_NOTICES", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.API.STREAM.INFO.PROV_APPLIED", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS", "$JS.API.STREAM.MSG.GET.PROV_APPLIED", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.bus.connz", "mesh.events.dead.>"] }
subscribe: { allow: ["$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.control.*.tools-alive", "mesh.events.dead.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>", "mesh.mod.nats.event.bus.connection-closed", "mesh.mod.nats.event.bus.slow-consumer", "mesh.mod.power.event.*", "mesh.seat.mesh-self-check.event.checked", "prov.applied.>"] }
publish: { allow: ["$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connz"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
@@ -47,18 +52,18 @@ accounts {
subscribe: { allow: ["_INBOX.one.nats.>"] }
} }
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.one.telegram", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.one.telegram", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.audit", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.audit", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.two.shop", "$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.two.shop", "$JS.API.STREAM.INFO.KV_mesh-grants_memberships", "$JS.FC.KV_mesh-grants_memberships.>", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] }
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16 -9
View File
@@ -7,6 +7,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -87,17 +88,23 @@ const ArtifactStoreTools = "artifact-store-tools"
const ConfigModule = "mesh-config"
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`: the grants part's wanted accounts
// are read by the vault alone (novox/hq ADR 0561 §2); nobody reads the healers' `would` (ADR 0564 clause 8,
// health_grants.go).
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, HealersWould: {},
// are read by the vault alone (novox/hq ADR 0561 §2).
// The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which
// reads the catalogue's placement alone from then (ADR 0549 §5, issue 774).
//
// The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the
// watchman read the lease; the keeper alone the controller's conditions; mesh-healers and mesh-hand-acts its hand-act log
// (ADR 0565 clauses 10, 11: every reader of acts reads both logs); and nobody the healers' `would` (health_grants.go).
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule,
// ADR 0571 clause 2: the artifact store's seat sweeps only once the lease names no controller.
ArtifactStoreTools,
// ADR 0561 §3: mesh-config takes over the config only once the lease is handed away (its takeover.go leaseAway).
ConfigModule},
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
delivery.EngineDataRead: {health.DiskLoadModule},
// ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its
// machine principal (NodeLoginsReads), never through `reads`.
delivery.NodeLoginsRead: {GrantsModule},
// ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part.
delivery.EnrolmentsRead: {GrantsModule}}
delivery.EngineDataRead: {health.DiskLoadModule}}
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
// that names none.
+30 -56
View File
@@ -2,13 +2,10 @@ package buscompose
import (
"fmt"
"git.novox.be/novox/mesh-sdk/go/announce"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Every user the composed file should contain, derived from what the mesh knows.
@@ -132,8 +129,7 @@ type Records struct {
Assigned map[string][]Declared
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
// answer goes to is scoped to the token and a shared one is one machine reading another's
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
// sealed credentials (design 25 §6).
Enrolling []string
// People is each person's name against the tools they may invoke, `*` for an administrator.
People map[string][]string
@@ -149,38 +145,40 @@ type Records struct {
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
// Nil where its manifest was not read.
Engine *Declared
// NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one
// rotating, each composed as a user of the node's permissions. A node absent logs in as `node.<n>` alone.
NodeLogins map[string]delivery.NodeLogin
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
Announced map[string]announce.States
}
// Users is every user the composed file should contain, in the order it will be written.
//
// The controller is always first and always present: a mesh whose own controller is not in the file
// is a mesh that cannot be told anything, and there is no state of the records in which that is
// correct.
func Users(r Records) ([]Principal, error) {
var out []Principal
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
served := servedReadsOf(r)
logs := DeclaredLogs(r)
grants := -1
for _, node := range sortedCopy(r.Nodes) {
witness := false
var checks []string
bus := false
for _, d := range r.Assigned[node] {
if d.Module == controllerModule {
witness = true
}
if d.SnapshotsTheBus {
bus = true
}
checks = append(checks, d.Checks...)
}
// **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too,
// each with the node's permissions.
for _, login := range nodeLoginsOf(r, node) {
out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)})
}
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
// per machine that runs it, delivered to the module running it.
for _, d := range r.Assigned[node] {
@@ -220,10 +218,6 @@ func Users(r Records) ([]Principal, error) {
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
Invokes: pr.Invokes, Removes: pr.Removes,
})
// The ended controller's leftovers include each node's declaration files' bucket (ADR 0590 clause 6).
if slices.Contains(pr.Removes, catalogue.RemovesControllerLeftovers) {
out[len(out)-1].FilesFor = sortedCopy(r.Nodes)
}
}
if runtimeHere && d.Module == RuntimeModule {
continue
@@ -274,22 +268,16 @@ func Users(r Records) ([]Principal, error) {
out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried})
}
}
// A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an
// enrolling node has nowhere to enrol, and gets no user.
if servesVerb(r, EnrolmentRedeem) {
for _, node := range sortedCopy(r.Enrolling) {
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: true})
}
for _, node := range sortedCopy(r.Enrolling) {
out = append(out, Principal{Kind: KindEnrolment, Node: node})
}
for _, person := range sortedNames(r.People) {
out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]})
}
// The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1).
if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) {
for i := range out {
out[i].VaultEnrols = true
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
if administers(p.Invokes) {
// The operator's hand path writes each node's compositions (ADR 0539 clause 10).
p.FilesFor = sortedCopy(r.Nodes)
}
out = append(out, p)
}
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
@@ -351,6 +339,9 @@ func sortedNames(in map[string][]string) []string {
return out
}
// controllerModule is the controller's module: the machine assigned it witnesses its upgrades.
const controllerModule = "mesh-controller"
// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller
// asks the operator through it like any other user, and is granted what its declaration names for a caller.
// None while nothing holds it.
@@ -388,27 +379,6 @@ func asksSeatOf(r Records) []Seat {
return nil
}
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
// node-logins key says is in use and the one rotating, in that order; `node.<n>` alone when the key is absent or does
// not hold (a key naming another node's login is no login of this node's).
func nodeLoginsOf(r Records, node string) []string {
l, ok := r.NodeLogins[node]
if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) {
return []string{""}
}
login := func(name string) string {
if name == delivery.FirstLogin(node) {
return ""
}
return name
}
out := []string{login(l.Login)}
if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) {
out = append(out, login(l.Rotating))
}
return out
}
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
const VaultSeat = "mesh-vault"
@@ -421,6 +391,10 @@ func holdsSeat(d Declared, seat string) bool {
return false
}
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
// is granted to ask it in its own name. None while no holder serves them.
func vaultVerbsOf(r Records) []string { return servedOf(r, VerbsTheControllerAsksTheVault) }
// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name.
func servedOf(r Records, asked []SeatVerb) []string {
seen := map[string]bool{}
+123 -54
View File
@@ -25,7 +25,7 @@ type WriterRow struct {
KeptIn string
Others string
// Subjects are the bus subjects a write of this state is a publish to; none for state kept off the
// bus (a module's own store) or not built yet.
// bus (the controller's store, a module's own) or not built yet.
Subjects []string
// Writes says a principal granted a publish pattern overlapping Subjects is this state's writer —
// given the pattern, because a machine writes its own report and no other's.
@@ -34,13 +34,16 @@ type WriterRow struct {
Shared string
}
// createsDeclared, and the other writers' tests, are who a row's writer is as a principal.
//
// createsDeclared is the grants part creating one stream a manifest declares, by its own name: the create alone, never an
// update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); or the bus module's tools deleting one of
// the ended controller's leftover streams (ADR 0590 clause 6).
// isController, and the other writers' tests, are who a row's writer is as a principal.
func isController(p Principal, _ string) bool { return p.Kind == KindController }
// createsDeclared is the controller, or the grants part creating one stream a manifest declares, by its own name: the
// create alone, never an update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54).
func createsDeclared(p Principal, pattern string) bool {
if removesLeftover(p, pattern) {
if isController(p, pattern) {
return true
}
if removesAssignmentsStream(p, pattern) {
return true
}
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
@@ -93,27 +96,17 @@ func ownModule(p Principal, pattern string) bool {
return false
}
// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it.
func moduleNamed(module string) func(Principal, string) bool {
return func(p Principal, _ string) bool {
switch p.Kind {
case KindModule, KindProcess:
return p.Module == module
case KindNodeTools:
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
}
return false
}
}
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps (novox/hq ADR
// 0495 clause 2): each under its own name alone.
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
func ownAnnouncer(p Principal, pattern string) bool {
tokens := strings.Split(pattern, ".")
if len(tokens) < 3 {
return false
}
if p.Kind == KindProcess {
switch p.Kind {
case KindController:
return tokens[2] == ControllerSeat
case KindProcess:
return p.Module == tokens[2]
}
return ownModule(p, pattern)
@@ -138,61 +131,121 @@ func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
// WritersTable is to-be 45 §1, in its order.
var WritersTable = []WriterRow{
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController},
// The operator's hand-over of a directory used as found, asked of the machine's engine at the controller's
// terminal (novox/hq issue 356). One publisher; and because a responder can still reach the subject through a
// reply, the ask is signed with the mesh's key and the engine verifies it — the row bounds who is granted the
// publish, the signature who is believed.
{State: "a hand-over asked of a machine", Writer: "controller, at its terminal", KeptIn: "the machine, beside its state",
Others: "the engine verifies the mesh's signature and records it, or refuses",
Subjects: []string{"mesh.node.*.ask.hand-over"}, Writes: isController},
// The operator asking a machine's engine for a fresh search for setuid programs, at the controller's
// terminal (novox/hq issue 361): signed as a hand-over is, under a signing context of its own.
{State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal",
KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses",
Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController},
// A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a
// hand-over is, under a signing context of its own. It changes nothing on the machine.
{State: "a judgement of a machine's directories asked of it", Writer: "controller",
KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses",
Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController},
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
// machine, inside the grant it already had (`mesh.control.<its own>.>`). And a line mesh-cli was given
// on the machine (novox/hq ADR 0272): that subject is this machine's engine's alone.
// on the machine (novox/hq ADR 0272): the node is what the controller judges the terminal by, so that
// subject is this machine's engine's alone.
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
{State: "stream definitions and bus permissions", Writer: "the grants part", KeptIn: "the bus", Others: "—",
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
// The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder.
{State: "the merges mesh-delivery announced", Writer: "controller (lease holder)",
KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration",
Subjects: kvOf(DeliveryMergesBucket), Writes: isController},
// And the compositions (novox/hq ADR 0539 clauses 2 and 4): each node's in its own bucket, which only that node's
// node-engine reads, and each digest with the judgement of its move, which the assigner reads.
{State: "the compositions and their digests", Writer: "controller (lease holder), compare-and-set",
KeptIn: "key-value composed-<node> for each node, and " + delivery.ControllerComposedBucket,
Others: "a node-engine reads its own node's; the assigner reads the digests",
Subjects: kvOf(delivery.ControllerComposedBucket), Writes: isControllerOrTheHandPath},
{State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " +
ConditionHistoryBucket + "); how often each was observed, when last and its newest evidence in the " +
"controller's store, so a look that changes nothing writes no bucket", Others: "raise or clear only through observations the controller reads",
Subjects: append(kvOf(ConditionsBucket), kvOf(ConditionHistoryBucket)...), Writes: isController},
{State: "calls and their outcomes", Writer: "controller", KeptIn: "key-value " + CallsBucket,
Others: "read by id", Subjects: kvOf(CallsBucket), Writes: isController},
{State: "the hand-act log", Writer: "controller, through the verbs that act", KeptIn: "key-value " + HandActsBucket,
Others: "—", Subjects: kvOf(HandActsBucket), Writes: isController},
// What the healers did (novox/hq to-be 45 §7, Phase 3): the controller's alone, in its store — the
// budgets and the mesh-wide brake are counted from it, so a controller restarting cannot reset them.
{State: "the healers' acts and their brake", Writer: "controller (lease holder), each act begun before it is made",
KeptIn: "the controller's store", Others: "read through healers; each act said as healer-acted and in its condition's tried"},
{State: "stream definitions and bus permissions", Writer: "controller", KeptIn: "the bus", Others: "—",
// A stream's definition, and a durable consumer's by the API that names it so. Not every
// consumer create: a module watching its own bucket makes and deletes an ordered consumer on the
// bucket's stream (ADR 0201), which defines nothing the mesh keeps.
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
Writes: createsDeclared,
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the ended controller's leftover streams, each by its name, and no other (novox/hq ADR 0590 clause 6)"},
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"},
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
Others: "the builder asks",
Others: "the controller asks",
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
Writes: holdsSeatOf,
Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"},
// What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module.
// What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module, kept by
// the controller.
{State: "a module's states announced", Writer: "the module itself, from its code, under its own name",
KeptIn: "the bus (EVENTS)", Others: "the grants part grants readers from it",
KeptIn: "the bus (EVENTS)", Others: "the controller keeps the last of each and grants readers from it",
Subjects: []string{"mesh.mod.*.event.states"}, Writes: ownAnnouncer},
{State: "the states announced", Writer: "controller, from each module's announcement",
KeptIn: "key-value " + StatesAnnouncedBucket, Others: "the composition of the bus's users reads it",
Subjects: kvOf(StatesAnnouncedBucket), Writes: isController},
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
// A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer.
{State: "a pull request's head announced", Writer: "the forge's announcer, the one that announces its merges",
KeptIn: "the bus", Others: "the checker checks it", Subjects: []string{"mesh.mod.*.event.pull.updated"},
KeptIn: "the bus", Others: "the controller asks the build seat to check it", Subjects: []string{"mesh.mod.*.event.pull.updated"},
Writes: ownModule},
{State: "a pull request's merge check", Writer: "the checker that ran it",
{State: "a pull request's merge check", Writer: "the build seat's holder that ran it, said as the controller's `checked`",
KeptIn: "the bus", Others: "the forge's holder sets it as the pull request's status"},
{State: "a provider's standing", Writer: "the provider", KeptIn: "the provider's events",
Others: "the watchman keeps the newest word as a condition",
Others: "the controller keeps the newest word as a condition",
Subjects: []string{"mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered",
"mesh.mod.*.event.provisioner.retirement"},
Writes: ownModule},
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
Others: "—"},
// The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a
// compare-and-set at the revision read; the grants part reads them as a named reader.
{State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set",
KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry",
Others: "the grants part reads them (a named reader) and composes each enrol.<node> user from them",
Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)},
// Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it.
{State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket,
Others: "the grants part reads every key, each node's engine its own node's key alone",
Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)},
// What a consumer gave up on (novox/hq issue 330): copied by the watchman from the server's notice (ADR 0564
// clause 5, ADR 0590 clause 2).
{State: "a message a consumer gave up on", Writer: "the watchman", KeptIn: "the bus, the stream " + DeadLettersStream,
Others: "read through the watchman's dead-letters; delivered again by nobody until the hand-act log's home is delivered",
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters},
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
Others: "the build seat reads"},
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
// and delivered again or dropped only through its verb, with why.
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
Others: "read, delivered again or dropped through the controller's dead-letters verb",
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters,
Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"},
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
// anything else write them is refused at composition. Built so far: the assignments and each registered
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
{State: "the assignments mirror", Writer: "controller (lease holder): the verbs that assign, and the sends that record what runs; " +
"a mirror of its store only until the assignments are moved to the bus (novox/hq ADR 0408)",
KeptIn: "key-value " + AssignmentsBucket + ", the record once moved", Others: "read through `reads` as mesh-controller." + MirrorAssignments,
Subjects: kvOf(AssignmentsBucket), Writes: isController},
// The chain (novox/hq ADR 0408): builds and rollouts beside the assignments above, which keep their bucket. Not
// mirrors of the store but the record, written by the lease holder by compare-and-set and removed by nobody.
{State: "the builds of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision",
KeptIn: "key-value " + BuildsBucket + ", sixty-four revisions of each key, none removed",
Others: "read through `reads` as mesh-controller." + ChainBuilds, Subjects: kvOf(BuildsBucket), Writes: isController},
{State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision",
KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed",
Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController},
// The grants issued (novox/hq ADR 0528 clause 2, issue 1394): the controller's, read by the assigner for its sixth rule.
{State: "the grants issued", Writer: "controller (lease holder), once the bus's user list carrying a module's manifest at a fingerprint was sent",
KeptIn: "key-value " + GrantsBucket + ", one key a module's fingerprint, none removed",
Others: "read through `reads` as mesh-controller." + ControllerGrants, Subjects: kvOf(GrantsBucket), Writes: isController},
// What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under
// its own node's keys; the view and the watchman read it.
// its own node's keys, never by the controller; the view and the watchman read it.
{State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `<node>.>`",
KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key",
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
@@ -217,23 +270,33 @@ var WritersTable = []WriterRow{
return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern)
},
Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"},
// The object buckets (novox/hq ADR 0486 §2, ADR 0547 clause 7): the declaration files' buckets the ended controller
// wrote are written by nobody (ADR 0590); the grants part writes its own.
{State: "an object bucket", Writer: "the grants part, its own bucket alone",
// The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each
// object under the digest of its content; a node-engine reads one by its name, with no list and no write.
{State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +
"operator's account on release 6's hand path alone (ADR 0539 clause 10)",
KeptIn: "the object bucket " + DeclarationFilesBucket + "-<node> of each node, an object removed a day after the " +
"node's latest declaration no longer names it",
Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it",
Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool {
return grantsOwnObjects(p, pattern)
return isControllerOrTheHandPath(p, pattern) || grantsOwnObjects(p, pattern)
},
Shared: "nobody else: the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"},
Shared: "the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"},
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
{State: "the predefined-keys mirror", Writer: "controller (lease holder), from its store, only on a change",
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
Subjects: kvOf(PredefinedBucket), Writes: isController},
{State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change",
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages,
Subjects: kvOf(DeclaredPackagesBucket), Writes: isController},
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
// in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket.
{State: "a module's files placed on a machine", Writer: "that machine's provisioner client",
KeptIn: "the bus, the stream " + ProvAppliedStream + ", newest per module; and key-value " +
ProvisionerAppliedBucket,
Others: "the watchman reads it; a failure is that machine's failed apply",
Others: "the controller reads it at a walk's end and a gate; a failure is that machine's failed apply",
Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner},
}
@@ -280,3 +343,9 @@ func SubjectsOverlap(a, b string) bool {
}
}
}
// isControllerOrTheHandPath is the controller, or the operator's account (a person who may invoke every tool) writing
// release 6's hand path: the one second writer ADR 0539 clauses 2 and 10 name, for a composition made by hand.
func isControllerOrTheHandPath(p Principal, pattern string) bool {
return isController(p, pattern) || (p.Kind == KindPerson && administers(p.Invokes))
}
-108
View File
@@ -1,108 +0,0 @@
// Package calladdress reads the key a module calls a tool or a seat verb by, as a module's invokes names it, with the
// machine it is asked of after '@': `seat:<seat>.<verb>[@<node>]` or `<module>.<tool>[@<node>]` (novox/hq ADR 0159,
// ADR 0469 §3). Every caller in the SDK parses its key here before it publishes anything, so a malformed key fails at
// once and says why, never as a call nobody serves and a timeout (novox/hq issue 774, the lease write's stop asked on
// `...call.stop@novox-be...`).
package calladdress
import (
"fmt"
"strings"
)
// Address is a parsed key: a seat and its verb, or a module and its tool, and the machine named, if any.
type Address struct {
Seat, Verb string
Module, Tool string
Node string
}
// Key is the address as a module's invokes names it, without the machine.
func (a Address) Key() string {
if a.Seat != "" {
return "seat:" + a.Seat + "." + a.Verb
}
return a.Module + "." + a.Tool
}
// Parse reads a key, refusing one with more than one '@', a node that is not one subject token, or a seat, verb, module
// or tool that holds a wildcard, white space or (but a tool) a dot.
func Parse(key string) (Address, error) {
name, node, err := cutNode(key)
if err != nil {
return Address{}, err
}
if rest, ok := strings.CutPrefix(name, "seat:"); ok {
seat, verb, ok := strings.Cut(rest, ".")
if !ok || seat == "" || verb == "" {
return Address{}, fmt.Errorf("%q names no seat and verb (seat:<seat>.<verb>)", key)
}
if err := tokens(key, seat, verb); err != nil {
return Address{}, err
}
return Address{Seat: seat, Verb: verb, Node: node}, nil
}
module, tool, ok := strings.Cut(name, ".")
if !ok || module == "" || tool == "" {
return Address{}, fmt.Errorf("%q is not <module>.<tool>, and does not start seat:<seat>.<verb>", key)
}
if err := tokens(key, module); err != nil {
return Address{}, err
}
if err := checkTool(key, tool); err != nil {
return Address{}, err
}
return Address{Module: module, Tool: tool, Node: node}, nil
}
// CheckOwn is Parse for a caller that may also name a tool of its own module by its bare name (the runtime's mesh/ask).
func CheckOwn(key string) error {
name, _, err := cutNode(key)
if err != nil {
return err
}
if !strings.HasPrefix(name, "seat:") && !strings.Contains(name, ".") {
return tokens(key, name)
}
_, err = Parse(key)
return err
}
// CheckNode refuses a node name that is not one subject token.
func CheckNode(node string) error {
if node == "" || strings.ContainsAny(node, ".*>@ \t\r\n") {
return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>', '@' or space)", node)
}
return nil
}
func cutNode(key string) (name, node string, err error) {
name, node, at := strings.Cut(key, "@")
if !at {
return key, "", nil
}
if strings.Contains(node, "@") {
return "", "", fmt.Errorf("%q holds more than one '@': a key names at most one machine", key)
}
if err := CheckNode(node); err != nil {
return "", "", fmt.Errorf("%q: %w", key, err)
}
return name, node, nil
}
func tokens(key string, parts ...string) error {
for _, p := range parts {
if p == "" || strings.ContainsAny(p, ".*> \t\r\n") {
return fmt.Errorf("%q: %q is not one subject token (no '.', '*', '>' or space)", key, p)
}
}
return nil
}
// checkTool refuses a tool name with a wildcard or white space; a dot is kept, as the tool runner serves it.
func checkTool(key, tool string) error {
if strings.ContainsAny(tool, "*> \t\r\n") || strings.HasPrefix(tool, ".") || strings.HasSuffix(tool, ".") {
return fmt.Errorf("%q: %q is not one subject token (no '*', '>' or space)", key, tool)
}
return nil
}
-56
View File
@@ -1,56 +0,0 @@
package calladdress
import (
"strings"
"testing"
)
// Issue 774 notes 84-87 (the lease write's stop asked of `seat:node-service-manager.stop@novox-be` published on a subject
// nobody serves and timed out after 30 s): a key names its machine after '@', and every other malformed key is refused
// before anything is published, naming why.
func TestAKeyIsParsedOrRefusedBeforeItIsPublished(t *testing.T) {
for _, row := range []struct {
key, want string // want: "seat|<seat>|<verb>|<node>", "mod|<module>|<tool>|<node>", or a refusal's words
}{
{"seat:node-service-manager.stop@novox", "seat|node-service-manager|stop|novox"},
{"seat:operator-channel.tell", "seat|operator-channel|tell|"},
{"mesh-build-go.fetch@ace", "mod|mesh-build-go|fetch|ace"},
{"nats.nats_users", "mod|nats|nats_users|"},
{"seat:node-service-manager.stop@novox@g14", "more than one '@'"},
{"seat:node-service-manager.stop@", "not a node name"},
{"seat:node-service-manager.stop@nov.ox", "not a node name"},
{"seat:node-files.*", "not one subject token"},
{"mesh-build-go.fe tch", "not one subject token"},
{"mesh>build.fetch", "not one subject token"},
{"seat:noverb", "names no seat and verb"},
{"nodot", "is not <module>.<tool>"},
{"", "is not <module>.<tool>"},
} {
a, err := Parse(row.key)
got := ""
switch {
case err != nil:
got = err.Error()
if !strings.Contains(got, row.want) {
t.Errorf("Parse(%q) refused %q, want %q", row.key, got, row.want)
}
continue
case a.Seat != "":
got = "seat|" + a.Seat + "|" + a.Verb + "|" + a.Node
default:
got = "mod|" + a.Module + "|" + a.Tool + "|" + a.Node
}
if got != row.want {
t.Errorf("Parse(%q) = %s, want %s", row.key, got, row.want)
}
}
if a, err := Parse("seat:node-files.read@g14"); err != nil || a.Key() != "seat:node-files.read" {
t.Errorf("the key without its machine: %q, %v", a.Key(), err)
}
if err := CheckOwn("list@ace"); err != nil {
t.Errorf("a module's own tool by its bare name was refused: %v", err)
}
if err := CheckOwn("li*st"); err == nil {
t.Error("a bare name that is not one token was accepted")
}
}
+3 -2
View File
@@ -24,12 +24,13 @@ func wantsVerbs() []Verb {
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
"with policy default, the choice taken back. With compare, every module whose policy differs from the one the " +
"controller writes beside its compositions.",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll, together, record, or default to take a person's choice back",
"why": "with policy: why, required and kept",
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
"compare": "\"true\": every module whose policy at the assigner differs from the controller's",
}, nil, "compare")},
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
-143
View File
@@ -1,143 +0,0 @@
package catalogue
import (
"fmt"
"slices"
"sort"
"strings"
)
// **Nothing may name the controller** (novox/hq ADR 0590 clause 5, issue 1126): the controller ended by its unassignment
// on 2026-11-02 and its code left the mesh, so a manifest that reads one of its states (its lease too), invokes one of its
// verbs or tools, consumes or waits on one of its events, uses, claims or requires it, or is it, depends on nothing that
// answers. ManifestRefusals runs the check over a repository's manifests, in the test each repository of manifests keeps
// (mesh-catalog, mesh-delivery, mesh-tools); a later decision that places a controller again must first lift it.
// EndedControllerName is the name the ended controller had, as its module and as its seat: no manifest may name it.
const EndedControllerName = "mesh-controller"
// The kinds of a declaration that names the controller.
const (
DependsRead = "read"
DependsInvoke = "invoke"
DependsConsume = "consume"
DependsUses = "uses"
DependsWaits = "waits"
DependsClaims = "claims"
DependsRequires = "requires"
DependsIs = "is"
)
// Dependency is one declaration of a manifest that names the controller.
type Dependency struct {
Module string `json:"module"`
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
By string `json:"by,omitempty"`
Kind string `json:"kind"`
What string `json:"what"`
}
// onController says whether a name `<owner>.<rest>` is the controller's.
func onController(name string) bool { return strings.HasPrefix(name, EndedControllerName+".") }
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
// it is the console's grant to every tool.
func invokesController(invoke string) bool {
if seat, _, ok := SeatInvoke(invoke); ok {
return seat == EndedControllerName
}
return onController(invoke)
}
// Dependencies are every declaration naming the controller in the manifests given, by module.
func Dependencies(manifests map[string]Manifest) []Dependency {
var out []Dependency
add := func(module, by, kind, what string) {
if d := (Dependency{Module: module, By: by, Kind: kind, What: what}); !slices.Contains(out, d) {
out = append(out, d)
}
}
for name, m := range manifests {
if name == EndedControllerName || m.Module == EndedControllerName {
add(name, "", DependsIs, EndedControllerName)
}
for _, r := range m.Reads {
if onController(r) {
add(name, "", DependsRead, r)
}
}
for _, i := range m.Invokes {
if invokesController(i) {
add(name, "", DependsInvoke, i)
}
}
for _, c := range m.Consumes {
if onController(c) {
add(name, "", DependsConsume, c)
}
}
for _, w := range m.Waits {
if onController(w) {
add(name, "", DependsWaits, w)
}
}
for _, u := range m.Uses {
if u == EndedControllerName {
add(name, "", DependsUses, u)
}
}
for _, c := range m.Claims {
if c.Name == EndedControllerName {
add(name, "", DependsClaims, c.Name)
}
}
for _, r := range m.Requires {
if r == EndedControllerName || onController(r) {
add(name, "", DependsRequires, r)
}
}
for _, a := range m.BusAccounts() {
by := "process " + a.ID
if a.Tools {
by = "tools " + a.ID
}
for _, i := range a.Bus.Invokes {
if invokesController(i) {
add(name, by, DependsInvoke, i)
}
}
}
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Module != b.Module {
return a.Module < b.Module
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
if a.What != b.What {
return a.What < b.What
}
return a.By < b.By
})
return out
}
// ManifestRefusals is the check over a repository's manifests, by module name: one reason per declaration naming the
// controller, none when it passes. None given fails it, since nothing read can hide a declaration.
func ManifestRefusals(manifests map[string]Manifest) []string {
if len(manifests) == 0 {
return []string{"no manifest was read: with nothing read, nothing naming the controller can be judged absent"}
}
var out []string
for _, d := range Dependencies(manifests) {
who := d.Module
if d.By != "" {
who += " (" + d.By + ")"
}
out = append(out, fmt.Sprintf("%s declares %s %s: the controller ended by its unassignment and nothing may name it "+
"(ADR 0590 clause 5)", who, d.Kind, d.What))
}
return out
}
@@ -1,58 +0,0 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
// Issue 1126 B1/AC1, B2/AC1 (ADR 0590 clause 5): the seat set holds no mesh-controller, and the dependence check refuses
// every manifest that reads (the lease too), invokes, consumes, waits on, uses, claims, requires or is mesh-controller.
func TestNothingMayNameTheController(t *testing.T) {
for _, s := range DefaultSeats() {
if s.Name == EndedControllerName {
t.Errorf("the seat set still holds %s", s.Name)
}
}
if _, ok := SeatNamed(EndedControllerName); ok {
t.Errorf("SeatNamed still finds %s", EndedControllerName)
}
for _, row := range []struct {
name, manifest string
want []string // "<kind> <what>"
}{
{"a read of the lease", `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`, []string{"read mesh-controller.lease"}},
{"the controller's own manifest", `{"module":"mesh-controller","version":"1"}`, []string{"is mesh-controller"}},
{"a claim of its seat", `{"module":"a-controller","version":"1","claims":[{"name":"mesh-controller","scope":"mesh"}]}`,
[]string{"claims mesh-controller"}},
{"each declaration kind; every tool (*) is none",
`{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
`"consumes":["mesh-controller.artifacts-gone","mod-x.thing"],"uses":["mesh-controller"],"waits":["mesh-controller.checked"],` +
`"requires":["mesh-controller"]}`,
[]string{"consume mesh-controller.artifacts-gone", "invoke mesh-controller.status", "invoke seat:mesh-controller.root-free",
"requires mesh-controller", "uses mesh-controller", "waits mesh-controller.checked"}},
{"a process's own invoke", `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},` +
`{"id":"worker","type":"process","run":["./w"],"user":"mod-p","bus":{"invokes":["seat:mesh-controller.held"]}}]}`,
[]string{"invoke seat:mesh-controller.held"}},
{"a module on its own seats", `{"module":"mod-ok","version":"1","reads":["mesh-grants.accounts"],"invokes":["*"]}`, nil},
} {
m, err := Decode([]byte(row.manifest))
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
var got []string
for _, d := range Dependencies(map[string]Manifest{m.Module: m}) {
got = append(got, d.Kind+" "+d.What)
}
if !slices.Equal(got, row.want) {
t.Errorf("%s: dependencies %q, want %q", row.name, got, row.want)
}
refused := ManifestRefusals(map[string]Manifest{m.Module: m})
if len(refused) != len(row.want) || len(refused) > 0 && !strings.Contains(refused[0], "ADR 0590") {
t.Errorf("%s: refused %q", row.name, refused)
}
}
if got := ManifestRefusals(map[string]Manifest{}); len(got) != 1 || !strings.Contains(got[0], "no manifest") {
t.Errorf("no manifest given: %q", got)
}
}
-5
View File
@@ -846,11 +846,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// and nothing would say so.
continue
}
if g.Sealed == "" {
// Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential
// file would be read as one that failed. Who asked still reaches the provider in its contributions.
continue
}
first = append(first, ownedBy(r.provisionsAs(m), map[string]any{
// One file per holder — the consumer's module with its local name after it
// where it keeps several (ADR 0094); the lab found two files with one id.
-96
View File
@@ -1,96 +0,0 @@
package catalogue
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
// EnrolmentSeat is the seat mesh-enrolment.
const EnrolmentSeat = "mesh-enrolment"
// The seat's six verbs (ADR 0587 §1, §4-§8).
const (
EnrolmentVerbIssue = "issue"
EnrolmentVerbRedeem = "redeem"
EnrolmentVerbConfirm = "confirm"
EnrolmentVerbRevoke = "revoke"
EnrolmentVerbTokens = "tokens"
EnrolmentVerbAgain = "again"
)
// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole
// content except enrolment-expired, which names the node and the enrolment-issued event that carried it.
const (
EnrolmentIssuedEvent = "enrolment-issued"
NodeEnrolledEvent = "node-enrolled"
EnrolmentRefusedEvent = "enrolment-refused"
EnrolmentRevokedEvent = "enrolment-revoked"
EnrolmentExpiredEvent = "enrolment-expired"
)
// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one.
var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent,
EnrolmentExpiredEvent}
// enrolmentDecision is the record that made the seat.
const enrolmentDecision = "novox/hq ADR 0587"
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
// the seat is new with its holder, so no holder of before serves fewer.
func enrolmentVerbs() []Verb {
str := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
return []Verb{
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"The token itself is read only through tokens.",
Input: schema(map[string]string{
"node": "the new machine's name in the mesh",
"why": "why it joins, shown to the operator with the ask",
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
// enters placement with it and the hub peers it before redeem.
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
}, []string{"node", "why", "overlay_key"})},
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
"it finishes what is not done and answers the same; with other keys it is refused.",
Input: map[string]any{"type": "object", "additionalProperties": false,
"required": []string{"node", "public_key", "sealing_key", "proof"},
"properties": map[string]any{
"node": str("the node the token was issued for"),
"public_key": str("the identity key's public half, base64"),
"overlay_key": str("the private network key's public half"),
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
"serving_key": str("the serving key's public half, which the certificate binds"),
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
}}},
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
"enrolment.",
Input: schema(map[string]string{}, nil)},
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
"it, records the act as a hand act and says enrolment-revoked.",
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
[]string{"node", "why"})},
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
"the bucket's fill against its cap. For a node at live, its " +
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
nil, "history")},
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
"and nothing more.",
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
}
}
+6 -3
View File
@@ -744,9 +744,12 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
}
// And from outside as well, on a machine that faces outward — because that is the way in when the
// private network is the thing that broke.
nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
}
// And from outside as well, on a machine that faces outward — because that is the way in when the
// private network is the thing that broke.
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
}
-17
View File
@@ -66,23 +66,6 @@ func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) {
"root-owned 0600 file is one it cannot read — which is silent, because the harness "+
"calls it \"not readable yet\"", fmtPath(secret), got, "operator")
}
// A grant whose offer presents no credential (form none, issue 1126) carries nothing sealed: no secret file is
// written for it, as none is for a need with no credential, and the provider still learns who asked.
out, err = r.Declaration(Rendering{Grants: []Grant{{
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos", Values: map[string]any{}}}})
if err != nil {
t.Fatal(err)
}
asked := false
for _, res := range out {
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
t.Errorf("a grant with no credential writes %v", res)
}
asked = asked || fmtPath(res) == "/var/lib/mongodb/grants/mesh.json"
}
if !asked {
t.Errorf("the provider no longer learns who asked: %v", out)
}
}
// And a provider whose code still runs in a container keeps the owner it declares, so this
+16 -3
View File
@@ -29,7 +29,7 @@ func conditionsVerbs() []Verb {
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
"lately.",
"lately, the controller's before the take-over included.",
Input: schema(map[string]string{
"key": "a condition's key: that one whole; with history, only its transitions",
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
@@ -46,6 +46,17 @@ func conditionsVerbs() []Verb {
"why": "why, required and recorded",
"cause": "the cause in a word (the condition's kind when absent)",
}, []string{"key", "for", "why"})},
{Name: "compare", Description: "The keeper's open conditions against the controller's: every key open in one and not " +
"the other, or open in both with another severity, each with its reason; the kinds that end with the controller " +
"and the keys held while their module is delivered listed apart and not counted. The take-over waits for it empty.",
Input: schema(map[string]string{}, nil)},
{Name: "take-over", Description: "Ask the operator's Approve to switch who the router, the grants part and " +
"mesh-watcher hear from the controller to the keeper; refused while compare shows a difference. On the warrant " +
"the keeper writes its take-over key.",
Input: schema(map[string]string{"why": "why now, shown to the operator"}, []string{"why"})},
{Name: "hand-back", Description: "Switch the readers back to the controller's conditions, until the controller's " +
"lease is handed away; refused after it.",
Input: schema(map[string]string{"why": "why, kept with the switch"}, []string{"why"})},
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
"conditions: the recovery of a keeper that missed a change.",
Input: schema(map[string]string{}, nil)},
@@ -67,7 +78,8 @@ func watchdogsVerbs() []Verb {
Input: schema(map[string]string{"machine": "one machine"}, nil)},
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
"whole. With deliver or drop, a hand act with why.",
"whole. With deliver or drop, a hand act with why, taken only once the controller's lease is handed away; " +
"before, refused naming the controller's verb.",
Input: schema(map[string]string{
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
@@ -102,7 +114,8 @@ func selfCheckVerbs() []Verb {
func healersVerbs() []Verb {
return []Verb{
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
"budget and what happens when it is spent; every act taken lately with its outcome, the acts it would have " +
"taken while the controller's lease names a controller, and the mesh-wide brake.",
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
Input: schema(map[string]string{
+13 -12
View File
@@ -12,13 +12,15 @@ package catalogue
// PlacementSeat is the seat the assigner holds for placement.
const PlacementSeat = "mesh-placement"
// The seat's five verbs (ADR 0567 §10).
// The seat's seven verbs (ADR 0567 §10).
const (
PlacementVerbPlacement = "placement"
PlacementVerbProposals = "proposals"
PlacementVerbPropose = "propose"
PlacementVerbWithdraw = "withdraw"
PlacementVerbAgain = "again"
PlacementVerbAdopt = "adopt"
PlacementVerbHandBack = "hand-back"
)
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
@@ -29,10 +31,7 @@ func placementVerbs() []Verb {
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
"on the same warrant)",
"layer, mesh-wide-values, values)",
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
return []Verb{
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
@@ -53,17 +52,11 @@ func placementVerbs() []Verb {
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
"caller, as the runtime proves it.",
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
"properties": map[string]any{
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
"changes": changes,
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
// Approve is the proposal's; the holder writes it with no ask of its own.
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
"warrant {node, address, ask, warrant}, naming the change's node and address",
"required": []string{"node", "address", "ask", "warrant"}},
}}},
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
"candidates are deleted and placement-withdrawn carries it whole. With why.",
@@ -72,5 +65,13 @@ func placementVerbs() []Verb {
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
{Name: PlacementVerbAdopt, Optional: true, Description: "Take placement over from the controller: its " +
"placement-export is proposed whole as the seed, every node's key, which every node must answer as the same. " +
"Its Approve is the take-over; at the warrant the export is read again and a changed one is proposed anew.",
Input: schema(map[string]string{"why": "why, shown to the operator"}, []string{"why"})},
{Name: PlacementVerbHandBack, Optional: true, Description: "The operator's alone, recorded as a hand act: " +
"delete every placement key, the history kept, so every node returns to the controller's compositions. " +
"Refused after the lease write.",
Input: schema(map[string]string{"why": "why, recorded with the hand act"}, []string{"why"})},
}
}
+8 -9
View File
@@ -33,16 +33,15 @@ type ProcessBus struct {
Removes []string `json:"removes,omitempty"`
}
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: keys of the node-engine's acts
// bucket, and the ended controller's leftovers on the bus (ADR 0590 clause 6): its consumers, its CONTROL and ASSIGNMENTS
// streams, its buckets and the declaration files' object buckets, each by its name. A further consumer, stream or bucket
// is a change of this set, through a decision.
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: the controller's consumer on
// DEAD_LETTER_NOTICES (the lease write's step, clause 5), and keys of the node-engine's acts bucket. A further consumer
// or bucket is a change of this set, through a decision.
const (
RemovesEngineActs = "mesh-host_acts"
// RemovesControllerLeftovers is the closed set of the ended controller's leftovers (buscompose.ControllerLeftovers).
// The word does not name the controller's module, so the check that refuses any manifest naming it (ADR 0590 clause
// 5) holds over the bus module's manifest too.
RemovesControllerLeftovers = "controller-leftovers"
RemovesControllerDeadLetterConsumer = "DEAD_LETTER_NOTICES.controller"
RemovesEngineActs = "mesh-host_acts"
// RemovesAssignmentsStream is the stream ASSIGNMENTS, removed whole at the lease write (novox/hq research 072 row 33):
// its 350 messages have no consumer, and the placement is the provisioner's from then on.
RemovesAssignmentsStream = "ASSIGNMENTS"
)
// BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564
+1
View File
@@ -13,6 +13,7 @@ func TestTheVerbsWorkedAroundSayWhatTheyReplace(t *testing.T) {
ServiceManagerSeat + ".restart": "systemctl restart",
ServiceManagerSeat + ".status": "systemctl status",
"node-hostname.add": "edit /etc/hosts",
ControllerSeatName + ".node": "hostnamectl",
}
for _, s := range DefaultSeats() {
for _, v := range s.Serves {
+1 -1
View File
@@ -372,7 +372,7 @@ func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) {
with := Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}},
Kept: &KeptExport{Export: 1, OperatorKey: "k"},
Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram", Sealed: "sealed-to-anchor",
Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram",
Values: map[string]any{"name": "telegram"}}}}
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
+51 -55
View File
@@ -100,6 +100,37 @@ type Receivable struct {
//
// In the order a person reads it: the mesh's own, then a node's.
var defaultSeats = append([]Seat{
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
// so no work queue is raised for it — only what its holder may say.
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
// the control plane is replaced.
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
// And what is wrong, as it changes, and the self-check's heartbeat (novox/hq to-be 45 §2, §4).
Emits: []string{"applied", "refused", "built-before",
"condition-raised", "condition-changed", "condition-cleared", "doctor-heartbeat",
// A value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
"secret-replaced",
// Every act a healer takes (novox/hq to-be 45 §7).
"healer-acted",
// A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8).
"rolled-back",
// A pull request's merge check, judged (novox/hq to-be 45 §9).
"checked",
// A walk kept in a new state, and a tier sent to a machine: kept until the catalogue's main consumes them no
// more; nothing emits them (ADR 0539).
"plan-moved",
"sent",
// A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
"assignment-changed", "declared-config-changed", "predefined-changed",
// A builder's key not taken in because the artifact store does not hold an artifact it names (novox/hq
// issue 1937): the builder builds it again.
"artifacts-gone",
// A bucket of a state this release deleted, deleted at the controller's first start (novox/hq ADR 0539
// clause 13): the merge window's, the silent sends' and the building plans' hold, each said once.
"bucket-deleted"},
Serves: ControllerVerbs},
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
// served by whichever module holds the seat with tools of these names.
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
@@ -202,59 +233,13 @@ var defaultSeats = append([]Seat{
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
"and the signer's fingerprint, and says signed; never a key.",
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
// manifest names for these two.
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
Input: withObject(schema(map[string]string{"node": "the node the token is for",
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
"sealed": "\"true\": answer the token sealed to the operator key",
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
"address": "the joining node's tunnel address",
"key": "the joining machine's own tunnel public key, as given at issue",
"range": "the private network's range, a prefix"})},
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
Input: schema(map[string]string{"node": "the joining node",
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
// node last; the state node-logins says where each node stands.
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
"without it.",
Input: schema(map[string]string{}, nil)},
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
"node": "the machine, for a node certificate: the caller's own",
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
}},
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
@@ -263,8 +248,21 @@ var defaultSeats = append([]Seat{
Serves: []Verb{
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
"with why, validated, and naming each module on each node whose value moves.",
"with why, validated, and naming each module on each node whose value moves. A trusted value only " +
"from the controller's terminal.",
Input: configInput()},
// The switch of config to its own sources (novox/hq ADR 0565 §4): optional until every mesh-config serves it.
{Name: "compare", Optional: true, Description: "Every difference between config's own sources and the " +
"controller's records, each with its reason: a module's declared config against the controller's mirror, " +
"the indexer's predefined list against the mirror's, and each open pull request's config candidate against " +
"what the controller told. The take-over waits for none.",
Input: schema(map[string]string{}, nil)},
{Name: "take-over", Optional: true, Description: "Ask the operator's Approve to switch config to its own " +
"sources (the indexer); refused while compare shows a difference.",
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
{Name: "hand-back", Optional: true, Description: "Switch config back to the controller's mirrors, until the " +
"controller hands its lease away; refused after it.",
Input: schema(map[string]string{"why": "why, in a sentence"}, []string{"why"})},
}},
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
@@ -304,8 +302,6 @@ var defaultSeats = append([]Seat{
// policy and the bus step at the assigner, and the hand-act log.
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
Serves: artifactSweepVerbs()},
+2 -22
View File
@@ -1,9 +1,6 @@
package catalogue
import (
"slices"
"testing"
)
import "testing"
// The vault's provision is one the controller itself dereferences — every minted credential is
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
@@ -68,14 +65,6 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
"rotate": {"node", "module", "name", "provider", "provision", "by"},
// 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5).
"enrolment-token": {"node", "expires", "sealed", "drop", "tunnel"},
"node-account": {"node", "sealing_key", "keys_digest", "retire"},
"sign": {"kind", "node", "serving_key", "reply"},
// 774/S38: the rotation verbs (ADR 0583 §2, §3, §5).
"rotate-node": {"node", "why", "act"},
"node-account-confirm": {},
"abandon": {"node"},
}
for name, args := range want {
var verb *Verb
@@ -100,14 +89,6 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
if props["value"] != nil {
t.Errorf("%s takes a value in the clear", name)
}
// The first tunnel the token carries (ADR 0587 §3, insight of 2026-11-02): an object of the hub's key, its endpoint
// and the node's address.
if name == "enrolment-token" {
tunnel, _ := props["tunnel"].(map[string]any)
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
t.Errorf("enrolment-token's tunnel is %v", tunnel)
}
}
}
// The holders `issued` names (issue 749) are a list of {node, module}.
for _, verb := range seat.Serves {
@@ -124,8 +105,7 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
}
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
"rotate-node", "node-account-confirm", "abandon"}}}}
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
if err := CanHold(serving, seat); err != nil {
t.Fatalf("the vault serving its verbs was refused: %v", err)
}
+515 -10
View File
@@ -4,8 +4,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"slices"
)
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
@@ -76,6 +74,521 @@ func VerbNames(verbs []Verb) []string {
return out
}
// RemovedControllerVerbs are the controller seat's verbs release 6 removed (novox/hq ADR 0539, ADR 0429 §8): no
// delivery plan, batch, tier or push exists, so nothing starts, stops, retries, follows or reads one. Each stays in
// ControllerVerbs as a row with no input, so a caller is answered by name with its replacement, given here.
var RemovedControllerVerbs = map[string]string{
"plans": "the delivery view (nox-delivery): each machine's want and have, read from the assigner's wants and the " +
"node-engine's haves",
"plan": "the node's compositions: mesh-controller_composed holds each module's digest and judgement per node, and " +
"mesh-hand-compose prints one module's composition for one node",
"push": "a want: the assigner writes want.<node>.<module> by its rules, and by hand the operator's credential writes " +
"it, naming a kept composition (ADR 0539 clause 10)",
"deliver": "nothing to start: a merged change reaches each machine by its want",
"delivery-stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
"delivery-backups-again": "the assigner's backup ask, answered on the phone or in the delivery view (ADR 0539 clause 16)",
"delivery-walks": "the delivery view (nox-delivery) and the wants, haves and compositions on the bus",
"delivery-order": "no order to keep: each module reaches its machines by its own want, its providers first by rule 5",
}
// removedInR6 is the row of a verb release 6 removed from the controller's seat: no input, refused by name.
func removedInR6(name string) Verb {
return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
RemovedControllerVerbs[name], Input: schema(nil, nil)}
}
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
const ControllerSeatName = "mesh-controller"
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
//
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
// is a command the controller's binary already answers, run by the holder of the seat with the
// arguments below and answered with what the command printed. A verb here is a contract every future
// holder must implement, which is why the list is short and made of what an operator asks weekly.
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
var ControllerVerbs = []Verb{
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
Input: schema(nil, nil)},
{Name: "calls", Description: "The calls this controller answered lately and what came of each — " +
"one still running, one that finished after its caller was told it was running, one whose answer " +
"the bus refused — and, given a call's id, its whole answer (novox/hq issue 265). A call that has " +
"not finished within ten seconds answers that it is running, with its id; this is where it ends.",
Input: schema(map[string]string{
"call": "a call's id, as a running answer or `calls` gives it: that call and its whole answer",
}, nil)},
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
"machines are behind what the mesh would send them.",
Input: schema(nil, nil)},
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
Input: schema(nil, nil)},
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}),
Replaces: []string{"hostnamectl", "uptime"}},
{Name: "placement-export", Description: "Each machine's placement as the catalogue's placement file says it (novox/hq ADR 0555 " +
"§6, issue 1126): what a person decided of the machine (endpoint, site, hub, address on the private network, public " +
"domain, accounts, adoption, the tunnel found at adoption) and where its modules run (in order, with host ports, " +
"bindings, config placement layers, pins and holdings), read by the controller's own code from its own store, as JSON. " +
"Read-only; the one verb the controller gains for its exit (the operator, 2026-10-26 19:06:40), read once by the seed " +
"of the placement files.",
Input: schema(map[string]string{"node": "one machine's name; every machine when absent"}, nil)},
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
"and which machines run it.",
Input: schema(nil, nil)},
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
Input: schema(nil, nil)},
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one; " +
"or, given a build's id, everything the build machine said while building it, line by line, from the bus.",
Input: schema(map[string]string{
"module": "one module's name; every module when absent",
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
"limit": "how many builds to list (default 20); not with log",
}, nil)},
removedInR6("plans"),
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
{Name: "delivery-plan", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
Input: schema(nil, nil)},
removedInR6("delivery-order"),
{Name: "delivery-check", Description: "Answers that the checker (mesh-delivery-checker) judges a group (novox/hq " +
"ADR 0503 §3); asks nothing. Its inputs stay, since hq's repository check still names it (ADR 0513 §4).",
Input: schema(map[string]string{"group": "the group's id (none for one head checked again)",
"members": "the members, as JSON, as delivery-order takes them",
"ready": "\"true\" when the pull request is marked ready: its check is queued before every other check (novox/hq ADR 0406 §3)"},
[]string{"members"})},
removedInR6("deliver"),
removedInR6("delivery-stop"),
removedInR6("delivery-backups-again"),
removedInR6("delivery-walks"),
removedInR6("plan"),
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207). " +
"A module not registered yet is looked for (novox/hq issue 325, ADR 0261): while its build runs — a merge " +
"that adds a module asks for one — the assignment is kept as pending and the controller makes it when the " +
"build registers it; `status` lists it, and if the build fails it expires with why and raises a condition. " +
"A module known and not built is said, and build asks for its build; a name with no module and no build " +
"request kept is refused with the closest names.",
Input: schema(map[string]string{"node": "the machine's name",
"module": "the module's name; several comma-separated are judged together",
"build": "\"true\": for a module known and not built (its last build failed, or was never asked), ask " +
"for its build and keep this assignment pending until the build registers it"},
[]string{"node", "module"}, "build")},
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
Input: schema(map[string]string{"node": "the machine's name",
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
Input: schema(map[string]string{
"node": "the machine's name",
"provision": "the provision, as the consumer requires it",
"from": "the node the chosen provider runs on",
"module": "the module providing it there",
}, []string{"node", "provision", "from", "module"})},
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
removedInR6("push"),
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine and module, " +
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
"name: made anew and the machine sent, so the module starts again on it — for a secret its definition " +
"says it reads at start, whether the mesh made the value or a person gave it (novox/hq ADR 0228); one " +
"an outside party issued, or one the module applies to a backend, is refused with the reason.",
Input: schema(map[string]string{
"provision": "a pair credential: the provision whose credential to replace",
"consumer": "with provision: only the holder on this machine (optional)",
"node": "an own secret: the machine",
"module": "an own secret: the module; with provision: only this consuming module's credential (optional)",
"secret": "an own secret: its name in the module's definition",
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
}, nil)},
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
"§10): the same as secret-ask with the desk named. A prompt that does not show what is typed opens on " +
"the machine named by at, its answer comes back sealed to this call alone, and is sealed to the " +
"module's machine as `secret accept` seals it. The value is never an argument and never in the answer: " +
"the answer says it was taken, or why not. Recorded in the hand-act log as a value given at the desk. " +
"The prompt waits 25 seconds; dismissed or unanswered, nothing changes. Then push the machine.",
Input: schema(map[string]string{
"node": "the machine the module runs on, which the secret is sealed to",
"module": "the module's name",
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens",
}, []string{"node", "module", "secret", "at"})},
{Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " +
"server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " +
"0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " +
"value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " +
"machines when it changed. Carries no value and answers none; a value every holder already holds is not " +
"sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.",
Input: schema(map[string]string{
"provision": "the provision whose provider's software issues its credential, e.g. plex-api",
}, []string{"provision"})},
{Name: "secret-move", Description: "Move every secret the controller holds into the vault, as it is (novox/hq " +
"ADR 0349 clause 7, design 57's V3): each machine's node-engine opens what was sealed to it and hands it to " +
"the vault, which takes it from that machine alone, never over a value it holds, and reads it back; the " +
"record of asks at a desk is copied once. No value is rotated, in the arguments or in the answer, and " +
"nothing points at the vault because of it. Answers, per machine, what moved, what was there already and " +
"what did not move and why; a machine not answering is moved when it is next heard from. Run again, it " +
"moves what is left.",
Input: schema(map[string]string{
"node": "only this machine's secrets (optional; every machine's without it)",
}, nil)},
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
"and never in a verb's argument; the answer comes back sealed to this call alone and is sealed to the " +
"module's machine as `secret accept` seals it. The answer says the value was taken, or why not. " +
"Refused for a secret the mesh issues itself (the bus account, one the mesh may make) and for a module " +
"that runs as an account of its own, whose value is typed at the controller's terminal. Bounded: one " +
"open prompt per secret, three asks an hour. Recorded in the hand-act log, with who asked, and " +
"announced on every channel. Then push the machine.",
Input: schema(map[string]string{
"node": "the machine the module runs on, which the secret is sealed to",
"module": "the module's name",
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens; the module's machine when absent",
}, []string{"node", "module", "secret"})},
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
Input: schema(map[string]string{
"node": "the machine that runs the module",
"module": "the module's name",
}, []string{"node", "module"})},
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
Input: schema(map[string]string{
"node": "a machine the mesh already has a record for",
"new": "or the name of a machine to create the record for",
"overlay_key": "the public half of the machine's tunnel key",
"for": "how long it may be used, as a duration (default 1h)",
"adopted": "\"true\" when the machine is in use and joins adopted",
}, nil, "adopted"),
Replaces: []string{"mesh-controller token issue", "wg set"}},
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
"then every value the module gives a default or a layer sets, with where it comes from: the module's default, " +
"the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " +
"module's preferences and each machine's value; " +
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
"what its definition says; a cleared or replaced layer is kept in the history. A trusted setting — " +
"places, accesses, what a provider serves, what a trusted file asks for, the whole layer of a module " +
"with a trusted mergeable file — is refused through this verb (novox/hq issue 339, 340) and PROPOSED " +
"instead (novox/hq ADR 0277): with propose, the values (or clear) are put to the operator on a channel " +
"that proves who answers, with every key and its exact new value, and the layer is set only on their " +
"Approve; the answer names the proposal, its fingerprint and when it expires, and nothing changes until " +
"then. With proposals, every proposal and where each stands; with proposal, one whole. Once move-config has " +
"run, a module in the config form forwards to mesh-config (issue 664): show lists what config lists, set and " +
"clear set and clear each value as the module's own on the machine (none for the whole mesh), placement " +
"staying beside the assignment, and propose proposes each value through mesh-config.",
Input: schema(map[string]string{
"module": "the module's name; with list, only that module's",
"values": "the settings as a JSON object, for set or propose",
"node": "one machine; the whole mesh when absent",
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
"propose": "\"true\": propose the values (or clear) to the operator instead of setting them (novox/hq ADR 0277)",
"proposals": "\"true\": every settings proposal, newest first, and where each stands",
"proposal": "a proposal's id: that proposal whole, its values and the layer it was shown against",
}, nil, "clear", "replace", "history", "propose", "proposals")},
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
"status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
"plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " +
"module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " +
"--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " +
"accepts, recovers or exports a secret is the controller's terminal's alone.",
Input: schema(map[string]string{
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
}, []string{"command"})},
// The build queue went with build-agent (novox/hq ADR 0525 §9): asks are the build seats' streams, their backlog the
// streams' own state, shown in the delivery view. The verbs are refused by name for one release, naming that.
{Name: "queue", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: the build seats' backlog in the delivery view",
Input: schema(nil, nil)},
{Name: "cancel", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement", Input: schema(nil, nil)},
{Name: "clear", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement", Input: schema(nil, nil)},
{Name: "replay", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement", Input: schema(nil, nil)},
// novox/hq issue 686: a builder ran a branch's store tests for the first time in its pull request's merge check.
{Name: "try", Description: "Try a pushed branch or commit before its pull request (novox/hq issue 686): the " +
"checker (mesh-delivery-checker) merges the head onto the repository's trunk and runs its levels on the merge, " +
"as it judges a pull request (ADR 0503 §3), its levels asks on the build seats (ADR 0525 §1); no merge-check.sh is run. Answers at " +
"once with an id; asked again with that id it waits up to 20 s and answers pass, fail or error, how long it " +
"took and the lines naming what failed — or that it is still waiting or running, to ask again. Sets no " +
"status on the forge, registers and builds nothing for delivery. One try of a commit at a time — a second " +
"ask answers the first's id. Only for a repository of the mesh; the ref must be on the forge.",
Input: schema(map[string]string{
"repository": "the repository on the forge, owner/name; with ref",
"ref": "the branch or commit to try, as pushed to the forge",
"base": "the branch it is merged into (the repository's default branch when none is named)",
"tests": "only the tests this `go test -run` pattern names",
"store-only": "\"true\": only the integration level",
"id": "instead of a repository: the id a try answered, to read what came of it",
}, nil, "store-only"),
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
{Name: "kill", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: a build seat's ask ends where it runs", Input: schema(nil, nil)},
{Name: "pause", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: <node>/node-build-<language>.pause", Input: schema(nil, nil)},
{Name: "resume", Description: "Removed in R4g (ADR 0525 §9): refused by name, naming its replacement: <node>/node-build-<language>.resume", Input: schema(nil, nil)},
// Acts done by hand, and what the bounds are set from (novox/hq to-be 45 §7, Phase 0).
{Name: "hand-act", Description: "Record an act done by hand outside the mesh — a container restarted, a file " +
"edited, a service started on a machine — with why and its cause, in the hand-act log beside the pushes and " +
"plans ended by hand (novox/hq to-be 45 §7). A cause recorded twice in a fortnight is a healer wanted.",
Input: schema(map[string]string{
"what": "what was done, in a line",
"why": "why it had to be done by hand",
"cause": "the cause in a word, or a condition's kind — the word a second act for the same reason uses",
"condition": "the key of the condition it addressed, if any (optional)",
}, []string{"what", "why", "cause"})},
{Name: "drill", Description: "Record a drill — something broken on purpose to see the mesh raise and clear it: " +
"a module stopped, a process killed — with what it tests, in the hand-act log. A drill is a person's " +
"deliberate test, never a repair: no healer is wanted for it however often it is run (S15).",
Input: schema(map[string]string{
"what": "what was done on purpose, in a line",
"why": "what the drill tests",
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
}, []string{"what", "why"})},
{Name: "warranted", Description: "Record in the hand-act log what a module did on the operator's warrant (novox/hq " +
"ADR 0274, ADR 0259): who chose, through which channel, with which proofs and which answer are read from the " +
"router's own record of that module's ask, never from the caller; recorded once per ask however often it is " +
"asked; the caller names the ask and says nothing else. Refused for an ask still open, ended without a choice, " +
"or not that module's.",
Input: schema(map[string]string{
"asker": "the module that asked, e.g. claude-code",
"ask": "its ask's id",
}, []string{"asker", "ask"})},
{Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " +
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
{Name: "durations", Description: "How long things take, as the controller measured them: a send to its machine's " +
"report (apply), a plan's tier, a build, and each phase of an " +
"ended walk per class (walk-phase, novox/hq ADR 0282) — per machine, repository, module or class/phase, with " +
"median, p90 and max. What the core's bounds are set from (novox/hq to-be 45 Phase 0).",
Input: schema(map[string]string{
"kind": "one kind: apply, plan-tier, build or walk-phase; every kind when absent",
"days": "how many days back (default 14)",
}, nil)},
// What is wrong, and the self-check (novox/hq to-be 45 §2, §4).
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, " +
"then oldest — raised by the watchdogs of the signals table, the self-check's probes and the providers' " +
"own words, and cleared when observation says it is resolved, never by hand. Given a key, that one " +
"whole with its evidence; with history, every transition lately; with silence, stop one's messages " +
"for a while — a hand act, which says why (novox/hq to-be 45 §2).",
Input: schema(map[string]string{
"key": "a condition's key: that one whole; with history, only its transitions",
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe or mesh",
"severity": "only urgent, or only warning",
"machine": "only those about this machine",
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
"days": "with history: how many days back (default 7, at most 90)",
"silence": "a condition's key: send no message for it for a while; it stays open and in status",
"for": "with silence: how long — 30m, 4h, 2d; at most 7d",
"why": "with silence: why — required, and recorded in the hand-act log",
"cause": "with silence: the cause in a word (the condition's kind when absent)",
}, nil, "history")},
// What the healers did (novox/hq to-be 45 §7).
{Name: "healers", Description: "The healers (novox/hq to-be 45 §7): each registered response to one kind " +
"of condition — its repair (the ordinary path again), its budget, what happens when it is spent — every act " +
"they took lately with its outcome, and the mesh-wide brake. A heal is never a hand act; whether it " +
"repaired anything is the condition's clearing to say. Each act is also in its condition's tried.",
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
{Name: "doctor", Description: "The self-check (novox/hq to-be 45 §4): the last run's verdict at once — " +
"each probe of the design's live invariants passed, failed or could not run, and how long ago. With " +
"run, a run now; with probes, the registry; with signals, every row of the signals table and the age " +
"of its newest signal. Runs every five minutes on its own; each failure is an open condition.",
Input: schema(map[string]string{
"run": "\"true\": run every probe now and answer the verdict",
"probes": "\"true\": the registry — what each probe asserts, and the condition it raises",
"signals": "\"true\": the signals table, each row with the age of its newest signal",
"probe": "one probe's id (D14): its verdict alone, with every finding — the verdict shows at most twenty a probe",
}, nil, "run", "probes", "signals")},
// How a module's new builds reach its machines, and the bus's planned step (novox/hq ADR 0236).
{Name: "upgrade", Description: "Whether each module's new builds wait for a person (novox/hq ADR 0236, ADR 0539 clause 14): " +
"for a module of policy record, the assigner writes no want naming a composition judged to recreate a container or move " +
"data until the operator answers its ask; with where the policy comes from (a person, the module, the bus, its " +
"irreplaceable data, the default) and why. With module, that one; with policy, a person's choice for it — roll-out, " +
"record (with why) or default to take the choice back. The bus is never moved by the assigner (ADR 0539 clause 15).",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll-out, record or default",
"why": "with policy: why — required for record, kept",
}, nil)},
{Name: "bus", Description: "The bus as a person's step (novox/hq to-be 45 §8, ADR 0236, ADR 0539 clause 15): what a bus " +
"upgrade would do — the bus's build on each machine against the one the mesh holds — and how the last step went. " +
"With upgrade, check one: a person's act with why, after the streams are snapshotted (snapshot-taken says where, " +
"while the mesh takes none itself), saying first whether it can be reverted; then it answers the want the operator " +
"writes with the operator's credential, want.<node>.<bus module>, recorded — nothing is sent, and the assigner never " +
"moves the bus.",
Input: schema(map[string]string{
"upgrade": "\"true\": start the bus's upgrade",
"why": "with upgrade: why — required, recorded in the hand-act log",
"cause": "with upgrade: the cause in a word (default bus-upgrade)",
"reversible": "\"true\": with upgrade, the new version can be undone by putting the old one back",
"irreversible": "\"true\": with upgrade, it cannot — your explicit word that it runs anyway",
"snapshot-taken": "with upgrade: where the streams' snapshot you took is",
}, nil, "upgrade", "reversible", "irreversible")},
// A consumer the mesh stopped asking for: retired, waiting for a person, deleted only by one
// (novox/hq ADR 0230).
{Name: "retire", Description: "A consumer the mesh stops asking for is retired by its provider — access " +
"disabled, data kept — after the same answer in five passes; more than three at once, or more than half " +
"of those held, waits for a person. With no answer, every provider that waits and what it would retire. " +
"With answer approve or reject, a node and a module: retire exactly what that provider waits with, or " +
"keep it active — a hand act, which says why (novox/hq ADR 0230).",
Input: schema(map[string]string{
"answer": "approve or reject: answer what the provider waits with (needs node, module and why)",
"node": "with answer: the machine the provider runs on",
"module": "with answer: the provider module",
"why": "with answer: why — required, and recorded in the hand-act log",
"cause": "with answer: the cause in a word (retire-waiting when absent)",
}, nil)},
{Name: "cleanup", Description: "Every consumer a provider holds retired — its age, its size where the " +
"backend knows, and why it was retired — and every module's own data retired on its machine (ADR 0233). " +
"With consumer (and node, module): the provider deletes that one retired consumer — never an active one; " +
"for a module's own retired item, consumer names the item and the machine's backup holder takes a last " +
"restore point of it, then deletes it. With older-than: every retired consumer older than that many " +
"days, listed; deleted only with confirm. Deleting is a hand act, which says why (novox/hq ADR 0230).",
Input: schema(map[string]string{
"node": "with consumer: the machine the provider runs on",
"module": "with consumer: the provider module",
"consumer": "delete this retired consumer (needs node, module and why)",
"older-than": "delete every consumer retired more than this many days (needs why; lists only without confirm)",
"confirm": "\"true\": with older-than, delete what is listed",
"why": "with consumer or older-than: why — required, and recorded in the hand-act log",
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
}, nil, "confirm")},
{Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " +
"node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " +
"each: placed, would be refused and why (the link and who could replace it), refused already by the placement " +
"guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.",
Input: schema(map[string]string{
"node": "the machine's name",
}, []string{"node"})},
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
"names an account its agents run as, its node-engine judged that account unable to become root within the " +
"last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " +
"included, is not free and says why. The router asks it before an answer from a channel proving its sender " +
"may approve. Only reads.",
Input: listed(schema(map[string]string{
"machines": "the machines to judge, by name: a list, or one text separated by commas",
}, []string{"machines"}), "machines")},
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " +
"as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " +
"when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " +
"again to the consumer that gave it up, and nobody else; with drop: let it go for good. Delivering and " +
"dropping are hand acts, which say why (novox/hq issue 330).",
Input: schema(map[string]string{
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
"limit": "how many to list, newest first (default 50); only when listing",
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
"drop": "a dead letter's id: drop it for good (needs why)",
"why": "with deliver or drop: why — required, and recorded in the hand-act log",
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
}, nil)},
// The data every machine declares (novox/hq ADR 0233).
{Name: "data", Description: "Every item of data every machine declares, as the self-check last measured it: " +
"its class (irreplaceable, rebuildable, cache), where it is, its size, its newest write, its newest good " +
"backup and the bound on it — and what is retired: kept after its module left the machine, removed only by " +
"`cleanup delete` (novox/hq ADR 0233).",
Input: schema(map[string]string{
"machine": "one machine (optional)",
"retired": "\"true\": only what is retired",
}, nil, "retired")},
{Name: "build", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
Input: schema(nil, nil)},
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251, to-be 51).
{Name: "artifacts", Description: "Every artifact the mesh recorded making that lives in its artifact store, " +
"each with its repository, digest, kind (image or archive) and state: kept — and why: a definition names " +
"it, or one of the five most recent builds of a module the mesh holds — or eligible: made, kept for no " +
"reason, not yet let go. Collected ones are counted, and listed only with collected. Reads only " +
"(novox/hq ADR 0251).",
Input: schema(map[string]string{
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
"collected": "\"true\": list the collected references too (they only grow; counted always)",
}, nil, "collected")},
// Whether the artifact store holds what a builder's done key names, before the builder reuses it (novox/hq issue
// 1937): the store's collector deletes a blob nothing holds, and a key the controller never took in holds nothing.
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key, and " +
"builds a key whose artifact is gone again. Reads only: nothing is held or written (novox/hq issue 1937).",
Input: listed(schema(map[string]string{
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
"or one text separated by commas",
}, []string{"references"}), "references")},
{Name: "collect", Description: "The artifact store's sweep, now: what the mesh made and keeps for no reason " +
"is let go of. Without confirm a dry run — every kept archive asked about with HEADs, what would be let go " +
"listed, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible " +
"artifact let go of, oldest first, and recorded collected — a hand act, recorded with its why. A confirmed " +
"collect lets an index go with the platform manifests no kept index names (read first; any read that " +
"fails lets nothing go); the sweep after a build leaves an eligible index for such a collect (novox/hq ADR " +
"0257). Bounded by " +
"most (500 by default, at most 5000) and 45 seconds; what is left is said. Bytes are reclaimed by the " +
"store's nightly collector. Never touches a digest the mesh did not record (novox/hq ADR 0189, ADR 0251).",
Input: schema(map[string]string{
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
"why": "with confirm: why — required, and recorded in the hand-act log",
"most": "let go of at most this many (500 by default, at most 5000)",
}, nil, "confirm")},
{Name: "move-placement", Description: "The move of placement out of the settings layers, run once after the " +
"delivery that holds it (novox/hq ADR 0348 clause 11, issue 736): every placement key a settings layer still " +
"holds — endpoints, expose, reach and ports, which a layer set before then keeps among its settings — is put " +
"beside the assignment, where places, accesses and networks already are. Without confirm it says which layers " +
"hold which keys and moves nothing. With confirm and why: in one transaction, each such layer is kept whole " +
"in its history first (why: moved to placement), its keys are laid over the placement beside it, and the " +
"layer keeps its other keys, when it was set and who set it — and the act is recorded in the hand-act log. " +
"Answers each layer moved: its machine (empty for the whole mesh), its module and its keys. Nothing a " +
"machine is told changes: composition reads the keys as placement wherever they are. Asked again, it " +
"moves nothing.",
Input: schema(map[string]string{
"confirm": "\"true\": move them (needs why); without it nothing is moved",
"why": "with confirm: why — required, and recorded in the hand-act log",
}, nil, "confirm")},
{Name: "move-config", Description: "The move of config: every config value a settings layer holds is set in " +
"mesh-config, run once after the delivery that holds it and after move-placement (novox/hq ADR 0348 §9, " +
"issue 664). A machine's layer becomes the module's own value on that machine; a whole-mesh layer becomes " +
"mesh config for the predefined key its value falls back to where that module alone falls back to it, and " +
"otherwise the module's own value on each machine it is assigned to, laid under that machine's layer; a key " +
"a layer says by its file's name is set under its config key. Without confirm it says each value it would " +
"set, where from, each layer that stays (its module is in the settings form) and anything that refuses the " +
"move, and sets nothing. With confirm and why: each value is set through mesh-config in the controller's " +
"name, every module's resolved values are read back and compared with what its layers lay today, and only " +
"then, in one transaction, is each layer moved kept whole in its history first (why: moved to mesh-config) " +
"and emptied, and the controller switched to read mesh-config where it read the layers — the act recorded " +
"in the hand-act log. Nothing a machine is told changes. Asked again, it sets nothing set already.",
Input: schema(map[string]string{
"confirm": "\"true\": set the values and switch (needs why); without it nothing is set",
"why": "with confirm: why — required, and recorded in the hand-act log and with each value",
}, nil, "confirm")},
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store for builds to stand on, as " +
"the records hold them: each with its state — kept because a kept build stood on it, or eligible — and " +
"which build or person recorded it. With record: references of copies no record names (a copy made " +
"before copies were recorded, or by a build that failed before then) to record as the mesh's, so the " +
"sweep keeps or lets go of them like any other; only a repository the mirror writes is taken " +
"(upstream/<host>/<path>, or a module's <module>/on-<argument>), and each must be in the store. Without " +
"confirm a dry run; with confirm and why, recorded — a hand act. A recorded copy is eligible: the next " +
"sweep lets it go unless a kept build stood on it. Records only: this verb deletes nothing " +
"(novox/hq ADR 0257).",
Input: schema(map[string]string{
"record": "references to record, artifact-store://<repository>@sha256:<hex>, separated by spaces or commas",
"confirm": "\"true\": record them (needs why); without it nothing is recorded",
"why": "with confirm: why — required, and recorded with each copy and in the hand-act log",
}, nil, "confirm")},
{Name: "images", Description: "Every container image one machine's declaration names — the declaration the " +
"mesh would send it now and the one it was last sent — each with the resources naming it: what the " +
"machine keeps when it prunes its images. sent_known is false when the last one sent is not kept with " +
"its images. Reads only (novox/hq ADR 0251).",
Input: schema(map[string]string{
"node": "the machine's name",
}, []string{"node"})},
}
// schema is a JSON schema for an object of string properties, which is every argument the verbs
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The
// switches are the properties that are "true" or "false" and nothing else, said in the schema as an
@@ -83,14 +596,6 @@ func VerbNames(verbs []Verb) []string {
//
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
// name is refused when the verb is called, never passed over.
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
inner["description"], inner["additionalProperties"] = description, false
s["properties"].(map[string]any)[name] = inner
return s
}
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
isSwitch := map[string]bool{}
for _, s := range switches {
+31
View File
@@ -0,0 +1,31 @@
package catalogue
import (
"strings"
"testing"
)
// 1887/A9b: the build queue's verbs are refused by name, naming what replaced them, and the verbs R4e removed are gone.
func TestTheBuildQueueVerbsAreRefusedByName(t *testing.T) {
rows := map[string]Verb{}
for _, v := range ControllerVerbs {
rows[v.Name] = v
}
for _, gone := range []string{"rebuild", "forget-checks"} {
if _, ok := rows[gone]; ok {
t.Errorf("the controller still lists %s", gone)
}
}
for _, refused := range []string{"queue", "cancel", "clear", "kill", "pause", "resume", "replay"} {
v, ok := rows[refused]
if !ok || !strings.HasPrefix(v.Description, "Removed in R4g (ADR 0525 §9): refused by name") {
t.Errorf("%s = %q, want refused by name", refused, v.Description)
}
}
if !strings.Contains(rows["queue"].Description, "delivery view") || !strings.Contains(rows["pause"].Description, "node-build-") {
t.Errorf("queue and pause do not name their replacements: %q, %q", rows["queue"].Description, rows["pause"].Description)
}
if strings.Contains(rows["try"].Description, "build-agent") {
t.Errorf("try still names build-agent: %q", rows["try"].Description)
}
}
+64
View File
@@ -5,6 +5,58 @@ import (
"testing"
)
// 1943/AC4: the controller's seat no longer offers a verb that starts, stops, retries, follows or reads a delivery
// plan, nor a push: each is a row with no input whose description refuses it by name and names its replacement, and
// each is in the removed list the holder answers from. upgrade keeps its policy and drops the plan's backlog.
func TestTheControllerSeatHasNoPlanVerbs(t *testing.T) {
rows := map[string]Verb{}
for _, v := range ControllerVerbs {
rows[v.Name] = v
}
gone := []string{"plans", "plan", "push", "deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-order"}
for _, name := range gone {
v, ok := rows[name]
if !ok {
t.Errorf("%s is not in the table, so a caller is not answered by name", name)
continue
}
replacement, removed := RemovedControllerVerbs[name]
if !removed || replacement == "" {
t.Errorf("%s is not in the removed list with its replacement", name)
}
if !strings.HasPrefix(v.Description, "Removed in R6 (ADR 0539): refused by name") || !strings.Contains(v.Description, replacement) {
t.Errorf("%s = %q, want refused by name, naming %q", name, v.Description, replacement)
}
if props, _ := v.Input["properties"].(map[string]any); len(props) > 0 {
t.Errorf("%s still takes %v", name, props)
}
}
props, _ := rows["upgrade"].Input["properties"].(map[string]any)
for _, arg := range []string{"together", "backlog", "release-backlog", "cause"} {
if _, has := props[arg]; has {
t.Errorf("upgrade still takes %s, a plan's", arg)
}
}
for _, arg := range []string{"module", "policy", "why"} {
if _, has := props[arg]; !has {
t.Errorf("upgrade no longer takes %s", arg)
}
}
for _, word := range []string{"rolled", "gate", "backlog", "push"} {
if strings.Contains(rows["upgrade"].Description, word) {
t.Errorf("upgrade's description still speaks of %q: %q", word, rows["upgrade"].Description)
}
}
if !strings.Contains(rows["bus"].Description, "want") {
t.Errorf("bus does not say the operator writes the bus's want: %q", rows["bus"].Description)
}
for name := range RemovedControllerVerbs {
if v := rows[name]; !strings.HasPrefix(v.Description, "Removed in R6") {
t.Errorf("%s is in the removed list and its row says %q", name, v.Description)
}
}
}
// 1943/AC4: the delivery seat keeps the merge's verbs and no longer offers times, what-if, release, stop or
// retire-history: each is a row with no input, optional so the holder of before and the holder of release 6 both hold
// the seat, refusing it by name; mark-ready, served by nobody since issue 1022, is gone.
@@ -49,3 +101,15 @@ func TestTheDeliverySeatHasNoPlanVerbs(t *testing.T) {
t.Errorf("the gate of release 5 cannot hold the seat: %v", err)
}
}
// 1943 (for 1945/AC14): the controller's seat says a bucket it deleted at its first start as its event, bucket-deleted
// (ADR 0539 clause 13).
func TestTheControllerSaysABucketItDeleted(t *testing.T) {
seat, ok := SeatNamed(ControllerSeatName)
if !ok {
t.Fatal("no controller seat")
}
if !SeatSays(seat.Emits, "bucket-deleted") {
t.Errorf("the controller's seat does not emit bucket-deleted: %v", seat.Emits)
}
}
+18 -25
View File
@@ -1,7 +1,6 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
@@ -26,30 +25,6 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) {
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
// 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused.
enrolment, ok := SeatNamed(EnrolmentSeat)
if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 {
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
}
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
t.Errorf("issue requires %v", issue.Input["required"])
}
holder := func(serves ...string) Manifest {
return Manifest{Module: "mesh-enrolment", Tools: serves,
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
}
if err := CanHold(holder(six...), enrolment); err != nil {
t.Errorf("the holder serving the six verbs was refused: %v", err)
}
if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") {
t.Errorf("a holder missing again: %v", err)
}
if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") {
t.Errorf("a holder saying it serves mint: %v", err)
}
was := Seats()
t.Cleanup(func() { UseSeats(was) })
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
@@ -68,3 +43,21 @@ func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
t.Fatalf("a holder serving every verb was refused: %v", err)
}
}
// The mesh's own verbs are declared in full: an agent cannot call a name without a schema.
func TestEveryControllerVerbIsDescribedWithASchema(t *testing.T) {
seen := map[string]bool{}
for _, v := range ControllerVerbs {
if v.Description == "" || v.Input == nil || v.Input["type"] != "object" {
t.Errorf("%s: no description or no object schema", v.Name)
}
if seen[v.Name] {
t.Errorf("%s declared twice", v.Name)
}
seen[v.Name] = true
}
seat, _ := SeatNamed(ControllerSeatName)
if len(seat.Serves) != len(ControllerVerbs) {
t.Fatalf("the compiled mesh-controller seat serves %d verbs, the table has %d", len(seat.Serves), len(ControllerVerbs))
}
}
+201
View File
@@ -0,0 +1,201 @@
// Package cli is the local protocol between `mesh-cli` and the node-engine on its machine (novox/hq ADR 0272).
//
// `mesh-cli <command line>` asks the controller to run one of its command lines. It holds no credential and
// reads no file of the mesh: it connects to the node-engine's socket, writes one Request, and reads one Answer.
// The node-engine reads the connecting process's account from the kernel and asks the controller over its own
// link; that request is the node-engine's and the controller's, and lives in neither this package nor any SDK
// (ADR 0039 as ADR 0188 reads it: the transport lives in the runtime).
//
// One request and one answer per connection, each one line of JSON. Standard library only.
package cli
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"time"
)
// Socket is where the node-engine listens for mesh-cli: a file under its runtime directory, with no network
// address (ADR 0272 §3).
const Socket = "/run/nox-mesh-host/cli.sock"
// MaxStdin bounds the standard input mesh-cli carries for a line: a secret, a key, a settings file — kilobytes. More
// is refused by mesh-cli before anything is asked, and by the node-engine and the controller again.
const MaxStdin = 64 << 10
// MaxRequest bounds what the node-engine reads of a request: a command line of a few hundred bytes, settings given
// inline of a few kilobytes, and standard input of at most MaxStdin, which JSON writes as base64 (4 bytes for every 3).
const MaxRequest = 64<<10 + MaxStdin*4/3 + 4
// MaxAnswer bounds what mesh-cli reads of an answer. The controller cuts what it runs to fit one bus message, and
// says so; this is the local bound above that.
const MaxAnswer = 16 << 20
// AskWithin is how long mesh-cli waits for its answer: the controller's own bound on a call (five minutes),
// and a minute for the link on either side.
const AskWithin = 6 * time.Minute
// Request is what mesh-cli asks: the controller's command line, as words, and what mesh-cli's own standard input
// held when it was not a terminal.
//
// **Stdin is for the controller's terminal alone** (novox/hq ADR 0259 §10, ADR 0272): a secret given with
// `secret accept … --from -`, so it never stands on a command line. The controller gives it to a line that runs as
// its terminal and refuses any other line that carries it; nobody keeps it — not the node-engine, not the
// controller's record of calls, not its journal.
type Request struct {
Line []string `json:"line"`
Stdin []byte `json:"stdin,omitempty"`
}
// Answer is what comes back: what the command printed, how it exited, and whether it ran as the controller's
// terminal and why. Refused is set when nothing ran at all, and says why; Exit is then 1.
type Answer struct {
Stdout []byte `json:"stdout,omitempty"`
Stderr []byte `json:"stderr,omitempty"`
Exit int `json:"exit"`
Terminal bool `json:"terminal"`
// Why says whether the line ran as the terminal, and why or why not, in the controller's words.
Why string `json:"why,omitempty"`
// Refused says why nothing ran: the account, the node, the controller unreachable.
Refused string `json:"refused,omitempty"`
// Cut says the output was longer than one bus message and was cut there.
Cut bool `json:"cut,omitempty"`
// Unknown says the line was asked and its outcome is not known: no answer came in time, and it may have
// run. Never said as a refusal, which means nothing ran.
Unknown string `json:"unknown,omitempty"`
}
// Refusal is an answer saying that nothing ran, and why.
func Refusal(why string) Answer { return Answer{Exit: 1, Refused: why} }
// NotKnown is an answer saying the line was asked and what came of it is not known: it may have run. call is
// the controller's call the line runs as, when it is known, which `calls` answers.
func NotKnown(call, why string) Answer {
where := "the controller's `calls` lists what came of each line"
if call != "" {
where = "see the call " + call + " (`calls " + call + "`)"
}
return Answer{Exit: 1, Unknown: why + "; the line may have run — " + where}
}
// ErrTooLong is a request or an answer over its bound.
var ErrTooLong = errors.New("longer than the protocol allows")
// WriteRequest writes one request, as one line.
func WriteRequest(w io.Writer, r Request) error { return writeLine(w, r) }
// ReadRequest reads one request, refusing one over MaxRequest or one that names no command.
func ReadRequest(r io.Reader) (Request, error) {
var req Request
if err := readLine(r, MaxRequest, &req); err != nil {
return Request{}, err
}
if len(req.Line) == 0 {
return Request{}, errors.New("the request names no command")
}
if len(req.Stdin) > MaxStdin {
return Request{}, fmt.Errorf("%w: standard input of more than %d bytes", ErrTooLong, MaxStdin)
}
return req, nil
}
// WriteAnswer writes one answer, as one line.
func WriteAnswer(w io.Writer, a Answer) error { return writeLine(w, a) }
// ReadAnswer reads one answer, refusing one over MaxAnswer.
func ReadAnswer(r io.Reader) (Answer, error) {
var a Answer
err := readLine(r, MaxAnswer, &a)
return a, err
}
// Ask is mesh-cli's side: connect to the socket, ask, and wait for the answer within AskWithin or ctx.
func Ask(ctx context.Context, socket string, line []string) (Answer, error) {
return AskWith(ctx, socket, Request{Line: line})
}
// AskWith asks a whole request: a line, and the standard input it carries, at most MaxStdin.
func AskWith(ctx context.Context, socket string, req Request) (Answer, error) {
line := req.Line
if len(line) == 0 {
return Answer{}, errors.New("no command given")
}
if len(req.Stdin) > MaxStdin {
return Answer{}, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", MaxStdin)
}
ctx, cancel := context.WithTimeout(ctx, AskWithin)
defer cancel()
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", socket)
if err != nil {
return Answer{}, fmt.Errorf("the node-engine on this machine does not answer at %s: %w", socket, err)
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if err := WriteRequest(conn, req); err != nil {
// The engine may have answered before it read the request — a refusal (busy, an unknown account) written
// and the connection closed — and the write then fails on the close. That answer is already waiting, and
// it says why where the write error says only that the pipe broke (novox/hq issue 444, found under issue
// 434). So read it, within the deadline the connection already has, and only when none came say the write.
// Only a refusal is taken: the request was never wholly written, so nothing can have run, and any other
// answer here is not one a correct engine gives — passing it on as success would hide the failure.
a, rerr := ReadAnswer(conn)
if rerr != nil {
return Answer{}, fmt.Errorf("asking the node-engine: %w; and no answer came (%v)", err, rerr)
}
if a.Refused == "" {
return Answer{}, fmt.Errorf("asking the node-engine: %w; and the answer that came is not a refusal, "+
"though the request was never wholly written", err)
}
return a, nil
}
a, err := ReadAnswer(conn)
if err != nil {
return Answer{}, fmt.Errorf("reading the node-engine's answer: %w", err)
}
return a, nil
}
func writeLine(w io.Writer, v any) error {
body, err := json.Marshal(v)
if err != nil {
return err
}
_, err = w.Write(append(body, '\n'))
return err
}
// readLine reads one line of JSON of at most limit bytes. A line over the limit is refused rather than read
// whole: the reader is the side that pays for it.
func readLine(r io.Reader, limit int, into any) error {
br := bufio.NewReaderSize(io.LimitReader(r, int64(limit)+1), 64<<10)
var body []byte
for {
chunk, err := br.ReadSlice('\n')
body = append(body, chunk...)
if len(body) > limit {
return fmt.Errorf("%w: more than %d bytes", ErrTooLong, limit)
}
if err == nil {
break
}
if errors.Is(err, bufio.ErrBufferFull) {
continue
}
if errors.Is(err, io.EOF) && len(body) > 0 {
break
}
return err
}
if err := json.Unmarshal(body, into); err != nil {
return fmt.Errorf("not one: %w", err)
}
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"path/filepath"
"strings"
"testing"
)
// A request and an answer survive a round trip, bytes and all.
func TestARequestAndAnAnswerSurviveTheirRoundTrip(t *testing.T) {
var b bytes.Buffer
line := []string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "a"}
if err := WriteRequest(&b, Request{Line: line}); err != nil {
t.Fatal(err)
}
got, err := ReadRequest(&b)
if err != nil {
t.Fatal(err)
}
if strings.Join(got.Line, "\x00") != strings.Join(line, "\x00") {
t.Fatalf("the line came back as %q", got.Line)
}
b.Reset()
sent := Answer{Stdout: []byte("ok\n\xff"), Stderr: []byte("said"), Exit: 3, Terminal: true, Why: "the terminal", Cut: true,
Unknown: "no answer; the line may have run — see the call call-1"}
if err := WriteAnswer(&b, sent); err != nil {
t.Fatal(err)
}
a, err := ReadAnswer(&b)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(a.Stdout, sent.Stdout) || string(a.Stderr) != "said" || a.Exit != 3 || !a.Terminal || a.Why != sent.Why || !a.Cut ||
a.Unknown != sent.Unknown {
t.Fatalf("the answer came back as %+v", a)
}
}
// A request over the bound is refused, not read whole; one naming no command is refused.
func TestARequestOverTheBoundOrNamingNothingIsRefused(t *testing.T) {
long := Request{Line: []string{strings.Repeat("x", MaxRequest)}}
big := Request{Line: []string{"secret"}, Stdin: bytes.Repeat([]byte("s"), MaxStdin+1)}
var bb bytes.Buffer
if err := WriteRequest(&bb, big); err != nil {
t.Fatal(err)
}
if _, err := ReadRequest(&bb); !errors.Is(err, ErrTooLong) {
t.Fatalf("standard input over its bound was read: %v", err)
}
if _, err := AskWith(context.Background(), "/nonexistent.sock", big); err == nil || !strings.Contains(err.Error(), "nothing was asked") {
t.Fatalf("mesh-cli would ask with standard input over its bound: %v", err)
}
var b bytes.Buffer
if err := WriteRequest(&b, long); err != nil {
t.Fatal(err)
}
if _, err := ReadRequest(&b); !errors.Is(err, ErrTooLong) {
t.Fatalf("a request over the bound was read: %v", err)
}
if _, err := ReadRequest(strings.NewReader(`{"line":[]}` + "\n")); err == nil {
t.Fatal("a request naming no command was taken")
}
if _, err := ReadRequest(strings.NewReader("not json\n")); err == nil {
t.Fatal("a request that is not one was taken")
}
}
// Ask speaks the protocol over a socket, as mesh-cli does to the node-engine.
func TestAskSpeaksTheProtocolOverASocket(t *testing.T) {
socket := filepath.Join(t.TempDir(), "cli.sock")
l, err := net.Listen("unix", socket)
if err != nil {
t.Skipf("no unix socket here: %v", err)
}
defer l.Close()
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
req, err := ReadRequest(conn)
if err != nil {
_ = WriteAnswer(conn, Refusal(err.Error()))
return
}
_ = WriteAnswer(conn, Answer{Stdout: []byte(strings.Join(req.Line, " "))})
}()
a, err := Ask(context.Background(), socket, []string{"status"})
if err != nil {
t.Fatal(err)
}
if string(a.Stdout) != "status" || a.Exit != 0 || a.Refused != "" {
t.Fatalf("asked status and heard %+v", a)
}
if _, err := Ask(context.Background(), filepath.Join(t.TempDir(), "none.sock"), []string{"status"}); err == nil ||
!strings.Contains(err.Error(), "does not answer") {
t.Fatalf("a missing socket was not said: %v", err)
}
}
// An outcome not known is never a refusal: it says the line may have run, and exits non-zero.
func TestAnUnknownOutcomeIsNotARefusal(t *testing.T) {
a := NotKnown("call-7", "no answer within 6m0s")
if a.Refused != "" || a.Exit == 0 || !strings.Contains(a.Unknown, "may have run") || !strings.Contains(a.Unknown, "call-7") {
t.Fatalf("an unknown outcome reads as %+v", a)
}
if b := NotKnown("", "no answer"); strings.Contains(b.Unknown, "call ") {
t.Fatalf("an unknown outcome with no call names one: %+v", b)
}
}
// Standard input up to its bound travels whole, beside the line: a secret given with --from -.
func TestStandardInputTravelsWithItsLine(t *testing.T) {
given := append(bytes.Repeat([]byte{0, 0xff, '\n', 'x'}, MaxStdin/4-1), "end"...)
var b bytes.Buffer
if err := WriteRequest(&b, Request{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
Stdin: given}); err != nil {
t.Fatal(err)
}
if b.Len() > MaxRequest {
t.Fatalf("a request with standard input at its bound is %d bytes, over MaxRequest %d", b.Len(), MaxRequest)
}
got, err := ReadRequest(&b)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got.Stdin, given) || got.Line[1] != "accept" {
t.Fatalf("standard input did not travel whole: %d bytes, want %d", len(got.Stdin), len(given))
}
none, _ := json.Marshal(Request{Line: []string{"status"}})
if strings.Contains(string(none), "stdin") {
t.Fatalf("a request without standard input says it has one: %s", none)
}
}
+123
View File
@@ -0,0 +1,123 @@
package cli
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// anEngineThatClosesFirst listens like the node-engine before mesh-host#80: it writes its answer, if any, and
// closes without reading the request (novox/hq issue 434). It answers one connection.
func anEngineThatClosesFirst(t *testing.T, answer *Answer) string {
t.Helper()
socket := filepath.Join(t.TempDir(), "cli.sock")
l, err := net.Listen("unix", socket)
if err != nil {
t.Skipf("no unix socket here: %v", err)
}
t.Cleanup(func() { l.Close() })
go func() {
conn, err := l.Accept()
if err != nil {
return
}
if answer != nil {
_ = WriteAnswer(conn, *answer)
}
conn.Close()
}()
return socket
}
// aLineTheSocketCannotHold is a request larger than a unix socket buffers, so its write cannot finish before
// the engine reads it — and an engine that closes first makes it fail every time, not only when it loses a race.
func aLineTheSocketCannotHold() []string { return []string{"status", strings.Repeat("x", 4<<20)} }
// An engine that refuses and closes before reading the request is heard: its refusal reaches the caller, not
// the broken pipe its close left the client's write (novox/hq issue 444).
func TestARefusalWrittenBeforeTheRequestIsReadReachesTheCaller(t *testing.T) {
busy := Refusal("the node-engine is busy: too many lines at once")
socket := anEngineThatClosesFirst(t, &busy)
a, err := Ask(context.Background(), socket, aLineTheSocketCannotHold())
if err != nil {
t.Fatalf("the engine said %q and the caller heard: %v", busy.Refused, err)
}
if a.Refused != busy.Refused || a.Exit != 1 {
t.Fatalf("the engine said %q and the caller heard %+v", busy.Refused, a)
}
}
// When the write fails and no answer is waiting, the error says both: what failed, and that no answer came.
func TestAFailedWriteWithNoAnswerSaysBoth(t *testing.T) {
socket := anEngineThatClosesFirst(t, nil)
start := time.Now()
_, err := Ask(context.Background(), socket, aLineTheSocketCannotHold())
if err == nil {
t.Fatal("a request the engine never read, and never answered, came back without an error")
}
if !strings.Contains(err.Error(), "asking the node-engine") || !strings.Contains(err.Error(), "no answer came") {
t.Fatalf("the error does not say what failed and that no answer came: %v", err)
}
if waited := time.Since(start); waited > 30*time.Second {
t.Fatalf("waited %s on a connection the engine had closed", waited)
}
}
// After a failed write only a refusal is taken: nothing can have run, so an answer saying it ran and exited 0
// is an error, never a success (novox/hq issue 444).
func TestAnAnswerOtherThanARefusalAfterAFailedWriteIsAnError(t *testing.T) {
ran := Answer{Exit: 0, Stdout: []byte("ok")}
socket := anEngineThatClosesFirst(t, &ran)
a, err := Ask(context.Background(), socket, aLineTheSocketCannotHold())
if err == nil {
t.Fatalf("a request never wholly written came back as success: %+v", a)
}
if !strings.Contains(err.Error(), "not a refusal") {
t.Fatalf("the error does not say the answer was not a refusal: %v", err)
}
}
// Reading after a failed write never waits longer than the ask already allowed: a peer that never reads and
// never answers is given up on at the context's deadline, not later (novox/hq issue 444).
func TestAFailedWriteWaitsNoLongerThanTheDeadline(t *testing.T) {
socket := filepath.Join(t.TempDir(), "cli.sock")
l, err := net.Listen("unix", socket)
if err != nil {
t.Skipf("no unix socket here: %v", err)
}
t.Cleanup(func() { l.Close() })
held := make(chan net.Conn, 1)
go func() {
conn, err := l.Accept()
if err != nil {
return
}
held <- conn // neither read nor answered, nor closed, until the test ends
}()
t.Cleanup(func() {
select {
case c := <-held:
c.Close()
default:
}
})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err = Ask(ctx, socket, aLineTheSocketCannotHold())
waited := time.Since(start)
if err == nil {
t.Fatal("a peer that never read nor answered gave no error")
}
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("the error is not the deadline: %v", err)
}
if waited > time.Second {
t.Fatalf("waited %s with a deadline of 50ms", waited)
}
}
+210
View File
@@ -0,0 +1,210 @@
// mesh-hand-compose composes one module for one node by hand, from the inputs the controller keeps, and signs it with the
// operator's key, for the hand path of novox/hq ADR 0539 clause 10: a fingerprint the controller never composed (a
// hand build), while the controller cannot compose its own fix. It composes with mesh-sdk's composition, the one the
// controller writes with (compose.Compositions), and prints the composition, its key and its digest; only with -write
// does it write them, with the operator's credential: the composition into the node's bucket composed-<node>, each
// file over 64 KiB into the node's object bucket, and the digest into mesh-controller_composed. The want is the
// operator's next act, naming that digest; each act is recorded in the hand-act log.
//
// mesh-hand-compose -input <inputs.json> -node <node> -module <module> -fp <src2:...> -key <operator key>
// [-write -nats <url> -creds <operator credential>]
//
// The inputs are a compose.Input as JSON. The key is the operator's ed25519 private key, PKCS#8 in PEM; the
// node-engine trusts it beside the controller's (ADR 0539 clause 3).
package main
import (
"context"
"crypto/ed25519"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"os"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/kvlimit"
)
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "mesh-hand-compose:", err)
os.Exit(1)
}
}
// printed is what mesh-hand-compose prints: where the composition goes, its digest, the composition, and whether it was
// written.
type printed struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Digest string `json:"digest"`
Composition json.RawMessage `json:"composition"`
Objects []string `json:"objects,omitempty"`
Written bool `json:"written"`
Next string `json:"next"`
}
func run(args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("mesh-hand-compose", flag.ContinueOnError)
input := fs.String("input", "", "the controller's kept inputs, a compose.Input as JSON")
node := fs.String("node", "", "the node to compose for")
module := fs.String("module", "", "the module to compose")
fp := fs.String("fp", "", "the module's fingerprint, src2: and 64 hex digits")
keyFile := fs.String("key", "", "the operator's ed25519 private key, PKCS#8 in PEM")
write := fs.Bool("write", false, "write the composition, its files and its digest, with the operator's credential")
url := fs.String("nats", nats.DefaultURL, "with -write: the bus")
creds := fs.String("creds", "", "with -write: the operator's credential file")
if err := fs.Parse(args); err != nil {
return err
}
switch {
case *input == "" || *node == "" || *module == "" || *fp == "":
return errors.New("-input, -node, -module and -fp are each required")
case *keyFile == "":
return errors.New("-key is required: a composition made by hand is signed by the operator's key, and the node-engine applies no other")
case *write && *creds == "":
return errors.New("-write needs -creds: the operator's credential is the only hand writer of a composition")
}
key, err := readKey(*keyFile)
if err != nil {
return err
}
key2, err := delivery.ComposedKey(*node, *module, *fp)
if err != nil {
return err
}
bucket, err := delivery.ComposedBucketFor(*node)
if err != nil {
return err
}
raw, err := os.ReadFile(*input)
if err != nil {
return err
}
var in compose.Input
if err := json.Unmarshal(raw, &in); err != nil {
return fmt.Errorf("the inputs in %s are not a compose.Input: %w", *input, err)
}
all, _, err := compose.Compositions(in, *node, map[string]string{*module: *fp}, compose.Reading)
if err != nil {
return err
}
c, ok := all[key2]
if !ok {
return fmt.Errorf("%s does not run %s: its composition holds none of it", *node, *module)
}
c, objects, err := c.ByDigest()
if err != nil {
return err
}
if c, err = delivery.SignComposed(c, key); err != nil {
return err
}
encoded, err := delivery.EncodeComposed(c)
if err != nil {
return err
}
digest, err := delivery.ComposedDigestOf(c)
if err != nil {
return err
}
out := printed{Bucket: bucket, Key: key2, Digest: digest, Composition: encoded,
Next: fmt.Sprintf("write want.%s.%s naming fp %s and composed %s with the operator's credential, and record each act in the hand-act log", *node, *module, *fp, digest)}
for d := range objects {
out.Objects = append(out.Objects, d)
}
if *write {
if err := writeIt(*url, *creds, *node, bucket, key2, encoded, digest, objects); err != nil {
return err
}
out.Written = true
}
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func readKey(path string) (ed25519.PrivateKey, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(raw)
if block == nil {
return nil, fmt.Errorf("%s holds no PEM block", path)
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok {
return nil, fmt.Errorf("%s is not an ed25519 key", path)
}
return key, nil
}
// writeIt writes the files by digest, then the composition, then its digest: a reader that finds the digest finds the
// composition and its files already there.
func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string, objects map[string][]byte) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
nc, err := nats.Connect(url, nats.UserCredentials(creds), nats.Name("mesh-hand-compose"))
if err != nil {
return err
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
return err
}
if len(objects) > 0 {
filesBucket, err := declaration.FileBucketFor(node)
if err != nil {
return err
}
store, err := js.ObjectStore(ctx, filesBucket)
if err != nil {
return fmt.Errorf("the node's object bucket %s: %w", filesBucket, err)
}
for d, body := range objects {
name, err := declaration.ObjectName(d)
if err != nil {
return err
}
if _, err := store.PutBytes(ctx, name, body); err != nil {
return fmt.Errorf("the file %s: %w", d, err)
}
}
}
composed, err := js.KeyValue(ctx, bucket)
if err != nil {
return fmt.Errorf("the bucket %s: %w", bucket, err)
}
if _, err := kvlimit.Put(ctx, composed, key, encoded); err != nil {
return fmt.Errorf("%s in %s: %w", key, bucket, err)
}
value, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot,
Words: "composed by hand with mesh-hand-compose (ADR 0539 clause 10): the operator writes the want"})
if err != nil {
return err
}
digests, err := js.KeyValue(ctx, delivery.ControllerComposedBucket)
if err != nil {
return fmt.Errorf("the bucket %s: %w", delivery.ControllerComposedBucket, err)
}
if _, err := kvlimit.Put(ctx, digests, key, value); err != nil {
return fmt.Errorf("%s in %s: %w", key, delivery.ControllerComposedBucket, err)
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
// aMesh is the Input of mesh-controller's aMesh, as mesh-sdk's compose tests make it (compose/fixture_test.go): two
// machines on the private network, the module the mesh provides for itself assigned to both. The manifest is the one
// the controller provides (overlay.Manifest), read back as the catalogue reads it.
func aMesh(t *testing.T) compose.Input {
t.Helper()
var m catalogue.Manifest
b, _ := json.Marshal(overlay.Manifest())
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
in := compose.Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: m}}
for i, name := range []string{"anchor", "laptop"} {
pub, _, _ := ed25519.GenerateKey(rand.Reader)
in.Nodes = append(in.Nodes, compose.Node{Name: name,
Place: compose.Place{Key: base64.StdEncoding.EncodeToString(pub), Endpoint: name + ".example:51820", Site: "here", Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]},
Assigned: []string{overlay.Name}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
}
return in
}
func writeKey(t *testing.T, dir string) ed25519.PublicKey {
t.Helper()
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "operator.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
t.Fatal(err)
}
return pub
}
// 1943/AC5: mesh-hand-compose, given the kept inputs, a node, a module and its fingerprint, prints that module's
// composition for that node signed by the operator's key and its digest: its resources are the controller's for that
// module on that node (compose.Compose), and it verifies against the operator's key. Without --write it writes nothing.
func TestMeshComposeGivesTheControllersResourcesSignedByTheOperator(t *testing.T) {
dir := t.TempDir()
in := aMesh(t)
raw, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
input := filepath.Join(dir, "input.json")
if err := os.WriteFile(input, raw, 0o600); err != nil {
t.Fatal(err)
}
operator := writeKey(t, dir)
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
var out bytes.Buffer
if err := run([]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp,
"-key", filepath.Join(dir, "operator.key")}, &out); err != nil {
t.Fatal(err)
}
var printed struct {
Key string `json:"key"`
Bucket string `json:"bucket"`
Digest string `json:"digest"`
Composition json.RawMessage `json:"composition"`
Written bool `json:"written"`
}
if err := json.Unmarshal(out.Bytes(), &printed); err != nil {
t.Fatalf("%s: %v", out.String(), err)
}
c, err := delivery.DecodeComposed(printed.Composition)
if err != nil {
t.Fatal(err)
}
if digest, err := delivery.VerifyComposed(c, []ed25519.PublicKey{operator}); err != nil || digest != printed.Digest {
t.Errorf("the composition verifies %q, %v against the operator's key; printed %s", digest, err, printed.Digest)
}
wantKey, _ := delivery.ComposedKey("laptop", overlay.Name, fp)
if printed.Key != wantKey || printed.Bucket != "composed-laptop" || printed.Written {
t.Errorf("printed key %s in %s, written %v", printed.Key, printed.Bucket, printed.Written)
}
d, _, err := compose.Compose(in, "laptop")
if err != nil {
t.Fatal(err)
}
var want, got []string
for _, r := range d.Resources {
want = append(want, r["id"].(string))
}
for _, r := range c.Resources {
var head struct{ ID string }
_ = json.Unmarshal(r, &head)
got = append(got, head.ID)
}
sort.Strings(want)
sort.Strings(got)
if len(got) == 0 || strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("mesh-hand-compose gives %v, the controller's composition %v", got, want)
}
}
// 1943/AC5: mesh-hand-compose refuses what it cannot compose, naming why: no key, a fingerprint not of the new form, a
// module the node does not run, a node the mesh does not have.
func TestMeshComposeRefuses(t *testing.T) {
dir := t.TempDir()
raw, _ := json.Marshal(aMesh(t))
input := filepath.Join(dir, "input.json")
_ = os.WriteFile(input, raw, 0o600)
writeKey(t, dir)
key := filepath.Join(dir, "operator.key")
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
for _, c := range []struct {
args []string
why string
}{
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp}, "-key"},
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", "src2:1", "-key", key}, "fingerprint"},
{[]string{"-input", input, "-node", "laptop", "-module", "mesh-mail", "-fp", fp, "-key", key}, "does not run mesh-mail"},
{[]string{"-input", input, "-node", "nowhere", "-module", overlay.Name, "-fp", fp, "-key", key}, "nowhere"},
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp, "-key", key, "-write"}, "-creds"},
} {
var out bytes.Buffer
if err := run(c.args, &out); err == nil || !strings.Contains(err.Error(), c.why) {
t.Errorf("mesh-hand-compose %v = %v, want a refusal saying %q", c.args, err, c.why)
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
// (mesh-hand-compose) gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not
+1 -18
View File
@@ -105,12 +105,7 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl
}
}
sealed := s.ForProvider
switch {
case credentiallessRow(plan, s, node):
// Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and
// nothing stands in for it, so neither end waits for a value nobody makes (issue 1126).
sealed = ""
case sealed == "":
if sealed == "" {
sealed = catalogue.ForeseenSealed
foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer))
}
@@ -121,18 +116,6 @@ func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overfl
return out, withheld, unbound, foreseen, nil
}
// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault
// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126).
func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone }
// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none.
func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool {
return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool {
return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider &&
credentialless(n)
})
}
// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them
// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send
// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not
+2 -7
View File
@@ -19,8 +19,7 @@ type CredentialKey struct {
// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's,
// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the
// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the
// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is
// in neither list: the vault mints nothing for it (issue 1126).
// node's own apply refuses (CompositionsApplied).
func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) {
c := newComposer(in)
resolved, err := c.resolution(node)
@@ -28,7 +27,7 @@ func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []Credenti
return nil, nil, err
}
for _, n := range resolved.Needs {
if n.ByRecord || n.From == "" || credentialless(n) {
if n.ByRecord || n.From == "" {
continue
}
asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For,
@@ -39,10 +38,6 @@ func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []Credenti
return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err)
}
for _, r := range rows {
// A grant whose offer presents no credential (form none) has none to ask for (issue 1126).
if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) {
continue
}
asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer,
ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local})
}
+22 -66
View File
@@ -384,74 +384,30 @@ func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) {
}
}
// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential
// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for
// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no
// credential, nothing stands in on either side, and neither end asks the vault for one.
func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) {
for _, tc := range []struct {
form string
cred *catalogue.OfferCredential
}{
{"undeclared", nil},
{catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}},
{catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}},
{catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}},
} {
in := aGrantMesh(t)
in.Issued = nil
provider := in.Catalogue["objects"]
provider.Provides[0].Credential = tc.cred
if tc.cred != nil && tc.cred.Own != "" {
provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}}
}
in.Catalogue["objects"] = provider
none := tc.form == catalogue.CredentialFormNone
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Rendering.Grants) != 1 {
t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants)
}
g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed
if none {
wantSealed = ""
}
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed {
t.Errorf("%s: the grant is %+v", tc.form, g)
}
if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none {
t.Errorf("%s: foreseen %v", tc.form, got.Foreseen)
}
consumer, asProvider, err := CredentialsNeeded(in, "laptop")
_, anchorProvides, err2 := CredentialsNeeded(in, "anchor")
if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none {
t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2)
}
resolved, err := Resolve(in, "laptop")
if err != nil {
t.Fatalf("%s: %v", tc.form, err)
}
if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none {
t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen)
}
if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 {
t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen)
}
// Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
t.Errorf("%s: the consumer is granted its own credential", tc.form)
}
if len(in.Issued) != 0 {
t.Errorf("%s: a row was added to the Input", tc.form)
}
// A row made long ago for an offer of form none (the controller's) carries no credential either.
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") {
t.Errorf("%s: an old row's grant is %+v", tc.form, g)
}
}
// A credential only a person gives and nobody has is no grant.
// Round 1, finding 2: a consumer the same send adds has no credential in the Input yet, and the provider's declaration of that send
// carries its grant, composed with a stand-in and named foreseen.
func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInAndNamedForeseen(t *testing.T) {
in := aGrantMesh(t)
in.Issued = nil
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Rendering.Grants) != 1 {
t.Fatalf("grants are %+v", got.Rendering.Grants)
}
g := got.Rendering.Grants[0]
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != catalogue.ForeseenSealed {
t.Errorf("the grant is %+v", g)
}
if !has(got.Foreseen, "the grant of s3-bucket for files on laptop") {
t.Errorf("foreseen %v", got.Foreseen)
}
// Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
t.Error("the consumer is granted its own credential")
}
if len(in.Issued) != 0 {
t.Error("a row was added to the Input")
}
// A credential only a person gives and nobody has is no grant.
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}}
if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 {
t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants)
+39 -110
View File
@@ -223,56 +223,9 @@ func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest,
// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the
// read: that is the bus's, not the module's.
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
b, err := readBase(ctx, r, mesh, s)
b.refuseOwn(refuse)
if err != nil {
return compose.Input{}, err
}
return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
}
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
type base struct {
in compose.Input
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
modules map[string]delivery.Module
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
// read: every scope holds it.
ownRefused [][2]string
}
// refuseOwn hands refuse each module placed on the composing node whose manifest is refused.
func (b base) refuseOwn(refuse func(module string, err error)) {
if refuse == nil {
return
}
for _, r := range b.ownRefused {
refuse(r[0], errors.New(r[1]))
}
}
// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over
// one base never see each other's builds.
func (b base) cloned() compose.Input {
in := b.in
in.Catalogue = maps.Clone(b.in.Catalogue)
in.Nodes = slices.Clone(b.in.Nodes)
for i := range in.Nodes {
in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept)
}
in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes)
in.OwnSecrets = slices.Clone(b.in.OwnSecrets)
return in
}
// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
var b base
placements, err := r.Placements(ctx)
if err != nil {
return b, fmt.Errorf("frombus: placement: %w", err)
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
}
if len(s.Placements) > 0 {
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
@@ -285,7 +238,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
continue
}
if p.Node != node {
return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
}
given[node] = *p
}
@@ -293,14 +246,14 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
}
records, err := r.Records(ctx)
if err != nil {
return b, fmt.Errorf("frombus: node records: %w", err)
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
}
modules, err := r.Modules(ctx)
if err != nil {
return b, fmt.Errorf("frombus: the indexer's modules: %w", err)
return compose.Input{}, fmt.Errorf("frombus: the indexer's modules: %w", err)
}
if _, ok := placements[s.Node]; !ok && !s.Every {
return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
return compose.Input{}, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
}
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
@@ -326,6 +279,18 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
}
}
parse := parser(mesh)
read := func(module, digest string) (catalogue.Manifest, error) {
raw, err := r.Manifest(ctx, digest)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
}
man, err := parse(raw)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
}
return man, nil
}
named := func(field *map[string]string, module, why string) {
if *field == nil {
*field = map[string]string{}
@@ -344,7 +309,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
raw, err := r.Manifest(ctx, m.Manifest)
if err != nil {
// Not read is no refusal of the module: a bus that does not answer is waited out by the caller.
return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
}
// One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read
// follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused
@@ -353,8 +318,8 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
man, why := taken(name, raw, m)
if why != "" {
named(&in.Refused, name, why)
if ownPlaced[name] {
b.ownRefused = append(b.ownRefused, [2]string{name, why})
if refuse != nil && ownPlaced[name] {
refuse(name, errors.New(why))
}
} else if aside := man.SetAside(); aside != "" {
named(&in.SetAside, name, aside)
@@ -367,11 +332,11 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
for _, name := range delivery.ProvidedModules() {
raw, err := json.Marshal(overlay.Manifest())
if err != nil {
return b, err
return compose.Input{}, err
}
var private catalogue.Manifest
if err := json.Unmarshal(raw, &private); err != nil {
return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
return compose.Input{}, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
}
in.Catalogue[name] = private
}
@@ -392,13 +357,13 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
var values map[string]any
if withValues {
if values, err = r.Values(ctx, node, m.Module); err != nil {
return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
}
} else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 {
// What a provider elsewhere serves from its settings is told to every consumer: the composing node reads
// those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks).
if values, err = r.Served(ctx, node, m.Module, keys); err != nil {
return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
return compose.Input{}, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
strings.Join(keys, ", "), err)
}
}
@@ -471,35 +436,14 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
}
if len(missing) > 0 {
return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
return compose.Input{}, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
strings.Join(missing, ", "))
}
b.in, b.placements, b.records, b.modules = in, placements, records, modules
return b, nil
}
// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
var err error
parse := parser(mesh)
read := func(module, digest string) (catalogue.Manifest, error) {
raw, err := r.Manifest(ctx, digest)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
}
man, err := parse(raw)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
}
return man, nil
}
if !s.Every {
if _, ok := b.records[s.Node]; !ok {
if _, ok := records[s.Node]; !ok {
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
}
if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse, wanted); err != nil {
return compose.Input{}, err
}
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
@@ -659,8 +603,15 @@ func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
// One composition for the three, so each node of the mesh is resolved once (issue 1954).
resolved, rendered, gens, err := compose.Composing(in, node, mode)
resolved, err := compose.Resolve(in, node)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
rendered, err := compose.Render(in, node, mode)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
gens, err := compose.Generators(in)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
@@ -722,8 +673,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
// (issue 1126).
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
refuse func(module string, err error), wanted map[string]string) error {
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
@@ -766,24 +716,6 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
if b.Manifest == "" {
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
}
// A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest.
keeper, keeps := r.(takenInKeeper)
if keeps && !addressed {
address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
addressed = true
}
// Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did.
key, keyed := takenInKey(module, fp, address, *b)
keyed = keyed && addressErr == nil && address != ""
if keeps && keyed {
if raw, ok := keeper.takenIn(key); ok {
resolved, err := parse(raw)
if err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
}
return resolved, nil
}
}
src, err := read(module, b.Manifest)
if err != nil {
return catalogue.Manifest{}, err
@@ -801,9 +733,6 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
if resolved, err = parse(raw); err != nil {
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
}
if keeps && keyed {
keeper.keepTakenIn(key, raw)
}
return resolved, nil
}
// A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one
@@ -837,7 +766,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
continue // nothing is built of it: its manifest is what it composes from
}
b, err := build(ctx, pm.Module, m.FP2)
b, err := r.Build(ctx, pm.Module, m.FP2)
if err != nil {
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
if refuse == nil {
@@ -872,7 +801,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
}
for _, module := range sortedKeys(keptAt) {
fp := keptAt[module]
b, err := build(ctx, module, fp)
b, err := r.Build(ctx, module, fp)
switch {
case err != nil:
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
@@ -2,12 +2,10 @@ package frombustest
import (
"context"
"reflect"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -119,18 +117,6 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
}
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
// and where the time went is said by phase.
keptAt := FPs()
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
}
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
t.Errorf("%s: the phases are said as %q", row.name, said)
}
if row.bad == "" || row.elsewhere {
if len(held) > 0 {
t.Errorf("%s: held %v", row.name, held)
@@ -181,85 +167,6 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
if fetched != 1 {
t.Errorf("a templates archive was fetched %d times, want once", fetched)
}
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
// and the plans are the same.
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
fetched++
_, digest, _ := strings.Cut(url, "/blobs/")
return b.Archives[digest], nil
}), fetched
var first map[string]catalogue.Plan
for i := range 2 {
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
t.Fatalf("composed %v (held %v): %v", plans, held, err)
}
first = plans
}
if fetched-archives != 1 {
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
}
}
// statesOf is the bus b as a module's read states hold it.
func statesOf(t *testing.T, b *Bus) frombus.States {
t.Helper()
s := states{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if s[state] == nil {
s[state] = map[string][]byte{}
}
s[state][key] = raw
}
for node, p := range b.Placement {
raw, err := delivery.EncodePlacement(p)
put(frombus.PlacementRead, "placement."+node, raw, err)
}
for node, rec := range b.Record {
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(frombus.HaveRead, node+".node", raw, err)
}
for module, m := range b.Module {
key, _ := delivery.ModulesKey(module)
raw, err := delivery.EncodeModule(m)
put(frombus.ModulesRead, key, raw, err)
}
for digest, man := range b.Manifests {
key, _ := delivery.ManifestsKey(digest)
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
put(frombus.ManifestsRead, key, raw, err)
}
for at, build := range b.Builds {
module, fp, _ := strings.Cut(at, " ")
key, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(frombus.BuildsRead, key, raw, err)
}
return s
}
// states are read states in memory.
type states map[string]map[string][]byte
func (s states) Get(state, key string) ([]byte, bool, error) {
v, ok := s[state][key]
return v, ok, nil
}
func (s states) Keys(state string) ([]string, error) {
var keys []string
for k := range s[state] {
keys = append(keys, k)
}
return keys, nil
}
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
+1 -10
View File
@@ -43,22 +43,13 @@ type States interface {
type Fetch func(ctx context.Context, url string) ([]byte, error)
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
// takes in only a build it has not seen.
func StatesReader(s States, fetch Fetch) Reader {
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
}
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
type statesReader struct {
s States
fetch Fetch
kept *keptTakeIns
}
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
keys, err := r.s.Keys(read)
if err != nil {
-147
View File
@@ -1,147 +0,0 @@
package frombus
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
type HeldPlans struct {
Plans map[string]catalogue.Plan
Held map[string]string
Err error
}
// Phase is one step of a composition and how long it took.
type Phase struct {
Name string `json:"phase"`
Took time.Duration `json:"took"`
}
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
// took names each phase in order: the read, then each scope's builds and its plans.
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
start := time.Now()
mark := func(name string) {
now := time.Now()
took = append(took, Phase{Name: name, Took: now.Sub(start)})
start = now
}
b, err := readBase(ctx, r, mesh, s)
mark("read")
builds := onceBuilds(r)
newestScope := s
newestScope.KeptAt = nil
for _, sc := range []struct {
scope Scope
into *HeldPlans
name string
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
held := map[string]string{}
sc.into.Held = held
refuse := func(module string, err error) { held[module] = err.Error() }
b.refuseOwn(refuse)
if err != nil {
sc.into.Err = err
continue
}
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
mark("builds at " + sc.name)
if err != nil {
sc.into.Err = err
continue
}
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
mark("plans at " + sc.name)
}
return at, newest, took
}
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
// builds of the same moment.
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
type answer struct {
b *delivery.BuilderBuild
err error
}
read := map[string]answer{}
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key := module + " " + fp
if a, ok := read[key]; ok {
return a.b, a.err
}
b, err := r.Build(ctx, module, fp)
read[key] = answer{b, err}
return b, err
}
}
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
type takenInKeeper interface {
takenIn(key string) ([]byte, bool)
keepTakenIn(key string, raw []byte)
}
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
raw, err := json.Marshal(b)
if err != nil {
return "", false
}
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
return hex.EncodeToString(sum[:]), true
}
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
const takenInAtMost = 512
// keptTakeIns is a reader's kept take-ins.
type keptTakeIns struct {
mu sync.Mutex
kept map[string][]byte
}
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
k.mu.Lock()
defer k.mu.Unlock()
raw, ok := k.kept[key]
return raw, ok
}
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
k.mu.Lock()
defer k.mu.Unlock()
if k.kept == nil || len(k.kept) >= takenInAtMost {
k.kept = map[string][]byte{}
}
k.kept[key] = raw
}
// PhasesSaid says the phases in one line, each in milliseconds.
func PhasesSaid(took []Phase) string {
out := ""
for i, p := range took {
if i > 0 {
out += ", "
}
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
}
return out
}
-25
View File
@@ -27,23 +27,6 @@ type composer struct {
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
unhostable []string
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
// on the private network, and the rest of the mesh as each node sees it.
onNetwork []Node
onNetworkSet bool
rest map[string]restOf
plans map[string]planOf
}
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
type restOf struct {
world catalogue.World
passes Passes
left []LeftOut
unhostable []string
err error
}
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
@@ -266,14 +249,6 @@ func (c *composer) whereEveryoneIs() (map[string]string, error) {
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
// counted, and said.
func (c *composer) onTheNetwork() []Node {
if !c.onNetworkSet {
c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
}
return slices.Clone(c.onNetwork)
}
// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
func (c *composer) onTheNetworkNow() []Node {
var out []Node
for _, n := range c.in.sorted() {
if n.Place.Address == "" {
-20
View File
@@ -35,26 +35,6 @@ func Render(in Input, node string, mode Mode) (Rendered, error) {
return newComposer(in).render(node, mode)
}
// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh
// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each
// answers as it does alone; the first refusal ends it, in that order, with what follows it empty.
func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) {
c := newComposer(in)
resolved, err := c.planFor(node)
if err != nil {
return Resolved{}, Rendered{}, nil, err
}
rendered, err := c.render(node, mode)
if err != nil {
return resolved, Rendered{}, nil, err
}
gens, err := c.generators()
if err != nil {
return resolved, rendered, nil, err
}
return resolved, rendered, gens, nil
}
func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
resolved, err := c.planFor(nodeName)
if err != nil {
+1 -43
View File
@@ -36,46 +36,8 @@ func Resolve(in Input, node string) (Resolved, error) {
// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer.
func IsUnresolvable(err error) bool { return unresolvable(err) }
// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its
// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read.
// planFor is the controller's planFor.
func (c *composer) planFor(nodeName string) (Resolved, error) {
p, ok := c.plans[nodeName]
if !ok {
p.resolved, p.err = c.planForNow(nodeName)
if c.plans == nil {
c.plans = map[string]planOf{}
}
c.plans[nodeName] = p
}
if p.err != nil {
return Resolved{}, p.err
}
return p.resolved.copied(), nil
}
// planOf is planFor's answer for one node, kept.
type planOf struct {
resolved Resolved
err error
}
// copied is r with its own needs, words and settings lists: what a caller may change of it.
func (r Resolved) copied() Resolved {
out := r
out.Plan.Needs = slices.Clone(r.Plan.Needs)
out.Foreseen = slices.Clone(r.Foreseen)
out.NotGiven = slices.Clone(r.NotGiven)
if r.Settings != nil {
out.Settings = make(catalogue.SettingsBy, len(r.Settings))
for m, layers := range r.Settings {
out.Settings[m] = slices.Clone(layers)
}
}
return out
}
// planForNow is planFor worked out.
func (c *composer) planForNow(nodeName string) (Resolved, error) {
resolved, err := c.resolution(nodeName)
if err != nil {
return Resolved{}, err
@@ -118,10 +80,6 @@ func (c *composer) planForNow(nodeName string) (Resolved, error) {
}
continue
}
if credentialless(n) {
// Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126).
continue
}
cred, held := c.credential(n, nodeName)
_, outside := issuedOutsideBy(shelf, n)
switch {
-17
View File
@@ -3,7 +3,6 @@ package compose
import (
"errors"
"fmt"
"slices"
"sort"
"strings"
@@ -60,22 +59,6 @@ func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []Left
}
func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) {
r, ok := c.rest[exclude]
if !ok {
r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude)
r.unhostable = c.unhostable
if c.rest == nil {
c.rest = map[string]restOf{}
}
c.rest[exclude] = r
}
// Each caller has its own copy of what it may set or add to; the maps and the slices within are only read.
c.unhostable = slices.Clone(r.unhostable)
return r.world, r.passes, slices.Clone(r.left), r.err
}
// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice.
func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) {
shelf := c.in.Catalogue
holdings := append([]catalogue.Held(nil), c.in.Holdings...)
nodes := c.in.sorted()
+1 -1
View File
@@ -140,7 +140,7 @@ const (
RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3)
RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary
RuleGrant = 6 // the module's credential for F exists (grants.<module>.<fp>)
RuleComposed = 7 // the module's composition for that node at F exists (ADR 0539 §6)
RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6)
// RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's
// words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres".
RuleProvisions = 8
+34 -15
View File
@@ -268,39 +268,58 @@ func TestCompositionVerdictRefused(t *testing.T) {
// 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an
// empty account is refused.
func TestTheGrantsIssued(t *testing.T) {
func TestControllerGrant(t *testing.T) {
fp := fp2of('c')
key, err := PartGrantKey("mesh-delivery-assigner", fp)
key, err := ControllerGrantKey("mesh-delivery-assigner", fp)
if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) {
t.Fatalf("PartGrantKey = %q, %v", key, err)
t.Fatalf("ControllerGrantKey = %q, %v", key, err)
}
module, back, err := ParsePartGrantKey(key)
module, back, err := ParseControllerGrantKey(key)
if err != nil || module != "mesh-delivery-assigner" || back != fp {
t.Errorf("ParsePartGrantKey = %q, %q, %v", module, back, err)
t.Errorf("ParseControllerGrantKey = %q, %q, %v", module, back, err)
}
held, _ := HeldKey("mesh-mail", fp)
for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} {
if _, _, err := ParsePartGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("ParsePartGrantKey(%q) = %v, want a refusal naming it", bad, err)
if _, _, err := ParseControllerGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("ParseControllerGrantKey(%q) = %v, want a refusal naming it", bad, err)
}
}
for _, g := range []Grant{{}, {Accounts: []string{"UABC", "UDEF"}}} {
data, err := EncodeGrant(g)
for _, g := range []ControllerGrant{{}, {Accounts: []string{"UABC", "UDEF"}}} {
data, err := EncodeControllerGrant(g)
if err != nil {
t.Fatal(err)
}
got, err := DecodeGrant(data)
got, err := DecodeControllerGrant(data)
g.V = Version
if err != nil || !SameGrant(got, g) {
if err != nil || !SameControllerGrant(got, g) {
t.Errorf("grant %+v came back %+v, %v", g, got, err)
}
}
if _, err := EncodeGrant(Grant{Accounts: []string{""}}); err == nil {
if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil {
t.Errorf("a grant naming an empty account was accepted")
}
g := Grant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
// The grants part's keys are the controller's: the same key round-trips; the source is the part's bucket only for a
// handed-away lease (issue 774).
if pk, err := PartGrantKey("mesh-delivery-assigner", fp); err != nil || pk != key {
t.Errorf("PartGrantKey = %q, %v, want the controller's %q", pk, err, key)
}
if m, f, err := ParsePartGrantKey(key); err != nil || m != "mesh-delivery-assigner" || f != fp {
t.Errorf("ParsePartGrantKey = %q, %q, %v", m, f, err)
}
for _, c := range []struct {
g Grant
away bool
bucket, read string
}{
{false, "mesh-controller_grants", "mesh-controller.grants"},
{true, "mesh-grants_grants", "mesh-grants.grants"},
} {
if b, r := GrantSource(c.away); b != c.bucket || r != c.read {
t.Errorf("GrantSource(%v) = %q, %q, want %q, %q", c.away, b, r, c.bucket, c.read)
}
}
g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
for _, c := range []struct {
g ControllerGrant
node, module string
names bool
}{
@@ -308,7 +327,7 @@ func TestTheGrantsIssued(t *testing.T) {
{g, "shanks", "mesh-mail", true},
{g, "g14", "mesh-build-go", false},
{g, "ace", "mesh-build", false},
{Grant{}, "novox", "nats", true},
{ControllerGrant{}, "novox", "nats", true},
} {
if got := c.g.NamesNode(c.node, c.module); got != c.names {
t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names)
+2 -2
View File
@@ -10,8 +10,8 @@ import (
// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453
// clause 4): a build is `<module>.<fingerprint key part>` in the builder's bucket, one key per fingerprint, since equal
// fingerprints are equal content and the same source is never built twice. They are written only by the builder part
// from R4c and read by the checker, the assigner and the view. They are not the ended controller's build records
// (buildrecord.go: BuildKey).
// from R4c and read by the checker, the assigner and the view, and from R4e by the controller. They are not the
// controller's build records (buildrecord.go: BuildsBucket, BuildKey), which stay as they are.
//
// **The rules its writer keeps.** A builder claims a fingerprint that has no build key by creating it queued or
// building, a create that fails when the key exists, and moves it by compare-and-set; a builder reclaims only a build
+4 -1
View File
@@ -166,7 +166,10 @@ func TestBuilderLayoutDoesNotCollideWithTheControllers(t *testing.T) {
if BuilderBuildsBucket != "mesh-delivery-builder_builds" || BuilderBuildsHistory != 5 || BuilderModule != "mesh-delivery-builder" {
t.Errorf("bucket %s history %d module %s", BuilderBuildsBucket, BuilderBuildsHistory, BuilderModule)
}
if BuildKey("m", "7") != "m.build.7" || NewestKey("m") != "m.newest" {
if BuilderBuildsBucket == BuildsBucket || BuilderModule == "mesh-controller" {
t.Error("the builder's bucket is the controller's")
}
if BuildsBucket != "mesh-controller_builds" || BuildKey("m", "7") != "m.build.7" || NewestKey("m") != "m.newest" {
t.Error("the controller's layout changed")
}
// the two key forms are never read as each other
+7 -2
View File
@@ -2,8 +2,8 @@ package delivery
import "time"
// BuildRecord is the layout the ended controller's chain wrote (mesh-controller internal/chain/records.go Build, line 91),
// the fields a reader outside it read (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores
// BuildRecord is the layout mesh-controller internal/chain/records.go Build writes (line 91), the fields a reader outside
// the controller reads (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores
// what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100,
// set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose
// manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller
@@ -32,6 +32,11 @@ type Artifact struct {
Reference string `json:"reference"`
}
// BuildsBucket is the bucket the builds are kept in: mesh-controller internal/broker/controller_buckets.go:101,
// BucketName(ControllerSeat, ChainBuilds), with ControllerSeat "mesh-controller" (internal/broker/streams.go:359) and
// ChainBuilds "builds" (controller_buckets.go:113).
const BuildsBucket = "mesh-controller_builds"
// BuildKey is a build's record under its module, a build that knew none under the request's key: copied from
// mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42).
func BuildKey(module, id string) string {
+3
View File
@@ -114,6 +114,9 @@ func TestBuildRecordWithoutInputs(t *testing.T) { // 1223/AC2
func TestBuildKeys(t *testing.T) { // 1223/AC3
// Literal values of mesh-controller internal/broker/controller_buckets.go:101 (BucketName(ControllerSeat, ChainBuilds)
// is "mesh-controller_builds") and internal/chain/keys.go:35-44.
if BuildsBucket != "mesh-controller_builds" {
t.Errorf("BuildsBucket = %q", BuildsBucket)
}
if got := BuildKey("build-agent", "build-1"); got != "build-agent.build.build-1" {
t.Errorf("BuildKey = %q", got)
}
+11 -45
View File
@@ -32,8 +32,8 @@ type AskWaiter interface {
type Asker struct {
Publisher AskPublisher
Waiter AskWaiter
// Bound is how long a running step (a build node took the ask, its key written running) may stand still, with no new phase,
// before the asker stops waiting; a step that keeps moving is waited for up to 3 Bounds from its start (issue 1930); zero waits as long as ctx. An ask waiting in its stream is not bounded: the stream's backlog is no fault
// Bound is how long a step may run once a build node took the ask (its key written running) before the asker stops
// waiting; zero waits as long as ctx. An ask waiting in its stream is not bounded: the stream's backlog is no fault
// (ADR 0525 §6, §9), and the bound starts again when another node takes the ask.
Bound time.Duration
}
@@ -68,33 +68,20 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err
if err != nil {
return delivery.Step{}, err
}
var bound, outerCap <-chan time.Time
var timer, capTimer *time.Timer
var bound <-chan time.Time
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
if capTimer != nil {
capTimer.Stop()
}
}()
running, since, phase, phaseAt := "", time.Time{}, "", time.Time{}
standStill := func() error {
if phase == "" {
return fmt.Errorf("the ask %s's step on %s did not end within %s of its start: it stood still, saying no phase", a.ID, running, k.Bound)
}
return fmt.Errorf("the ask %s's step on %s did not end within %s: it stood still in %q since %s",
a.ID, running, k.Bound, phase, phaseAt.UTC().Format(time.RFC3339))
}
running, since := "", time.Time{}
for {
select {
case <-ctx.Done():
return delivery.Step{}, ctx.Err()
case <-bound:
return delivery.Step{}, standStill()
case <-outerCap:
return delivery.Step{}, fmt.Errorf("the ask %s's step on %s did not end within %s of its start: it kept moving (last phase %q at %s) but never ended",
a.ID, running, 3*k.Bound, phase, phaseAt.UTC().Format(time.RFC3339))
return delivery.Step{}, fmt.Errorf("the ask %s's step on %s did not end within %s of its start", a.ID, running, k.Bound)
case step, ok := <-steps:
if !ok {
return delivery.Step{}, errors.New("the watch of the ask's step ended")
@@ -105,42 +92,21 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err
if step.State != delivery.StepRunning {
return step, nil
}
if k.Bound <= 0 {
continue
}
newRun := step.Node != running || !step.Started.Equal(since)
if !newRun && step.Phase == phase && step.PhaseAt.Equal(phaseAt) {
if k.Bound <= 0 || (step.Node == running && step.Started.Equal(since)) {
continue
}
// A node took the ask, or took it again after its run stopped (another node, or the same node's build module
// replaced mid-run: a new start), or its step entered a phase. The bound is on standing still: it runs from the
// step's last movement (its phase's start, else its start), so a step that keeps moving is waited for, up to
// three bounds from its start (issue 1930).
running, since, phase, phaseAt = step.Node, step.Started, step.Phase, step.PhaseAt
last := step.Started
if step.PhaseAt.After(last) {
last = step.PhaseAt
}
// replaced mid-run: a new start): its step is bounded from its own start.
running, since = step.Node, step.Started
left := k.Bound
if !last.IsZero() {
left = time.Until(last.Add(k.Bound))
if !step.Started.IsZero() {
left = time.Until(step.Started.Add(k.Bound))
}
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(max(left, 0))
bound = timer.C
if newRun {
if capTimer != nil {
capTimer.Stop()
}
outer := 3 * k.Bound
if !step.Started.IsZero() {
outer = time.Until(step.Started.Add(3 * k.Bound))
}
capTimer = time.NewTimer(max(outer, 0))
outerCap = capTimer.C
}
}
}
}
+1 -36
View File
@@ -53,41 +53,6 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
if len(g.Asks()) != 0 {
t.Fatal("a wait put an ask")
}
// Issue 1930: the bound is on a step that stands still, not on its age. A step that enters a new phase inside the bound is
// waited for until it ends; one that stays in a phase the bound fails at once, naming the phase; one that never stops moving
// ends at three bounds, said as such.
k = Asker{Publisher: g, Waiter: g, Bound: 60 * time.Millisecond}
started := time.Now()
go func() {
for i := range 8 {
g.Write(delivery.Step{ID: "mvl0frv18d1980", Node: "ace", State: delivery.StepRunning, Started: started, Phase: "p" + string(rune('a'+i)), PhaseAt: time.Now()})
time.Sleep(15 * time.Millisecond)
}
g.Write(delivery.Step{ID: "mvl0frv18d1980", Node: "ace", State: delivery.StepPassed})
}()
if step, err := k.Wait(context.Background(), goAsk("mvl0frv18d1980")); err != nil || step.State != delivery.StepPassed {
t.Fatalf("a step that kept moving past its bound: %+v, %v", step, err)
}
g.Write(delivery.Step{ID: "mvl0frv18d1981", Node: "ace", State: delivery.StepRunning, Started: time.Now(), Phase: "integration: tests", PhaseAt: time.Now()})
if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1981")); err == nil || !strings.Contains(err.Error(), `stood still in "integration: tests"`) {
t.Fatalf("a step that stood still in a phase: %v", err)
}
loop := make(chan struct{})
defer close(loop)
started = time.Now()
go func() {
for i := 0; ; i++ {
select {
case <-loop:
return
case <-time.After(20 * time.Millisecond):
g.Write(delivery.Step{ID: "mvl0frv18d1982", Node: "ace", State: delivery.StepRunning, Started: started, Phase: "q" + string(rune('a'+i%26)), PhaseAt: time.Now()})
}
}
}()
if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1982")); err == nil || !strings.Contains(err.Error(), "kept moving") {
t.Fatalf("a step that never stops moving: %v", err)
}
// Issue 734: an ask the build seat gave up on (DEAD_LETTERS keeps its copy with the ask's id) ends its wait as failed
// with the words, though a node had taken it; under NotTakenBound one no node takes ends at the bound; a step written
// first is answered as the node wrote it.
@@ -192,7 +157,7 @@ func TestARedeliveryToTheSameNodeRestartsTheBound(t *testing.T) {
first := time.Now()
f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepRunning, Started: first})
go func() {
time.Sleep(15 * time.Millisecond)
time.Sleep(40 * time.Millisecond)
f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepRunning, Started: time.Now()})
time.Sleep(50 * time.Millisecond) // past the first run's bound, inside the second's
f.Write(delivery.Step{ID: id, Node: "ace", State: delivery.StepPassed, Started: time.Now()})
+4 -4
View File
@@ -91,8 +91,8 @@ func TestToolchainGate(t *testing.T) {
gate, set, said := start(t, true)
key, value := gateReport(t, "ace", "v26.12.0", old)
gate.Toolchain(NodeBuildModule, key, value, false)
if a := delivery.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out
t.Fatalf("before the placement is settled every report counts; got %+v", a)
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree") {
t.Fatalf("before the placement is settled every report counts; got %q", none)
}
k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter
gate.Placement(k, v, false, old)
@@ -111,8 +111,8 @@ func TestToolchainGate(t *testing.T) {
// Placed on ace with a report written since: it counts, and the builders really disagree.
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
gate.Placement(k, v, false, old.Add(-time.Minute))
if a := delivery.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" {
t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a)
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree: v26.12.0 recipe f59571cf4be75adf on ace") {
t.Fatalf("builders that run different versions still disagree; got %q", none)
}
// Taken off ace again (its key deleted): its report goes again.
if !gate.Placement(k, nil, true, placed) {
+5 -6
View File
@@ -143,7 +143,7 @@ type FailedFirst struct {
Words string `json:"words,omitempty"`
}
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty failures named one by one, and an entry
// MaxFailures is the most entries a verdict's Failures holds (issue 1921): fifty tests named one by one, and an entry
// after them saying how many more failed.
const MaxFailures = 50
@@ -157,11 +157,10 @@ type Verdict struct {
Trunk map[string]string `json:"trunk,omitempty"`
State string `json:"state"`
Failed FailedFirst `json:"failed,omitzero"`
// Failures names everything that failed, in the order judged, each with the first line of its words (issue 1921): every
// failed test, and every member's refused step, conflict, manifest refusal and the composition's failure, since a check
// runs every member, level and the composition to its end (the operator, 2026-11-02). Failed keeps the first one's
// words whole. At most MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value
// written before it, and from a verdict with one failure that is no test.
// Failures names every test that failed, in the order judged, each with the first line of its words (issue 1921):
// Failed keeps the first one's words whole, and a check that ran every test says all it found here. At most
// MaxFailures entries, the last of a longer list saying how many more failed. Absent in a value written before it,
// and from a verdict whose failure is no test.
Failures []FailedFirst `json:"failures,omitempty"`
Levels map[string]LevelResult `json:"levels,omitempty"`
Took Took `json:"took,omitzero"`
+20 -8
View File
@@ -7,17 +7,29 @@ import (
"strings"
)
// The compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources composed for one node
// at one fingerprint: a node composes its own (ADR 0547 §2), and the ended controller wrote them into a bucket of each
// node, `composed-<node>`, which nothing writes since it ended (ADR 0590), under `<node>.<module>.<fingerprint hex>`, and
// the node's own resources (adoption, firewall openings, accounts, the resolver, identities and groups) under
// `<node>.node`. A composition's digest and its judgement of the move from the composition the node runs now is
// ComposedDigest.
// The controller's compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources
// composed for one node at one fingerprint, written by the controller, which keeps the inputs. It lives in a bucket of
// each node, `composed-<node>`, which only that node's node-engine reads (a composition can carry a sealed value), under
// `<node>.<module>.<fingerprint hex>`, and the node's own resources (adoption, firewall openings, accounts, the
// resolver, identities and groups) under `<node>.node`. Beside it, under the same key in `mesh-controller_composed`,
// which the assigner reads, the controller writes the composition's digest and its judgement of the move from the
// composition the node runs now (ComposedDigest). The controller writes both by compare-and-set while it holds the
// lease; the operator's credential is a second writer on the hand path alone (clause 10, mesh-hand-compose).
//
// A composition is derived state (clause 4): its key and its digest are deleted by the controller when its fingerprint
// is no longer among the module's five newest and no want names it, and each bucket keeps five revisions of a key.
const (
// ComposedBucketPrefix starts the name of a node's bucket of compositions, ComposedBucketFor(node).
ComposedBucketPrefix = "composed-"
// ComposedHistory is the revisions a node's composition bucket keeps per key (ADR 0539 clause 4).
ComposedHistory = 5
// ControllerModule is the module of the controller, the writer of the compositions.
ControllerModule = "mesh-controller"
// ControllerComposedBucket is the controller's bucket of each composition's digest and judgement, `<owner
// module>_<name>`, read by the assigner (ADR 0539 clauses 2 and 6).
ControllerComposedBucket = ControllerModule + "_composed"
// ControllerComposedHistory is the revisions that bucket keeps per key.
ControllerComposedHistory = 5
// ComposedKeep is how many of a module's newest fingerprints keep their compositions though no want names them
// (ADR 0539 clause 4).
ComposedKeep = 5
@@ -130,8 +142,8 @@ func checkDigest(digest, of string) error {
//
// Policy is the module's upgrade policy as the controller holds it (ADR 0236, ADR 0539 clause 14), with PolicyFrom
// where it comes from and PolicyWhy why: the assigner holds a move of a module of policy record from it, the operator's
// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand,
// whose want the operator wrote.
// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand
// (mesh-hand-compose), whose want the operator writes.
type ComposedDigest struct {
V int `json:"v"`
Digest string `json:"digest"`
+4 -4
View File
@@ -6,9 +6,9 @@ import (
"fmt"
)
// A composition's signature (novox/hq ADR 0539 clause 3): the signer of a composition signs it with its key (the ended
// controller signed those it wrote, the operator one made by hand). The node-engine trusts the signers it is given and
// applies nothing of a composition whose signature fails or whose signer is none of them. The signature is an
// A composition's signature (novox/hq ADR 0539 clause 3): the controller signs each composition it writes with its
// key, and the operator signs one made by hand with mesh-hand-compose with theirs. The node-engine trusts exactly these two
// signers and applies nothing of a composition whose signature fails or whose signer is neither. The signature is an
// ed25519 signature, as a declaration's is, of the composition's digest (ComposedDigestOf) after a context line, so a
// signature made for anything else never verifies here.
const composedSigningContext = "novox composition v1\n"
@@ -84,7 +84,7 @@ func VerifyComposed(c Composed, trusted []ed25519.PublicKey) (string, error) {
}
}
if key == nil {
return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the vault's key or the operator's)",
return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the controller's key or the operator's)",
c.Module, c.Node, c.Signature.Signer)
}
digest, err := ComposedDigestOf(c)
+3 -3
View File
@@ -108,7 +108,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) {
// signers a node trusts and names which; an unknown key and another kind's context are refused.
vaultPub, vault := aSigningKey(t)
controllerPub, controller := aSigningKey(t)
trusted := []Signer{{Name: "operator", Key: controllerPub}, {Name: SignerVault, Key: vaultPub}}
trusted := []Signer{{Name: SignerController, Key: controllerPub}, {Name: SignerVault, Key: vaultPub}}
enrol, _ := SignContext(KindEnrolment)
certCtx, _ := SignContext(KindNodeCertificate)
msg := []byte("the reply")
@@ -120,7 +120,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) {
want string
}{
{"the vault's key verifies", vault, enrol, enrol, SignerVault},
{"the controller's key verifies", controller, enrol, enrol, "operator"},
{"the controller's key verifies", controller, enrol, enrol, SignerController},
{"an unknown key is refused", other, enrol, enrol, ""},
{"the wrong context is refused", vault, certCtx, enrol, ""},
} {
@@ -148,7 +148,7 @@ func TestACompositionIsSignedByAKeptKey(t *testing.T) {
}
}
if got := Trusts(trusted, nil); len(got) != 2 || !TrustsKey(got, SignerVault, SignerFingerprint(vaultPub)) ||
!TrustsKey(got, "operator", SignerFingerprint(controllerPub)) {
!TrustsKey(got, SignerController, SignerFingerprint(controllerPub)) {
t.Errorf("trusts: %v", got)
}
// A certificate the vault's authority signs for a node's name and serving key checks against a bundle holding that
-42
View File
@@ -40,10 +40,6 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
// A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback.
{"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` +
`"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` +
`"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}},
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
} {
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
@@ -64,10 +60,6 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
t.Error("a report naming no node was encoded")
}
if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b",
Why: "refused", At: time.Now()}}); err == nil {
t.Error("a fall back naming another node was encoded")
}
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
held := SeatHoldersOf(map[string]Placement{
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
@@ -150,40 +142,6 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
t.Errorf("%s keyed %q, %v", pull, got, err)
}
}
// ADR 0565 §6: the engine works out its node's key of `packages` from the compositions it composed, the mirror's bytes
// for the same resources (mesh-controller internal/inventory/declared_packages_test.go aNodesPackages at 0fbb5ef3):
// a module's own composition, the node's own with a folded module's resource named by its id alone, and a file.
res := func(s ...string) []json.RawMessage {
var out []json.RawMessage
for _, r := range s {
out = append(out, json.RawMessage(r))
}
return out
}
comps := []Composed{
{Node: "one", Module: "web", Resources: res(`{"id":"pkg-curl","type":"package","package":"curl"}`,
`{"id":"pkg-nano","type":"package","package":"nano","absent":true}`, `{"id":"conf","type":"file","path":"/etc/web.conf"}`)},
{Node: "one", Resources: res(`{"id":"backup.pkg-curl","type":"package","package":"curl"}`)},
}
want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
`{"package":"nano","modules":["web"],"absent":true}]}`
got, err := DeclaredPackagesOf("one", comps)
if raw, _ := json.Marshal(got); err != nil || string(raw) != want {
t.Errorf("the node's packages are %s (%v)", raw, err)
}
if none, _ := DeclaredPackagesOf("one", nil); string(jsonOf(none)) != `{"node":"one","packages":[]}` || EnginePackagesRead != "mesh-host.packages" {
t.Errorf("a node declaring no package is %s", jsonOf(none))
}
// Their difference with the mirror: a package only one side holds, and one whose modules differ; none when they agree.
mirror := DeclaredPackages{Node: "one", Packages: []PackageDeclared{{Package: "curl", Modules: []string{"web"}},
{Package: "nano", Modules: []string{"web"}, Absent: true}, {Package: "vim", Modules: []string{"edit"}}}}
if d := DiffDeclaredPackages(got, mirror); len(d) != 2 || d[0].Package != "curl" || strings.Join(d[0].Engine, ",") != "backup,web" ||
d[1].Package != "vim" || len(d[1].Engine) != 0 || d[1].Mirror[0] != "edit" {
t.Errorf("the difference is %+v", d)
}
if d := DiffDeclaredPackages(got, got); d == nil || len(d) != 0 {
t.Errorf("one declaration differs from itself: %+v", d)
}
}
func jsonOf(v any) []byte {
+15 -14
View File
@@ -367,7 +367,8 @@ type Have struct {
// holds now. The bus reloads the file itself when its digest changes (the module's entrypoint), so Digest is the list
// the bus loads. Wanted differs from Digest only while a write failed, and Error says why.
type UserListWritten struct {
// Source is the key the list was taken from: the grants part's `user-list` (GrantsUserListSource).
// Source is the key the list was taken from: the controller's composition of the bus module for this node
// (`<node>.<module>.<fp>`), or the grants part's `user-list` once it took over.
Source string `json:"source"`
Revision uint64 `json:"revision"`
// Wanted is the digest of the list the source holds, `sha256:<hex>`.
@@ -377,8 +378,8 @@ type UserListWritten struct {
Users int `json:"users"`
At time.Time `json:"at"`
Error string `json:"error,omitempty"`
// Follows is which list the node follows now (FollowGrants, novox/hq ADR 0547 clause 8), empty from a node-engine
// before the switch.
// Follows is which list the node follows now (FollowController or FollowGrants, novox/hq ADR 0547 clause 8), empty
// from a node-engine before the switch.
Follows string `json:"follows,omitempty"`
// Key is the bus node's sealing key, its public half as SPKI PEM: the key the grants part seals its list to
// (clause 7). Public, as the vault holds it.
@@ -416,19 +417,19 @@ type HaveNode struct {
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
// candidate.
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
// Composes is whose compositions the node applies: its own (ComposesOwn, placement places it), or none, empty, with
// ComposesWhy saying why in words. Written by the node agent at each start (issue 1126). An engine from before the
// controller ended may still say `controller` (composedByTheEndedController), which is read and never written.
// Composes is whose compositions the node applies: its own (ComposesOwn, placement places it) or the
// controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a
// node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty
// from an engine older than the field.
Composes string `json:"composes,omitempty"`
ComposesWhy string `json:"composes-why,omitempty"`
}
// ComposesOwn is a node applying its own compositions (HaveNode.Composes).
const ComposesOwn = "own"
// composedByTheEndedController is what an engine from before the controller ended (ADR 0590) wrote in Composes while
// it applied the controller's compositions: still read, so its have is not refused, and written by no engine now.
const composedByTheEndedController = "controller"
// Whose compositions a node applies (HaveNode.Composes).
const (
ComposesOwn = "own"
ComposesController = "controller"
)
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
// writes it.
@@ -525,9 +526,9 @@ func DecodeHave(data []byte) (Have, error) {
func (n HaveNode) check() error {
switch n.Composes {
case "", ComposesOwn, composedByTheEndedController:
case "", ComposesOwn, ComposesController:
default:
return fmt.Errorf("delivery: a node composes %q, not %s", n.Composes, ComposesOwn)
return fmt.Errorf("delivery: a node composes %q, neither %s nor %s", n.Composes, ComposesOwn, ComposesController)
}
for group, a := range n.Candidates {
if group == "" {
+2 -7
View File
@@ -245,18 +245,13 @@ func TestRoundTrip(t *testing.T) {
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
t.Fatalf("decoded %+v, %v", back, err)
}
// Issue 1126 B4a (ADR 0590): a key written with the lease's flag still reads; the controller's list is no source.
if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` +
`"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z","lease-handed":true}`)); err != nil || old.Revision != 3 {
t.Errorf("a key with the lease's flag decodes %+v, %v", old, err)
}
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: "controller"},
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object},
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
if _, err := EncodeGrantsUserList(bad); err == nil {
t.Errorf("%+v was written", bad)
}
}
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowGrants}); err != nil {
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowController}); err != nil {
t.Errorf("a switch with no list yet refused: %v", err)
}
object, err := SealUserList(list, node.PublicKey)
-137
View File
@@ -1,137 +0,0 @@
package delivery
import (
"encoding/json"
"fmt"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// **A node's declared packages, worked out by its engine** (novox/hq ADR 0565 §1, §6; issue 1953): the derivation and
// the comparison of the node-engine's state `packages` (EnginePackagesState, layout DeclaredPackages), so the engine
// that writes the key, the node seat's verb that compares it with the controller's mirror, and any reader work them out
// one way. The controller's own copy of the derivation (mesh-controller internal/inventory DeclaredPackagesValue) is
// not changed (ADR 0547 §1) and ends with its lease.
// EnginePackagesRead is the engine's state `packages` as a reader names it in `reads`: the node-engine's manifest is
// mesh-host's, so its bucket is mesh-host_packages, read as disk-load reads mesh-host.data (EngineDataRead).
const EnginePackagesRead = "mesh-host." + EnginePackagesState
// DeclaredPackagesOf is a node's key of `packages` from the compositions its engine composed for the node's wants:
// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so,
// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the
// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module
// lists none.
func DeclaredPackagesOf(node string, compositions []Composed) (DeclaredPackages, error) {
by := map[packageKey]map[string]bool{}
for _, c := range compositions {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Type string `json:"type"`
Package string `json:"package"`
Absent bool `json:"absent"`
Module string `json:"module"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return DeclaredPackages{}, fmt.Errorf("delivery: a resource of %s's composition %q is not readable: %w", node, c.Module, err)
}
if r.Type != "package" || r.Package == "" {
continue
}
k := packageKey{r.Package, r.Absent}
if by[k] == nil {
by[k] = map[string]bool{}
}
module := r.Module
if module == "" {
module = c.Module
}
if module == "" {
module, _ = declaration.ModuleGuess(r.ID)
}
if module != "" {
by[k][module] = true
}
}
}
v := DeclaredPackages{Node: node, Packages: []PackageDeclared{}}
for _, k := range sortedPackageKeys(by) {
modules := make([]string, 0, len(by[k]))
for m := range by[k] {
modules = append(modules, m)
}
sort.Strings(modules)
v.Packages = append(v.Packages, PackageDeclared{Package: k.name, Modules: modules, Absent: k.absent})
}
return v, nil
}
// PackageDifference is one package two declarations of a node hold differently: the modules each side declares it
// with (present, or absent when Absent), none on a side that does not hold it.
type PackageDifference struct {
Package string `json:"package"`
Absent bool `json:"absent"`
Engine []string `json:"engine"`
Mirror []string `json:"mirror"`
}
// DiffDeclaredPackages is every package the engine's key and the controller's mirror hold differently for one node, in
// the layout's order: what the node seat's verb packages answers until the lease write (ADR 0565 §6). Empty when they
// agree.
func DiffDeclaredPackages(engine, mirror DeclaredPackages) []PackageDifference {
index := func(d DeclaredPackages) map[packageKey][]string {
out := map[packageKey][]string{}
for _, p := range d.Packages {
out[packageKey{p.Package, p.Absent}] = append([]string{}, p.Modules...)
}
return out
}
e, m := index(engine), index(mirror)
union := map[packageKey]bool{}
for k := range e {
union[k] = true
}
for k := range m {
union[k] = true
}
out := []PackageDifference{}
for _, k := range sortedPackageKeys(union) {
em, inE := e[k]
mm, inM := m[k]
if inE && inM && slices.Equal(em, mm) {
continue
}
if em == nil {
em = []string{}
}
if mm == nil {
mm = []string{}
}
out = append(out, PackageDifference{Package: k.name, Absent: k.absent, Engine: em, Mirror: mm})
}
return out
}
// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent.
type packageKey struct {
name string
absent bool
}
// sortedPackageKeys is the keys of m by name, absent after present.
func sortedPackageKeys[V any](m map[packageKey]V) []packageKey {
keys := make([]packageKey, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].name != keys[j].name {
return keys[i].name < keys[j].name
}
return !keys[i].absent && keys[j].absent
})
return keys
}
-332
View File
@@ -1,332 +0,0 @@
package delivery
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/netip"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding
// no secret, with two kinds of key. `node.<node>`, one per open enrolment, holds an Enrolment; `address.<address>`
// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a
// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the
// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part,
// which composes the user `enrol.<node>` from a key at live and the vault's hash (§4). These constants are the seat's
// contract: every reader takes the names from here.
const (
// EnrolmentModule is the module holding the seat mesh-enrolment.
EnrolmentModule = catalogue.EnrolmentSeat
// EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares.
EnrolmentsState = "enrolments"
EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState
EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState
// The stages of an open enrolment (§2, §4, §5).
EnrolmentAsked = "asked"
EnrolmentLive = "live"
EnrolmentRedeeming = "redeeming"
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4).
DefaultTokenLife = time.Hour
MostTokenLife = 24 * time.Hour
// EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.<node>` (§4).
EnrolmentUserPrefix = "enrol."
enrolmentNodePrefix = "node."
enrolmentAddressPrefix = "address."
)
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token.
type Enrolment struct {
V int `json:"v"`
Node string `json:"node"`
Address string `json:"address"`
Why string `json:"why"`
Caller string `json:"caller"`
Ask string `json:"ask,omitempty"`
Stage string `json:"stage"`
Issued time.Time `json:"issued"`
Expires time.Time `json:"expires"`
KeysDigest string `json:"keys-digest,omitempty"`
// OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05),
// base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes.
OverlayKey string `json:"overlay-key"`
}
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask
// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the
// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and
// from the ask both set; one without the other is refused.
type EnrolmentAddress struct {
V int `json:"v"`
Node string `json:"node"`
Ask string `json:"ask,omitempty"`
Issued time.Time `json:"issued,omitzero"`
}
func (a EnrolmentAddress) check() error {
if err := checkName("node", a.Node); err != nil {
return err
}
if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() {
return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node)
}
return nil
}
// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued:
// `enrolment-issued.<node>.<issue time in Unix nanoseconds>`, what enrolment-expired names (EnrolmentExpiry).
func EnrolmentIssuedID(node string, issued time.Time) string {
return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
}
// EnrolmentNodeKey is a node's key in `enrolments`.
func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node }
// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens).
func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address }
// EnrolmentKeyNode is the node a key `node.<node>` names, and whether it is one.
func EnrolmentKeyNode(key string) (string, bool) {
node, ok := strings.CutPrefix(key, enrolmentNodePrefix)
return node, ok && checkName("node", node) == nil
}
// EnrolmentKeyAddress is the address a key `address.<address>` names, and whether it is one.
func EnrolmentKeyAddress(key string) (string, bool) {
address, ok := strings.CutPrefix(key, enrolmentAddressPrefix)
if !ok {
return "", false
}
_, err := netip.ParseAddr(address)
return address, err == nil
}
// EnrolmentUser is the bus user a node enrols as.
func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node }
func (e Enrolment) check() error {
if err := checkName("node", e.Node); err != nil {
return err
}
if _, err := netip.ParseAddr(e.Address); err != nil {
return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
}
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node)
}
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
}
switch e.Stage {
case EnrolmentAsked, EnrolmentLive:
if e.KeysDigest != "" {
return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
}
case EnrolmentRedeeming:
if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil {
return err
}
default:
return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
}
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node)
}
return nil
}
// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses.
func EncodeEnrolment(e Enrolment) ([]byte, error) {
if err := e.check(); err != nil {
return nil, err
}
e.V = Version
return json.Marshal(e)
}
// DecodeEnrolment reads a key `node.<node>`; it refuses an unknown version (ErrUnknownVersion), a node that is not a
// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not
// after the issue.
func DecodeEnrolment(data []byte) (Enrolment, error) {
var e Enrolment
if err := decode(data, &e); err != nil {
return Enrolment{}, err
}
if err := e.check(); err != nil {
return Enrolment{}, err
}
return e, nil
}
// EncodeEnrolmentAddress is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is
// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses.
func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) {
a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued}
if err := a.check(); err != nil {
return nil, err
}
return json.Marshal(a)
}
// DecodeEnrolmentAddress reads a key `address.<address>`; it refuses no node, and an ask without the issue's time or
// the reverse.
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
var a EnrolmentAddress
if err := decode(data, &a); err != nil {
return EnrolmentAddress{}, err
}
if err := a.check(); err != nil {
return EnrolmentAddress{}, err
}
return a, nil
}
// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not
// positive or longer than MostTokenLife.
func TokenLife(expires string) (time.Duration, error) {
if strings.TrimSpace(expires) == "" {
return DefaultTokenLife, nil
}
d, err := time.ParseDuration(strings.TrimSpace(expires))
if err != nil || d <= 0 || d > MostTokenLife {
return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
}
return d, nil
}
// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the
// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the
// event be named), since the bus deleted the key before the holder heard of it.
type EnrolmentExpiry struct {
V int `json:"v"`
Node string `json:"node"`
IssuedID string `json:"issued-event"`
}
func (x EnrolmentExpiry) check() error {
if err := checkName("node", x.Node); err != nil {
return err
}
if strings.TrimSpace(x.IssuedID) == "" {
return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node)
}
return nil
}
// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses.
func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) {
if err := x.check(); err != nil {
return nil, err
}
x.V = Version
return json.Marshal(x)
}
// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and
// no issued event named.
func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) {
var x EnrolmentExpiry
if err := decode(data, &x); err != nil {
return EnrolmentExpiry{}, err
}
if err := x.check(); err != nil {
return EnrolmentExpiry{}, err
}
return x, nil
}
// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine,
// which live in two repositories.
// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over
// RedeemProof. No token: the bus's login check verified it.
type Redeem struct {
Node string `json:"node"`
PublicKey string `json:"public_key"`
OverlayKey string `json:"overlay_key,omitempty"`
SealingKey string `json:"sealing_key"`
ServingKey string `json:"serving_key,omitempty"`
Profile map[string]any `json:"profile,omitempty"`
Tunnel json.RawMessage `json:"tunnel,omitempty"`
Proof string `json:"proof"`
}
// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it
// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes.
func RedeemProof(node, public, overlay, sealing, serving string) []byte {
return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving)
}
// KeysDigest is the digest of the public keys a redeem presents, `sha256:<hex>`: what the key at redeeming and the
// events carry, the one hash an event carries (§1).
func KeysDigest(public, overlay, sealing, serving string) string {
sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00")))
return "sha256:" + hex.EncodeToString(sum[:])
}
// KeysDigest is r's keys' digest.
func (r Redeem) KeysDigest() string {
return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey)
}
// ProofBytes is r's proof decoded from base64.
func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) }
// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it
// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over
// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries.
type RedeemAnswer struct {
Node string `json:"node"`
Login string `json:"login"`
Account string `json:"account"`
Certificate string `json:"certificate"`
Signer string `json:"signer"`
Signature string `json:"signature,omitempty"`
}
// SignedBytes is what the vault signs of a: a without its signature, as JSON.
func (a RedeemAnswer) SignedBytes() []byte {
a.Signature = ""
raw, _ := json.Marshal(a)
return raw
}
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
const (
EnrolmentTokenVerb = "enrolment-token"
NodeAccountVerb = "node-account"
)
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
// nor its hash.
type EnrolmentTokenAnswer struct {
Node string `json:"node"`
Broker string `json:"broker,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Signer string `json:"signer,omitempty"`
Sealed string `json:"sealed,omitempty"`
Dropped bool `json:"dropped,omitempty"`
}
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
// presented (base64), never in the clear; Retired says a retire left no account.
type NodeAccountAnswer struct {
Node string `json:"node"`
Login string `json:"login,omitempty"`
Account string `json:"account,omitempty"`
Retired bool `json:"retired,omitempty"`
}
+5 -4
View File
@@ -356,16 +356,17 @@ func TestGateSame(t *testing.T) { // 1201/AC6
}
}
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
// And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's
// token test).
func TestGateBuckets(t *testing.T) {
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
t.Error("a bucket or history differs from the decided layout")
}
}
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
hh, _ := HeadsHash(gateHeads())
for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} {
if k, err := MergesKey(name); err != nil || k != name {
+63 -30
View File
@@ -7,44 +7,41 @@ import (
"strings"
)
// **The grants issued** (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, ADR 0547 §4, §5, ADR 0590): when the grants
// part has issued a module's bus credentials for a fingerprint, it writes `<module>.<fingerprint hex>` in its state
// `grants` (PartGrantsBucket). The assigner reads it: a node's want moves to a fingerprint only once its grant exists.
// Only the grants part writes the bucket.
// The controller's grants (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6): when the controller has issued a module's
// bus credential for a fingerprint, it writes `<module>.<fingerprint hex>` in its bucket grants. The assigner reads
// it: a node's want moves to a fingerprint only once its grant exists. Only the controller writes the bucket.
//
// The names say Controller, since the catalogue's GrantKey (catalogue/streams.go) is a stream's grant pattern, unrelated.
const (
// PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it.
PartGrantsState = "grants"
// PartGrantsBucket is that state's bucket, `<owner module>_<name>`.
PartGrantsBucket = "mesh-grants_" + PartGrantsState
// PartGrantsRead is that state as a reader names it in `reads`.
PartGrantsRead = "mesh-grants." + PartGrantsState
// PartGrantsHistory is the revisions the bucket keeps per key.
PartGrantsHistory = 5
// ControllerGrantsBucket is the controller's bucket of issued credentials by fingerprint.
ControllerGrantsBucket = "mesh-controller_grants"
// ControllerGrantsHistory is the revisions the bucket keeps per key.
ControllerGrantsHistory = 5
)
// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: `<module>.<hex>`,
// the hex being the fingerprint's key part (FingerprintKeyPart).
func PartGrantKey(module, fingerprint string) (string, error) {
// ControllerGrantKey is the key, in ControllerGrantsBucket, of a module's credential issued for a fingerprint:
// `<module>.<hex>`, the hex being the fingerprint's key part (FingerprintKeyPart).
func ControllerGrantKey(module, fingerprint string) (string, error) {
return BuilderBuildKey(module, fingerprint)
}
// ParsePartGrantKey is the inverse of PartGrantKey; it refuses a want or held key, naming it.
func ParsePartGrantKey(key string) (module, fingerprint string, err error) {
// ParseControllerGrantKey is the inverse of ControllerGrantKey; it refuses a want or held key, naming it.
func ParseControllerGrantKey(key string) (module, fingerprint string, err error) {
if module, fingerprint, err = ParseBuilderBuildKey(key); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a grant key, <module>.<fingerprint hex>: %w", key, err)
}
return module, fingerprint, nil
}
// Grant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued
// ControllerGrant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued
// for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another
// account is a new revision. The entry's own time says when it was issued.
type Grant struct {
type ControllerGrant struct {
V int `json:"v"`
Accounts []string `json:"accounts,omitempty"`
}
func (g Grant) check() error {
func (g ControllerGrant) check() error {
for _, a := range g.Accounts {
if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") {
return fmt.Errorf("delivery: a grant names an empty or spaced account %q", a)
@@ -53,8 +50,8 @@ func (g Grant) check() error {
return nil
}
// EncodeGrant is g as JSON at Version; it refuses an empty or spaced account.
func EncodeGrant(g Grant) ([]byte, error) {
// EncodeControllerGrant is g as JSON at Version; it refuses an empty or spaced account.
func EncodeControllerGrant(g ControllerGrant) ([]byte, error) {
if err := g.check(); err != nil {
return nil, err
}
@@ -62,26 +59,26 @@ func EncodeGrant(g Grant) ([]byte, error) {
return json.Marshal(g)
}
// DecodeGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account.
func DecodeGrant(data []byte) (Grant, error) {
var g Grant
// DecodeControllerGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account.
func DecodeControllerGrant(data []byte) (ControllerGrant, error) {
var g ControllerGrant
if err := decode(data, &g); err != nil {
return Grant{}, err
return ControllerGrant{}, err
}
if err := g.check(); err != nil {
return Grant{}, err
return ControllerGrant{}, err
}
return g, nil
}
// SameGrant says whether two grants are the same value.
func SameGrant(a, b Grant) bool { return reflect.DeepEqual(a, b) }
// SameControllerGrant says whether two grants are the same value.
func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) }
// NamesNode says whether the grant names module's accounts on node: its own `<node>.<module>` or a process's
// `<node>.<module>.<process>`. A grant that names no account (a module that does not speak on the bus) names every
// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR
// 0546 clause 4).
func (g Grant) NamesNode(node, module string) bool {
func (g ControllerGrant) NamesNode(node, module string) bool {
if len(g.Accounts) == 0 {
return true
}
@@ -93,3 +90,39 @@ func (g Grant) NamesNode(node, module string) bool {
}
return false
}
// **The grants part's grants** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). The grants part writes the same
// keys, with the same value, into its own state `grants` (the bucket PartGrantsBucket), beside the controller's: first in
// shadow, which nobody reads, so a tool can compare the two before anything switches. The assigner reads the part's keys
// only once the controller's lease is handed away (GrantSource), and the controller's until then.
const (
// PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it.
PartGrantsState = "grants"
// PartGrantsBucket is that state's bucket, `<owner module>_<name>`.
PartGrantsBucket = "mesh-grants_" + PartGrantsState
// PartGrantsRead is that state as a reader names it in `reads`.
PartGrantsRead = "mesh-grants." + PartGrantsState
// PartGrantsHistory is the revisions the bucket keeps per key.
PartGrantsHistory = ControllerGrantsHistory
)
// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: the layout of the
// controller's key (ControllerGrantKey), so a key of one is the key of the other and a compare is by key.
func PartGrantKey(module, fingerprint string) (string, error) {
return ControllerGrantKey(module, fingerprint)
}
// ParsePartGrantKey is the inverse of PartGrantKey.
func ParsePartGrantKey(key string) (module, fingerprint string, err error) {
return ParseControllerGrantKey(key)
}
// GrantSource names the bucket the assigner reads grants from, and the `reads` name that grants it: the grants part's
// once the controller's lease is handed away (buscompose.LeaseHandedAway, which refuses every other value of the lease,
// an unreadable one included), the controller's in every other case.
func GrantSource(leaseHandedAway bool) (bucket, read string) {
if leaseHandedAway {
return PartGrantsBucket, PartGrantsRead
}
return ControllerGrantsBucket, "mesh-controller.grants"
}
+10 -10
View File
@@ -8,19 +8,19 @@ import (
)
// Whether the artifact store still holds what a done key names (novox/hq issue 1937). A build module pushes a bundle or
// an archive as a bare blob (ADR 0498 §1), and the store's nightly collector deletes every blob no manifest names. A done
// key can therefore name a blob that is gone, and reusing it sends a definition every machine fails on. So the builder
// asks before it reuses a done key, and the artifact sweep says when it found a gone blob. Both are the seat
// mesh-artifact-sweep's (novox/hq ADR 0571 §2, ADR 0590): the controller that served them first has ended.
// an archive as a bare blob (ADR 0498 §1), and the store's nightly collector deletes every blob no manifest names; only
// the controller's take-in and sweep hold one by its manifest. A done key the controller has not taken in can therefore
// name a blob that is gone, and reusing it sends a definition every machine fails on. So the builder asks before it
// reuses a done key, and the controller says when a take-in found a gone blob.
const (
// HeldVerb is the artifact sweep seat's verb the builder asks, as a module's invokes names it: references in, each
// HeldVerb is the controller seat's verb the builder asks, as a module's invokes names it: references in, each
// answered held or gone.
HeldVerb = "seat:mesh-artifact-sweep.held"
// ArtifactsGoneEvent is the artifact sweep seat's event said when the store does not hold an artifact a key names:
// as a consumer names it, `mesh-artifact-sweep.artifacts-gone`.
HeldVerb = "seat:mesh-controller.held"
// ArtifactsGoneEvent is the controller seat's event said when a take-in is refused because the store does not hold
// an artifact the key names: as a consumer names it, `mesh-controller.artifacts-gone`.
ArtifactsGoneEvent = "artifacts-gone"
// ArtifactsGoneSubject is where it is published (the seat's event subject).
ArtifactsGoneSubject = "mesh.seat.mesh-artifact-sweep.event." + ArtifactsGoneEvent
ArtifactsGoneSubject = "mesh.seat.mesh-controller.event." + ArtifactsGoneEvent
)
// HeldAnswer is the answer of HeldVerb: the references the store holds, and those it answered 404 for. A reference the
@@ -69,7 +69,7 @@ func DecodeHeldAnswer(raw []byte, asked []string) (HeldAnswer, error) {
return a, nil
}
// ArtifactsGone is the body of ArtifactsGoneEvent: the module whose builder's key names a gone artifact,
// ArtifactsGone is the body of ArtifactsGoneEvent: the module whose builder's key the controller would not take in,
// the key and its fingerprint, and every reference of it the store does not hold.
type ArtifactsGone struct {
Event string `json:"event"`
+24 -8
View File
@@ -1,7 +1,6 @@
package delivery
import (
"slices"
"strings"
"testing"
"time"
@@ -56,13 +55,30 @@ func TestArtifactsGoneRoundTrip(t *testing.T) {
}
}
// The verb and the event are on the artifact sweep seat's contract under the names the builder uses (ADR 0571 §2).
func TestHeldOnTheArtifactSweepSeat(t *testing.T) {
seat, ok := catalogue.SeatNamed(catalogue.ArtifactSweepSeat)
if !ok || HeldVerb != "seat:"+seat.Name+".held" || ArtifactsGoneSubject != "mesh.seat."+seat.Name+".event."+ArtifactsGoneEvent {
t.Fatalf("HeldVerb %q, ArtifactsGoneSubject %q", HeldVerb, ArtifactsGoneSubject)
// The verb and the event are on the controller seat's contract under the names the builder uses.
func TestHeldOnTheControllerSeat(t *testing.T) {
if HeldVerb != "seat:"+catalogue.ControllerSeatName+".held" {
t.Fatalf("HeldVerb %q", HeldVerb)
}
if !slices.ContainsFunc(seat.Serves, func(v catalogue.Verb) bool { return v.Name == "held" }) || !slices.Contains(seat.Emits, ArtifactsGoneEvent) {
t.Fatalf("the sweep seat serves %v and emits %v", seat.Serves, seat.Emits)
found := false
for _, v := range catalogue.ControllerVerbs {
if v.Name == "held" {
found = true
}
}
if !found {
t.Fatal("the controller seat declares no verb held")
}
for _, s := range catalogue.DefaultSeats() {
if s.Name != catalogue.ControllerSeatName {
continue
}
for _, e := range s.Emits {
if e == ArtifactsGoneEvent {
return
}
}
t.Fatalf("the controller seat emits %v, not %s", s.Emits, ArtifactsGoneEvent)
}
t.Fatal("no controller seat")
}
-137
View File
@@ -1,137 +0,0 @@
package delivery
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.<n>` and
// `node.<n>--b`, each with the node's permissions and its inbox `_INBOX.node.<n>` unchanged (§1). Which one a node uses
// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login
// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the
// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does:
// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement.
const (
NodeLoginsState = "node-logins"
NodeLoginsBucket = VaultModule + "_" + NodeLoginsState
NodeLoginsRead = VaultModule + "." + NodeLoginsState
// The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready
// when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called
// node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared.
RotationBegun = "begun"
RotationReady = "ready"
RotationConfirmed = "confirmed"
// The vault's rotation verbs on its seat (§2, §3, §5).
RotateNodeVerb = "rotate-node"
NodeAccountConfirmVerb = "node-account-confirm"
AbandonVerb = "abandon"
// The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin.
RotationBegunEvent = "rotation-begun"
NodeAccountReadyEvent = "node-account-ready"
RotationDoneEvent = "rotation-done"
// NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated
// authentication failures with the new one, before rotation-done; the watchman raises it (§6).
NodeAccountFallbackFinding = "node-account-fallback"
nodeLoginPrefix = "node."
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
SecondLoginSuffix = "--b"
)
// RotationEventSubject is where the vault says event of a rotation.
func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event }
// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and
// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held
// both logins, from ready on.
type NodeLogin struct {
V int `json:"v"`
Node string `json:"node"`
Login string `json:"login"`
Rotating string `json:"rotating,omitempty"`
Stage string `json:"stage,omitempty"`
Since time.Time `json:"since"`
ListRevision uint64 `json:"list-revision,omitempty"`
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
Acts []string `json:"acts,omitempty"`
}
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
func FirstLogin(node string) string { return nodeLoginPrefix + node }
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
func OtherLogin(node, login string) string {
if login == SecondLogin(node) {
return FirstLogin(node)
}
return SecondLogin(node)
}
// LoginNode is the node a node login names, either of its two, and whether login is one.
func LoginNode(login string) (string, bool) {
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
return rest, ok && checkName("node", rest) == nil
}
// Rotates says the node is rotating: a login rotating is named.
func (l NodeLogin) Rotates() bool { return l.Rotating != "" }
func (l NodeLogin) check() error {
if err := checkName("node", l.Node); err != nil {
return err
}
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
}
for _, act := range l.Acts {
if strings.TrimSpace(act) == "" {
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
}
}
if !l.Rotates() {
if l.Stage != "" || l.ListRevision != 0 {
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
}
return nil
}
if l.Rotating != OtherLogin(l.Node, l.Login) {
return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
}
switch l.Stage {
case RotationBegun, RotationReady, RotationConfirmed:
default:
return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
}
return nil
}
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
func EncodeNodeLogin(l NodeLogin) ([]byte, error) {
if err := l.check(); err != nil {
return nil, err
}
l.V = Version
return json.Marshal(l)
}
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not
// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An
// empty rotating reads as none.
func DecodeNodeLogin(data []byte) (NodeLogin, error) {
var l NodeLogin
if err := decode(data, &l); err != nil {
return NodeLogin{}, err
}
if err := l.check(); err != nil {
return NodeLogin{}, err
}
return l, nil
}
+2 -18
View File
@@ -13,8 +13,8 @@ import (
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host
// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf).
// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a
// report from its own health statement there (NodeHealthOf).
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
const (
@@ -40,10 +40,6 @@ type NodeHealth struct {
Relogin []NodeAccount `json:"relogin,omitempty"`
// Found are the directories used as found.
Found []NodeDirectory `json:"found,omitempty"`
// Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its
// confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as
// its login in force.
Fallback *LoginFallback `json:"fallback,omitempty"`
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
// judge an agent's confinement by, on the machine's own word.
@@ -55,15 +51,6 @@ type NodeHealth struct {
At time.Time `json:"at"`
}
// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the
// rotation moved to), why, in words with no credential, and when.
type LoginFallback struct {
Node string `json:"node"`
From string `json:"from"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
type NetworkHealth struct {
State string `json:"state"`
@@ -163,9 +150,6 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
if h.Node == "" {
return nil, errors.New("a node's health report names its node")
}
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
}
return json.Marshal(h)
}
+1 -141
View File
@@ -1,13 +1,10 @@
package delivery
import (
"encoding/base64"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
@@ -16,7 +13,7 @@ import (
// check (ADR 0201 §13) refuses such a field in a state every node reads.
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
// neither it nor the placement file names a field like a secret.
func TestNodeRecord(t *testing.T) {
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
@@ -71,143 +68,6 @@ func TestNodeRecord(t *testing.T) {
}
}
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
// grants part reads them (ADR 0587 §2).
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
// The tunnel key given at issue, at every stage.
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
for _, e := range []Enrolment{
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
} {
raw, err := EncodeEnrolment(e)
if err != nil {
t.Fatal(err)
}
back, err := DecodeEnrolment(raw)
e.V = Version
if err != nil || !reflect.DeepEqual(back, e) {
t.Errorf("enrolment round trip: %+v, %v", back, err)
}
}
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
t.Fatal(err)
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
}
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
t.Fatal(err)
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
t.Errorf("address at the ask round trip: %+v, %v", back, err)
}
for name, raw := range map[string]string{
"no node": `{"v":1}`,
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
} {
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
t.Errorf("an address with %s: taken", name)
}
}
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
t.Errorf("the issued event's id: %q", id)
}
for name, e := range map[string]Enrolment{
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
Expires: issued.Add(time.Hour)},
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
Expires: issued.Add(time.Hour)},
} {
if _, err := EncodeEnrolment(e); err == nil {
t.Errorf("enrolment with %s: taken", name)
}
}
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
t.Errorf("address key: %q %v", key, ok)
}
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
t.Errorf("node key: %q %v", node, ok)
}
for _, life := range []string{"", "1h", "24h"} {
if _, err := TokenLife(life); err != nil {
t.Errorf("a life of %q: %v", life, err)
}
}
for _, life := range []string{"25h", "-1h", "a day"} {
if _, err := TokenLife(life); err == nil {
t.Errorf("a life of %q: taken", life)
}
}
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
for _, l := range []NodeLogin{
{Node: "g14", Login: "node.g14", Since: issued},
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
} {
raw, err := EncodeNodeLogin(l)
if err != nil {
t.Fatal(err)
}
back, err := DecodeNodeLogin(raw)
l.V = Version
if err != nil || !reflect.DeepEqual(back, l) {
t.Errorf("node login round trip: %+v, %v", back, err)
}
}
// The hand acts that warranted its rotations, as the vault records them.
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
t.Errorf("a login with its acts: %s, %v", raw, err)
}
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
t.Errorf("an empty rotating: %+v, %v", l, err)
}
for name, l := range map[string]NodeLogin{
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
"another node's login": {Node: "g14", Login: "node.ace"},
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
} {
if _, err := EncodeNodeLogin(l); err == nil {
t.Errorf("node login with %s: taken", name)
}
}
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
t.Errorf("the second login's node: %q %v", node, ok)
}
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
t.Errorf("an expiry: %v", err)
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
}
for name, raw := range map[string]string{
"no issued event": `{"v":1,"node":"lab"}`,
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
} {
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
t.Errorf("an expiry with %s: taken", name)
}
}
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
walk(reflect.TypeOf(Placement{}), "Placement")
}
+8 -6
View File
@@ -127,9 +127,10 @@ type CandidateAnswer struct {
Held []ModuleRefused `json:"held,omitempty"`
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
Refused string `json:"refused,omitempty"`
// Returns is, for a proposal that takes the node's placement key away, what the node does then (ReturnsToNothing): a
// node with no placement key composes nothing (ADR 0590), so Removed names every module it runs and the verdict is
// theirs.
// Returns is, for a proposal that takes the node's placement key away, what the node does then: it returns to the
// controller's compositions (ReturnsToController), as a node with no placement key composes none of its own. Same, Differs, Added and Removed then compare the controller's compositions at
// the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is
// refused (Refused, without Returns).
Returns string `json:"returns,omitempty"`
// Note is what the node says of how it answered, in words, beside the verdict: a pass that ran over a minute, inputs
// it waited for (issue 1126: the shadow shows its work). It changes no verdict.
@@ -140,8 +141,8 @@ type CandidateAnswer struct {
ValuesDiffer int `json:"values_differ,omitempty"`
}
// ReturnsToNothing is what a node answers a proposal that takes its placement key away.
const ReturnsToNothing = "the proposal takes this node's placement away: it composes nothing then"
// ReturnsToController is what a node answers a proposal that takes its placement key away.
const ReturnsToController = "the proposal takes this node's placement away: it returns to the controller's compositions"
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
@@ -226,7 +227,8 @@ func (a CandidateAnswer) check() error {
return fmt.Errorf("delivery: a candidate the node could not compose names modules")
}
if a.Returns != "" && (a.Refused != "" || len(a.Refusals) > 0) {
return fmt.Errorf("delivery: a candidate that takes the node's placement away is compared, never refused")
return fmt.Errorf("delivery: a candidate the node returns to the controller's compositions on is compared, never refused: " +
"a composition the controller holds none of is refused without returning")
}
for _, d := range a.Differs {
if d.Module == "" || d.Why == "" {
+24 -134
View File
@@ -65,13 +65,6 @@ const (
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
OpConfig = "config" // set a module's config placement or values on the node
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
OpAddNode = "add-node"
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
// (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
OpRemoveNode = "remove-node"
)
// The states of an open proposal.
@@ -114,34 +107,31 @@ type PlacementProposalChange struct {
Values map[string]any `json:"values,omitempty"`
// Whole is the node's whole placement (OpSeed).
Whole *Placement `json:"whole,omitempty"`
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
// them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's
// decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest.
Decided *NodeDecided `json:"decided,omitempty"`
Keys *JoiningKeys `json:"keys,omitempty"`
}
// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given
// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the
// operator's yes carries the tunnel key alone. Public halves alone, never a private key.
type JoiningKeys struct {
Identity string `json:"identity"`
Overlay string `json:"overlay,omitempty"`
Sealing string `json:"sealing"`
Serving string `json:"serving,omitempty"`
}
// Digest is the keys' digest, as the enrolment's key holds it (KeysDigest).
func (k JoiningKeys) Digest() string { return KeysDigest(k.Identity, k.Overlay, k.Sealing, k.Serving) }
// AddNodeWarrant is the warrant an add-node carries (ADR 0587 §7): the enrolment's node and address as its issue's ask
// was bound to them, the ask's id, and the warrant's id as the teller wrote it. mesh-placement checks it names the
// change's node and address, and that the caller is mesh-enrolment as the runtime proves it (AddNodeRefusal).
type AddNodeWarrant struct {
Node string `json:"node"`
Address string `json:"address"`
Ask string `json:"ask"`
Warrant string `json:"warrant"`
// BeforeLease is why the change cannot be proposed before the lease write, or "" (ADR 0567 §8): until then the holder
// keeps the controller's assignments in step through its verbs assign, unassign, pin and unpin, so only those are
// proposable — a module placed or taken off with no host port or binding, a pin set or removed, a module's config values
// and the seed. A host port, a binding, a holding, a decided field and a config layer's places or accesses wait for the
// lease write, which the refusal names.
func (c PlacementProposalChange) BeforeLease() string {
const lease = "which the controller's verbs cannot take from a caller, so it waits for the lease write (ADR 0567 §8)"
switch c.Op {
case OpPlace:
if len(c.Ports)+len(c.Bindings) > 0 {
return fmt.Sprintf("the place of %s on %s names host ports or bindings, %s", c.Module, c.Node, lease)
}
case OpTakeOff, OpPin, OpUnpin, OpSeed:
case OpConfig:
// A module's config values are the state's alone (§13); its layers' places and accesses are the controller's
// until the lease write.
if c.MeshWide != nil || c.Layer != nil {
return fmt.Sprintf("the config of %s on %s sets a layer's places or accesses, %s", c.Module, c.Node, lease)
}
default:
return fmt.Sprintf("a change %s of %s is no assignment or pin, %s", c.Op, c.Node, lease)
}
return ""
}
func (c PlacementProposalChange) check() error {
@@ -170,28 +160,8 @@ func (c PlacementProposalChange) check() error {
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
}
return c.Whole.check()
case OpAddNode:
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node)
}
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
}
if c.Decided.Hub {
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
}
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
}
return nil
case OpRemoveNode:
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node)
}
return nil
}
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
}
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
@@ -230,23 +200,6 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
switch c.Op {
case OpSeed:
p = clonePlacement(*c.Whole)
case OpAddNode:
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
if _, has := current[c.Node]; has {
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
continue
}
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
case OpRemoveNode:
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
// anything is taken off module by module, by a proposal the operator approves).
cur, has := current[c.Node]
if !has {
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
} else if len(cur.Modules)+len(cur.Holds) > 0 {
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
}
continue
case OpPlace:
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
if at >= 0 {
@@ -450,9 +403,6 @@ type PlacementProposal struct {
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
// value.
Again time.Time `json:"again,omitempty"`
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7): its Approve is the issue's, so the holder
// writes it with no ask of its own. Only an add-node carries one.
Warrant *AddNodeWarrant `json:"warrant,omitempty"`
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
// placement-withdrawn, which only the holder's process can say on its stream.
Withdrawn string `json:"withdrawn,omitempty"`
@@ -494,69 +444,9 @@ func (p PlacementProposal) check() error {
if p.Seed && p.ExportDigest == "" {
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
}
return p.checkAddNode()
}
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
// proposal (ADR 0587 §7).
func (p PlacementProposal) checkAddNode() error {
adds := 0
for _, c := range p.Changes {
if c.Op == OpAddNode || c.Op == OpRemoveNode {
adds++
}
}
if adds == 0 {
if p.Warrant != nil {
return fmt.Errorf("delivery: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
}
return nil
}
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
return fmt.Errorf("delivery: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
}
c, w := p.Changes[0], p.Warrant
if w == nil || w.Ask == "" || w.Warrant == "" {
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
}
if c.Op == OpRemoveNode {
if w.Node != c.Node {
return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
}
return nil
}
if w.Node != c.Node || w.Address != c.Decided.Address {
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
w.Node, w.Address)
}
return nil
}
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
func (p PlacementProposal) RemovedNodes() []string {
var out []string
for _, c := range p.Changes {
if c.Op == OpRemoveNode {
out = append(out, c.Node)
}
}
return out
}
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
// refuses.
func AddNodeRefusal(p PlacementProposal, caller string) string {
if err := p.checkAddNode(); err != nil {
return err.Error()
}
if p.Warrant != nil && caller != EnrolmentModule {
return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller)
}
return ""
}
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
if err := p.check(); err != nil {
+19 -96
View File
@@ -388,8 +388,8 @@ func TestPlacement(t *testing.T) {
"a value": {Proposal: id, Revision: 1, Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
// A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126,
// the seed's revert, mesh-catalog #526).
"returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToNothing, Same: []string{"app", "node"}},
"returns, differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToNothing, Same: []string{"node"},
"returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}},
"returns, the controller's differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"},
Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}},
}
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers,
@@ -407,15 +407,11 @@ func TestPlacement(t *testing.T) {
t.Error("a node's have without its answers counts as the same")
}
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers,
ComposesWhy: n.ComposesWhy}) {
Composes: ComposesController, ComposesWhy: n.ComposesWhy}) {
t.Error("a node's have composing otherwise counts as the same")
}
if _, err := EncodeHaveNode(HaveNode{Composes: "both"}); err == nil {
t.Error("a node composing neither its own nor none was taken")
}
// Issue 1126 B4a: a have an engine from before the controller ended wrote still reads.
if _, err := DecodeHaveNode([]byte(`{"v":1,"composes":"controller"}`)); err != nil {
t.Errorf("an older engine's have is refused: %v", err)
t.Error("a node composing neither its own nor the controller's was taken")
}
// CompareComposed: the bus's user list is left out on both sides; anything else that differs is named.
file := func(id, content string) json.RawMessage {
@@ -609,8 +605,8 @@ func TestPlacement(t *testing.T) {
"same with an added": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
"differs refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"refused and modules": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
"returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToNothing, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToNothing, Refused: "x"},
"returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
} {
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
t.Errorf("%s: taken", name)
@@ -655,6 +651,19 @@ func TestPlacement(t *testing.T) {
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
}
}
for change, waits := range map[*PlacementProposalChange]bool{
{Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false,
{Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false,
{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true,
{Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true,
{Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true,
{Op: OpConfig, Node: "g14", Values: map[string]any{"puid": 1}}: false, {Op: OpConfig, Node: "g14", MeshValues: map[string]any{"a": 1}}: false,
{Op: OpConfig, Node: "g14", Layer: map[string]any{"places": 1}}: true, {Op: OpConfig, Node: "g14", MeshWide: map[string]any{"accesses": 1}}: true,
} {
if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) {
t.Errorf("%+v before the lease write: %q", *change, got)
}
}
const id = "p-20261028t183000-1a2b3c"
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil {
@@ -693,92 +702,6 @@ func TestPlacement(t *testing.T) {
if ChangesDigest(p) == ChangesDigest(seed) {
t.Error("two proposals' changes digest alike")
}
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
Keys: &JoiningKeys{Overlay: "wg"}}
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
if raw, err := EncodePlacementProposal(add); err != nil {
t.Errorf("an add-node: %v", err)
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" {
t.Errorf("an add-node round trip: %+v, %v", back, err)
}
if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, EnrolmentModule), AddNodeRefusal(add, AssignerModule))
}
if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 {
t.Errorf("an add-node laid: %+v, %v", laid, f)
}
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
t.Errorf("an add-node of a placed node: %v", f)
}
for name, mut := range map[string]func(*PlacementProposal){
"warrant of another node": func(p *PlacementProposal) {
p.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
},
"warrant of another address": func(p *PlacementProposal) {
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
},
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
"no tunnel key": func(p *PlacementProposal) {
c := joining
c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"}
p.Changes = []PlacementProposalChange{c}
},
"half its identity keys": func(p *PlacementProposal) {
c := joining
c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"}
p.Changes = []PlacementProposalChange{c}
},
"beside another change": func(p *PlacementProposal) {
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
},
"the hub": func(p *PlacementProposal) {
c := joining
c.Decided = &NodeDecided{Address: "10.0.0.7", Hub: true}
p.Changes = []PlacementProposalChange{c}
},
"a warrant on a move": func(p *PlacementProposal) {
p.Changes, p.Base = []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]uint64{"g14": 4}
},
} {
q := add
q.Changes = append([]PlacementProposalChange(nil), add.Changes...)
mut(&q)
if _, err := EncodePlacementProposal(q); err == nil {
t.Errorf("an add-node with %s: taken", name)
}
}
// With its identity keys whole, as bound at redeem, it is taken too.
whole := add
whole.Changes = []PlacementProposalChange{joining}
whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
if _, err := EncodePlacementProposal(whole); err != nil {
t.Errorf("an add-node with its identity keys: %v", err)
}
// An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing.
remove := add
remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}}
if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" ||
AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) {
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule))
}
joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}}
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
t.Errorf("a joining node removed: %v, %v", laid, f)
}
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
!strings.Contains(f[0], "runs modules") {
t.Errorf("a running node removed: %v", f)
}
remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
if _, err := EncodePlacementProposal(remove); err == nil {
t.Error("a remove-node on another node's warrant: taken")
}
for name, mut := range map[string]func(*PlacementProposal){
"no id": func(p *PlacementProposal) { p.ID = "" },
"no proposer": func(p *PlacementProposal) { p.By = "" },
+11 -9
View File
@@ -24,8 +24,9 @@ import (
// serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key.
//
// A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may
// write `signers`, the same trust the wants and the placement rest on. A node trusts the vault's key alone since the
// controller ended (ADR 0590). What a node trusts now it states in its record, NodeRecord.Trusts.
// write `signers`, the same trust the wants and the placement rest on. Until the controller's lease is handed away the
// node trusts the controller's key beside it; from then on the vault's alone (LeaseHandedAway). What a node trusts now
// it states in its record, NodeRecord.Trusts, so the gate before the lease write reads it from the bus.
const (
// VaultModule is the module holding the seat mesh-vault, whose state `signers` is.
@@ -47,17 +48,18 @@ const (
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
KindNodeCertificate = "node-certificate"
// KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for mesh-enrolment alone (ADR 0587
// §3, §6).
// KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record
// (ADR 0561 §5).
KindEnrolment = "enrolment"
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
KindAuthority = "authority"
// SignerVault names the signer a node trusts.
SignerVault = "vault"
// SignerController and SignerVault name the two signers a node may trust.
SignerController = "controller"
SignerVault = "vault"
// CertificateLife is how long a certificate the vault signs for a node lasts: ten years, renewed by asking again,
// never by a clock running out unseen.
// CertificateLife is how long a certificate the vault signs for a node lasts: the controller's ten years
// (identity.forever), renewed by asking again, never by a clock running out unseen.
CertificateLife = 10 * 365 * 24 * time.Hour
)
@@ -164,7 +166,7 @@ func DecodeSignerAuthority(raw []byte) (SignerAuthority, error) {
return a, nil
}
// Signer is one key a node trusts, by who holds it (SignerVault, or the operator's).
// Signer is one key a node trusts, by who holds it (SignerController, SignerVault, or the operator's).
type Signer struct {
Name string
Key ed25519.PublicKey
+2 -6
View File
@@ -344,8 +344,7 @@ type AskArtifact struct {
// CheckJob is a check ask's job, the levels of one check (ADR 0525 §3): fetch the head merged onto its trunk once, then
// for each level in order digest its packages, take the kept verdicts for the digests that have one, and test the rest,
// the packages the change's own tests are in first, every level and package running to its end also after a failure,
// so one check names every failure (CheckAnswer).
// the packages the change's own tests are in first, stopping at the first level that does not pass (CheckAnswer).
type CheckJob struct {
// Fetch is the fetch tool's arguments (repository, commit, ref, base, and the build module's own: beside, package).
Fetch map[string]any `json:"fetch"`
@@ -370,9 +369,6 @@ type CheckJob struct {
// has to list them. The build step hands them to its test tool as the argument NodeNamesArg, and the build module
// gives them to the tests in NodeNamesEnv.
NodeNames []string `json:"node-names,omitempty"`
// Toolchain is the toolchain the checker agreed for the check's language (novox/hq ADR 0584): a node building with
// another declines the ask, so a pass is never recorded as run on a version it did not run on. Nil names none.
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
}
// NodeNamesArg is the test tool's argument that carries a check's node names, and NodeNamesEnv the environment
@@ -565,7 +561,7 @@ type AskBuilt struct {
}
// CheckAnswer is a check ask's step result, each part the build module's own tool's answer whole: the fetch's, then one
// LevelAnswer per level run, in order, every asked level also after one that did not pass. The checker judges and keeps from it as it
// LevelAnswer per level run, in order; a level that did not pass is the last. The checker judges and keeps from it as it
// did from the tools' answers.
type CheckAnswer struct {
Fetch json.RawMessage `json:"fetch"`
-118
View File
@@ -1,118 +0,0 @@
package delivery
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate
// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the
// machine's tunnel key, the machine's first tunnel (ADR 0169).
//
// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the
// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the
// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue`
// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of
// ADR 0587 §3, 2026-11-02).
type Token struct {
Version int `json:"v"`
// Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before
// the mesh can say anything.
Node string `json:"node,omitempty"`
Broker string `json:"broker,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Signer []byte `json:"signer,omitempty"`
Secret string `json:"secret"`
// Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node.
Adopted bool `json:"adopted,omitempty"`
// Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own
// address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet.
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
}
// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes.
type TokenTunnel struct {
Key string `json:"key"`
Address string `json:"address"`
Range string `json:"range"`
HubKey string `json:"hub_key"`
HubEndpoint string `json:"hub_endpoint"`
}
// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong
// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token
// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects
// to whatever answers, without the signing key it cannot tell a declaration from a forgery.
func ParseToken(encoded string) (Token, error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
var t Token
if err := json.Unmarshal(raw, &t); err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
if t.Version != 1 {
return Token{}, fmt.Errorf(
"this token says it is version %d, and this host understands version 1", t.Version)
}
if missing := t.Missing(); len(missing) > 0 {
return Token{}, fmt.Errorf(
"this token is missing %s, so it cannot be used to join anything",
strings.Join(missing, ", "))
}
return t, nil
}
// Missing names every part of t a joining machine needs and t lacks.
func (t Token) Missing() []string {
var missing []string
if strings.TrimSpace(t.Broker) == "" {
missing = append(missing, "the broker's address")
}
if strings.TrimSpace(t.Fingerprint) == "" {
missing = append(missing, "the broker certificate's fingerprint")
}
if len(t.Signer) != ed25519.PublicKeySize {
missing = append(missing, "the vault's signing key")
}
if strings.TrimSpace(t.Secret) == "" {
missing = append(missing, "the one-time secret")
}
if tt := t.Tunnel; tt != nil {
for _, part := range []struct{ value, says string }{
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
} {
if strings.TrimSpace(part.value) == "" {
missing = append(missing, part.says)
}
}
}
return missing
}
// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the
// controller's `token issue` writes it. Refused when a part is missing.
func (t Token) Encode() (string, error) {
t.Version = 1
if missing := t.Missing(); len(missing) > 0 {
return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", "))
}
raw, err := json.Marshal(t)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after.
func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) }
-53
View File
@@ -1,53 +0,0 @@
package delivery
import (
"encoding/hex"
"reflect"
"strings"
"testing"
)
// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9,
// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519
// key of the seed 00 01 .. 1f. Copied, never written by hand.
const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19"
// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it
// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens.
func TestATokenTheControllerMadeReads(t *testing.T) {
signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8")
want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222",
Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true,
Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24",
HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}}
got, err := ParseToken(" " + controllerToken + "\n")
if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 {
t.Fatalf("parsed %+v, %v", got, err)
}
if again, err := got.Encode(); err != nil || again != controllerToken {
t.Errorf("encoded again: %q, %v", again, err)
}
for _, c := range []struct {
cut func(*Token)
expect string
}{
{func(t *Token) { t.Broker = "" }, "broker's address"},
{func(t *Token) { t.Fingerprint = "" }, "fingerprint"},
{func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"},
{func(t *Token) { t.Secret = "" }, "one-time secret"},
{func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"},
} {
cut := want
cut.Tunnel = &TokenTunnel{}
*cut.Tunnel = *want.Tunnel
c.cut(&cut)
if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) {
t.Errorf("a token without %s: %v", c.expect, err)
}
}
for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} {
if _, err := ParseToken(bad); err == nil {
t.Errorf("%q parsed as a token", bad)
}
}
}
+10 -42
View File
@@ -99,47 +99,22 @@ type NodeToolchain struct {
Toolchain
}
// SittingOut is a node whose toolchain of a language is not the agreed one: it builds nothing of that language until
// it reports the agreed version (novox/hq ADR 0584 clause 2).
type SittingOut struct {
Node string
Version string
Recipe string
}
// Agreement is what the nodes' entries of one language come to (novox/hq ADR 0584). Toolchain is the agreed one, set
// when Wait is empty. Wait is why nothing is agreed: "toolchain not yet reported" with no entries, or "builders
// disagree" naming each version and recipe with the nodes reporting it, when no version has a strict majority.
// SittingOut lists, sorted by node, the nodes that report another version than the agreed one.
type Agreement struct {
Toolchain Toolchain2
SittingOut []SittingOut
Wait string
}
// ToolchainAgreement is the agreed toolchain of a language: the version and recipe a strict majority of the
// reporting nodes report (novox/hq ADR 0584 clause 1). With no strict majority (two against two) or no entry it
// gives no toolchain and says why in Wait. The answer does not depend on the entries' order. The entries must all be
// of one language.
func ToolchainAgreement(entries []NodeToolchain) Agreement {
// AgreedToolchain is the one Toolchain2 every node's entry of a language agrees on, or none and why: "toolchain not
// yet reported" with no entries, and "builders disagree" naming each differing version and recipe with the nodes
// reporting it when two differ (novox/hq ADR 0405 clause 2). The answer does not depend on the entries' order. The
// entries must all be of one language.
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
if len(entries) == 0 {
return Agreement{Wait: ToolchainNoReport}
return Toolchain2{}, ToolchainNoReport
}
groups := map[Toolchain2][]string{}
for _, e := range entries {
k := Toolchain2{Language: e.Language, Version: e.Version, Recipe: e.Recipe}
groups[k] = append(groups[k], e.Node)
}
for k, nodes := range groups {
if 2*len(nodes) > len(entries) {
a := Agreement{Toolchain: k}
for _, e := range entries {
if e.Version != k.Version || e.Recipe != k.Recipe {
a.SittingOut = append(a.SittingOut, SittingOut{Node: e.Node, Version: e.Version, Recipe: e.Recipe})
}
}
sort.Slice(a.SittingOut, func(i, j int) bool { return a.SittingOut[i].Node < a.SittingOut[j].Node })
return a
if len(groups) == 1 {
for k := range groups {
return k, ""
}
}
parts := make([]string, 0, len(groups))
@@ -148,12 +123,5 @@ func ToolchainAgreement(entries []NodeToolchain) Agreement {
parts = append(parts, fmt.Sprintf("%s recipe %s on %s", k.Version, k.Recipe, strings.Join(nodes, ", ")))
}
sort.Strings(parts)
return Agreement{Wait: "builders disagree: " + strings.Join(parts, "; ")}
}
// AgreedToolchain is ToolchainAgreement's toolchain, or none and why (its Wait): the version a strict majority of the
// nodes report (novox/hq ADR 0584, which changes ADR 0405 clause 2 and ADR 0477 clause 2).
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
a := ToolchainAgreement(entries)
return a.Toolchain, a.Wait
return Toolchain2{}, "builders disagree: " + strings.Join(parts, "; ")
}
+30 -44
View File
@@ -55,50 +55,36 @@ func TestToolchainKeyAndValue(t *testing.T) {
}
}
// 1443/AC2-4, ADR 0584: the agreed toolchain is the strict majority's; a minority node sits out; a tie waits.
func TestToolchainAgreement(t *testing.T) {
e := tcEntry
for _, c := range []struct {
name string
in []NodeToolchain
version string
out []SittingOut
wait []string // "" wait means agreed
}{
{"all agree", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
{"three against one", []NodeToolchain{e("ace", "typescript", "26.12.1", "3"), e("g14", "typescript", "26.13.0", "3"), e("novox", "typescript", "26.13.0", "3"), e("shanks", "typescript", "26.13.0", "3")},
"26.13.0", []SittingOut{{"ace", "26.12.1", "3"}}, nil},
{"two against two", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.2", "3"), e("g14", "go", "1.26.1", "3"), e("shanks", "go", "1.26.2", "3")},
"", nil, []string{"builders disagree", "1.26.1 recipe 3 on ace, g14", "1.26.2 recipe 3 on novox, shanks"}},
{"one node alone", []NodeToolchain{e("ace", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
{"no report", nil, "", nil, []string{ToolchainNoReport}},
} {
a := ToolchainAgreement(c.in)
rev := make([]NodeToolchain, len(c.in))
for i, x := range c.in {
rev[len(c.in)-1-i] = x
// 1443/AC2
func TestAgreedToolchain(t *testing.T) {
tc, none := AgreedToolchain([]NodeToolchain{tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.1", "3")})
if tc != (Toolchain2{Language: "go", Version: "1.26.1", Recipe: "3"}) || none != "" {
t.Errorf("%+v %q", tc, none)
}
}
// 1443/AC3
func TestAgreedToolchainNoReport(t *testing.T) {
tc, none := AgreedToolchain(nil)
if tc != (Toolchain2{}) || none != "toolchain not yet reported" {
t.Errorf("%+v %q", tc, none)
}
}
// 1443/AC4
func TestAgreedToolchainDisagree(t *testing.T) {
a, b, c := tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.2", "3"), tcEntry("g14", "go", "1.26.1", "4")
_, first := AgreedToolchain([]NodeToolchain{a, b, c})
_, second := AgreedToolchain([]NodeToolchain{c, b, a})
if first == "" || first != second {
t.Errorf("order changed the answer: %q vs %q", first, second)
}
for _, want := range []string{"builders disagree", "1.26.1", "1.26.2", "ace", "novox", "g14"} {
if !strings.Contains(first, want) {
t.Errorf("%q does not name %q", first, want)
}
if b := ToolchainAgreement(rev); b.Wait != a.Wait || b.Toolchain != a.Toolchain {
t.Errorf("%s: order changed the answer: %+v vs %+v", c.name, a, b)
}
if a.Toolchain.Version != c.version || len(a.SittingOut) != len(c.out) {
t.Errorf("%s: %+v", c.name, a)
}
for i := range c.out {
if a.SittingOut[i] != c.out[i] {
t.Errorf("%s: sitting out %+v, want %+v", c.name, a.SittingOut, c.out)
}
}
if (len(c.wait) == 0) != (a.Wait == "") {
t.Errorf("%s: wait %q", c.name, a.Wait)
}
for _, w := range c.wait {
if !strings.Contains(a.Wait, w) {
t.Errorf("%s: %q does not name %q", c.name, a.Wait, w)
}
}
if tc, none := AgreedToolchain(c.in); tc != a.Toolchain || none != a.Wait {
t.Errorf("%s: AgreedToolchain %+v %q", c.name, tc, none)
}
}
if tc, _ := AgreedToolchain([]NodeToolchain{a, b}); tc != (Toolchain2{}) {
t.Errorf("a disagreement gave %+v", tc)
}
}
+13 -10
View File
@@ -21,10 +21,11 @@ import (
// list's digest (UserListObjectName).
// - It writes the key GrantsUserListKey of its state `user-list` (bucket GrantsUserListBucket): the GrantsUserList
// below, which names the object, the list's digest, its revision, its user count and the users left out, and which
// source the bus node follows (Follow: FollowGrants, the one source since the controller ended, ADR 0590).
// - The node-engine of the bus node watches that key alone. It opens the object, checks the digest, writes the bus's
// user-list file (mode 0600) only when the file's digest differs, and says what it loaded in its have of the bus
// module (UserListWritten, Source GrantsUserListSource).
// source the bus node follows (Follow: FollowGrants from take-over, FollowController in shadow and after hand-back).
// - The node-engine of the bus node watches that key alone. While it says FollowGrants it opens the object, checks
// the digest, writes the bus's user-list file (mode 0600) only when the file's digest differs, and says what it
// loaded in its have of the bus module (UserListWritten, Source GrantsUserListSource); otherwise it follows the
// controller's compositions as before (clause 8). Never both.
// The names of the grants part's user list on the bus.
const (
@@ -40,13 +41,16 @@ const (
GrantsUserListSource = GrantsUserListBucket + "." + GrantsUserListKey
)
// FollowGrants is the list the bus node follows: the grants part's, the one source (ADR 0590).
const FollowGrants = "grants"
// Which list the bus node follows.
const (
FollowController = "controller"
FollowGrants = "grants"
)
// GrantsUserList is the value of the grants part's key `user-list.sealed`.
type GrantsUserList struct {
V int `json:"v"`
// Follow is which list the bus node follows: FollowGrants, the one value it takes.
// Follow is which list the bus node follows: FollowGrants from take-over, FollowController otherwise.
Follow string `json:"follow"`
// Digest is the list's digest, `sha256:<hex>` of the file's bytes as the bus loads them.
Digest string `json:"digest"`
@@ -88,9 +92,8 @@ func DecodeGrantsUserList(data []byte) (GrantsUserList, error) {
}
func (g GrantsUserList) check() error {
if g.Follow != FollowGrants {
return fmt.Errorf("delivery: the user list's follow %q is not %s, the one list since the controller ended (ADR 0590)",
g.Follow, FollowGrants)
if g.Follow != FollowGrants && g.Follow != FollowController {
return fmt.Errorf("delivery: the user list's follow %q is not %s or %s", g.Follow, FollowGrants, FollowController)
}
if g.Digest == "" {
return nil // a switch with no list written yet: the bus node keeps what it follows
+11 -3
View File
@@ -21,6 +21,8 @@ const (
WarrantsState = "warrants"
// EventRecorded is said on the seat for every act, with the act as its body.
EventRecorded = "act-recorded"
// ControllerActs is the controller's log as a reader names it, read until its lease ends.
ControllerActs = "mesh-controller.hand-acts"
)
// KeptFor is how long an act is kept.
@@ -61,12 +63,15 @@ type Act struct {
RequestedBy string `json:"requested-by,omitempty"`
// Outcome is what came of an act recorded after it was done.
Outcome string `json:"outcome,omitempty"`
// Log is which log holds it, set when it is read (LogSeat); never written.
// Log is which log holds it, set when it is read (LogSeat or LogController); never written.
Log string `json:"log,omitempty"`
}
// LogSeat is the log an act is held in: the seat's (the ended controller's log is read from its backup, ADR 0590).
const LogSeat = Seat
// The logs an act may be held in.
const (
LogSeat = Seat
LogController = "mesh-controller"
)
// Repair says the act counts toward a cause repeated (S15): not a drill, not an operator's answer, not recorded builds.
func (a Act) Repair() bool {
@@ -94,6 +99,9 @@ type Taken struct {
type Recorder struct {
// Ask calls a seat verb by its address (`seat:<seat>.<verb>`): stdio.Ask through the runtime.
Ask func(key string, body any) (json.RawMessage, error)
// LeaseAway is no longer read: nothing falls back to the controller. Kept so a caller that sets it builds; it goes
// once no module sets it.
LeaseAway func() bool
}
// NoResponder says err is the bus finding nobody to answer: the seat has no holder now.
+2 -1
View File
@@ -31,6 +31,7 @@ func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
name string
seat error
ctrl error
away bool
log string
refused string
asked string
@@ -46,7 +47,7 @@ func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
{name: "a warrant recorded", warrants: true, log: LogSeat, asked: "seat:mesh-hand-acts.warranted"},
} {
var asked []string
r := Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
r := Recorder{LeaseAway: func() bool { return c.away }, Ask: func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key)
if strings.HasPrefix(key, "seat:mesh-hand-acts.") {
return json.RawMessage(`{}`), c.seat

Some files were not shown because too many files have changed in this diff Show More