Compare commits

..
3 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
56 changed files with 224 additions and 3187 deletions
+11 -27
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
+3 -9
View File
@@ -103,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()
@@ -112,16 +112,10 @@ 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
-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 }
-4
View File
@@ -219,10 +219,6 @@ type LeaseHolder struct {
// 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"`
// Ask is the operator's ask whose warrant handed it (novox/hq ADR 0582 clause 3: "with the why, the ask and the
// time"), and At when it was written. Neither turns the lease: LeaseHandedAway reads HandedTo alone.
Ask string `json:"ask,omitempty"`
At time.Time `json:"at,omitzero"`
}
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
+7 -234
View File
@@ -3,7 +3,6 @@ package buscompose
import (
"encoding/json"
"errors"
"maps"
"os"
"path/filepath"
"reflect"
@@ -97,9 +96,6 @@ func engineBeyond(user, g string) bool {
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, read, the controller's mirror's key of its node, until the lease write (novox/hq ADR 0565 clause 6).
strings.Contains(g, "KV_"+DeclaredPackagesBucket) && !strings.HasPrefix(g, "$KV.") &&
(!strings.Contains(g, "$KV.") || strings.HasSuffix(g, "$KV."+DeclaredPackagesBucket+"."+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).
@@ -162,11 +158,8 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
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). And it no longer hears
// the subject, where the grants part stops publishing at the lease write (ADR 0571 clause 10, issue 774).
node := strings.SplitN(user, ".", 2)[0]
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+node+".") &&
!strings.HasPrefix(g, "mesh.assignment."+node+".") {
// 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)
}
}
@@ -420,156 +413,6 @@ 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): from the lease write an enrolling node's user may publish the redeem call in its
// own name and nothing else, and subscribe its own inbox alone; before it, the controller's subject beside.
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"}}
for _, handed := range []bool{true, false} {
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, LeaseHandedAway: handed,
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 !handed {
want = []string{"mesh.control.enrol", "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("handed %v: enrol.lab publishes %v, subscribes %v, answers %v", handed, perms.Publish, perms.Subscribe,
perms.AllowResponses)
}
}
// No holder serving redeem and the lease handed: the user may enrol nowhere, refused by name.
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab", LeaseHandedAway: true}); 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 controller's golden server configuration", func(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
got, err := Compose(
@@ -715,9 +558,6 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}{
{"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},
// ADR 0582 clause 3 (774/S3): the write names the ask and the time, which turn nothing.
{"handed to mesh-grants with its ask and time", `{"handed-to":"mesh-grants","handed-by":"person.jochen","why":"w",` +
`"ask":"lease-write-1","at":"2026-11-02T10:00:00Z"}`, true, nil, true},
{"expired or deleted", ``, false, nil, false},
{"unreadable", ``, false, errors.New("refused"), false},
{"not a holder", `[1]`, true, nil, false},
@@ -793,48 +633,6 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
}
}
// ADR 0582 clause 3 (issue 774, 774/S2): the lease write's cut takes from the controller the lease's key and every
// update, purge and delete of its bucket, and gives the grants part the key holder alone; without it nothing
// changes (the golden lists above are composed without it).
lease := "$KV." + LeaseBucket + ".holder"
for _, cut := range []bool{false, true} {
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: GrantsModule}}},
LeaseCut: cut})
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
kind Kind
subject string
may bool
}{
{KindController, lease, !cut},
{KindController, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.PURGE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.DELETE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.MSG.DELETE.KV_" + LeaseBucket, !cut},
// It still reads the lease, and keeps its other buckets.
{KindController, "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder", true},
{KindController, "$KV." + CallsBucket + ".x", true},
{KindController, "$JS.API.STREAM.UPDATE.KV_" + CallsBucket, true},
{KindModule, lease, cut},
{KindModule, "$KV." + LeaseBucket + ".other", false},
{KindModule, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, false},
} {
for _, u := range users {
if u.Kind != c.kind || (c.kind == KindModule && u.Module != GrantsModule) {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatalf("cut %v: %s: %v", cut, u.Username(), err)
}
if MayPublish(perms, c.subject) != c.may {
t.Errorf("cut %v: %s may publish %s: %v, want %v", cut, u.Username(), c.subject, !c.may, c.may)
}
}
}
}
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
healthChecks(t)
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
@@ -942,9 +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",
// ADR 0582 clause 3 (issue 774, 774/S2): the lease's bucket's expiry, read and set by its tool.
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
"$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).
@@ -987,13 +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 the lease's bucket alone, and neither purges nor deletes it nor writes its key (ADR 0582 clause 3).
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
"$JS.API.STREAM.PURGE.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
"$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"},
}
@@ -1024,27 +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: and it reads the controller's mirror's key of its own node alone, and writes none of it.
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") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.b") ||
MayPublish(perms, "$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).
@@ -1102,10 +878,7 @@ func healthChecks(t *testing.T) {
// It hears its watchdogs' words on core subjects (Limits 3, 11).
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak",
// ADR 0582 clauses 4, 6 (issue 774, 774/S22): the lease write's events, and ASSIGNMENTS deleted.
"mesh.seat.mesh-grants.event.lease-altered", "mesh.seat.mesh-grants.event.removal-failed",
"mesh.seat.mesh-grants.event.lease-handed", "$JS.EVENT.ADVISORY.STREAM.DELETED.ASSIGNMENTS"} {
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
if !MaySubscribe(perms, subject) {
t.Errorf("the watchman may not hear %s", subject)
}
+1 -3
View File
@@ -74,11 +74,9 @@ func engineGrants(p Principal) (pub, sub []string) {
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
pub = append(pub, own...)
}
// And its node's declared packages, its own key alone (ADR 0565 clause 6); and, read, the controller's mirror's key
// of its node, which the node seat's verb packages compares it with until the lease write ends the mirror.
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
if safeSubject.MatchString(p.Node) {
pub = append(pub, EnginePackagesGrants(p.Node)...)
pub = append(pub, readGrants(DeclaredPackagesBucket, p.Node)...)
}
return append(pub, tp...), append(sub, ts...)
}
+1 -7
View File
@@ -114,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 {
@@ -129,12 +129,6 @@ func WatchmanHears() []string {
"mesh.mod.nats.event.bus.connection-closed",
ProvAppliedSubjects,
deadLetterPrefix + ">",
// And the lease write's (ADR 0582 clauses 4, 6; issue 774): the seat mesh-grants' three events, and the stream
// ASSIGNMENTS deleted, which with the controller's consumer deleted clears a removal that failed.
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseHanded,
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseAltered,
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventRemovalFailed,
"$JS.EVENT.ADVISORY.STREAM.DELETED." + AssignmentsStream,
}
}
-41
View File
@@ -1,41 +0,0 @@
package buscompose
import "slices"
// **The lease write's grants** (novox/hq ADR 0582 clause 3, issue 774). Composed with Records.LeaseCut, the list takes
// from the controller its write of the lease's key and every update, purge and delete of the lease's bucket, and gives
// the grants part the key `holder`, which it creates naming itself and deletes at a hand-back (clause 5). Whatever the
// records, the bus module's tools account may read and update the lease's bucket, for the tool that removes and
// restores its expiry; no other bucket's.
// leaseStream is the lease bucket's stream.
var leaseStream = "KV_" + LeaseBucket
// LeaseHolderWrite is the grants part's write of the lease's one key, from the cut.
func LeaseHolderWrite() string { return "$KV." + LeaseBucket + "." + leaseKey }
// LeaseCutDenied are the controller's subjects of the lease's bucket that its `$JS.API.>` reaches and the cut refuses:
// the bucket's update (its expiry), purge and delete, and a message's delete (the key's last value).
func LeaseCutDenied() []string {
return []string{"$JS.API.STREAM.UPDATE." + leaseStream, "$JS.API.STREAM.PURGE." + leaseStream,
"$JS.API.STREAM.DELETE." + leaseStream, "$JS.API.STREAM.MSG.DELETE." + leaseStream}
}
// LeaseExpiryGrants are the bus module's tools account's read and update of the lease's bucket, for its expiry.
func LeaseExpiryGrants() []string {
return []string{"$JS.API.STREAM.INFO." + leaseStream, "$JS.API.STREAM.UPDATE." + leaseStream}
}
// writesTheLease is the controller holding the lease, or, from the cut, the grants part writing its key holder alone.
func writesTheLease(p Principal, pattern string) bool {
if p.Kind == KindController {
return true
}
return p.Kind == KindModule && p.Module == GrantsModule && p.LeaseCut && pattern == LeaseHolderWrite()
}
// setsLeaseExpiry is the bus module's tools account updating the lease's bucket: the writers table's one exemption from
// stream definitions being the controller's beside ASSIGNMENTS' removal (ADR 0582 clause 3).
func setsLeaseExpiry(p Principal, pattern string) bool {
return p.Kind == KindProcess && len(p.Removes) > 0 && slices.Contains(LeaseExpiryGrants(), pattern)
}
+10 -131
View File
@@ -22,7 +22,6 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -202,9 +201,6 @@ type Principal struct {
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
Removes []string
// LeaseCut is Records.LeaseCut on the controller and the grants part (novox/hq ADR 0582 clause 3): the controller
// loses its write of the lease, and the grants part may write the key `holder`.
LeaseCut bool
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
DeadLetterSources []string
@@ -254,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
@@ -324,22 +300,6 @@ func ControllerOnly() []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 {
@@ -417,33 +377,6 @@ 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}
)
// 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}
// 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())
}
// 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)
}
// 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
@@ -592,9 +525,6 @@ 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"
@@ -654,14 +584,7 @@ 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 {
@@ -745,9 +668,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
if err != nil {
return Permissions{}, err
}
// And the lease's bucket's expiry, read and set, by the bus module's tool the grants part calls at the lease
// write and its hand-back (novox/hq ADR 0582 clauses 3, 5): the one bucket whose stream it may update.
pub = append(pub, LeaseExpiryGrants()...)
sort.Strings(pub)
if err := CheckWriters(p, pub); err != nil {
return Permissions{}, err
@@ -881,9 +801,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
if bucket == delivery.HaveBucket {
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
}
if bucket == LeaseBucket && p.LeaseCut {
continue // the lease write took it (novox/hq ADR 0582 clause 3): a controller started fails on the lease
}
pub = append(pub, "$KV."+bucket+".>")
}
// **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the
@@ -963,20 +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")
}
// **From the lease write a node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6): its
// user calls redeem in its own name, while the seat's holder serves it, and the controller's enrolment subject is
// kept only while the controller holds the lease.
pub = nil
if !p.LeaseHandedAway {
pub = append(pub, "mesh.control.enrol")
}
if p.Redeems {
pub = append(pub, EnrolmentRedeemCall(p))
}
if len(pub) == 0 {
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: the lease is handed away and "+
"no holder of %s serves %s", p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
}
pub = []string{"mesh.control.enrol"}
sub = []string{p.inbox()}
case KindNode:
@@ -1090,13 +994,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
}
// 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()))
}
// 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)
pub = append(pub, ep...)
@@ -1129,11 +1026,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...)
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
pub = append(pub, GrantsUserListWrites()...)
// And, from the lease write's cut, the lease's one key, which it creates naming itself and deletes at a
// hand-back (novox/hq ADR 0582 clauses 3, 5): never the bucket's other keys or its stream.
if p.LeaseCut {
pub = append(pub, LeaseHolderWrite())
}
}
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
if p.Module == ViewModule {
@@ -1169,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
@@ -1334,10 +1226,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
}
// 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.
@@ -1516,11 +1408,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
// What the controller alone may publish is denied to everybody else whose grant reaches it.
var deny []string
// And from the lease write's cut, the lease's bucket to the controller, whose `$JS.API.>` reaches it (novox/hq ADR
// 0582 clause 3): a controller started by mistake fails on the lease instead of taking it back.
if p.Kind == KindController && p.LeaseCut {
deny = append(deny, LeaseCutDenied()...)
}
if p.Kind != KindController {
for _, only := range ControllerOnly() {
for _, a := range pub {
@@ -1534,14 +1421,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
}
// 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)
}
}
}
return Permissions{
Publish: pub,
PublishDeny: deny,
+1 -6
View File
@@ -104,12 +104,7 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA
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.
+8 -58
View File
@@ -2,12 +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/delivery"
)
// Every user the composed file should contain, derived from what the mesh knows.
@@ -131,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
@@ -148,22 +145,9 @@ 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
// LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part
// reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The
// controller's composer never sets it. It is not LeaseCut: the cut is composed before the holder is written, so the
// grants part may write it, and outlasts the holder at a hand-back; the two are true together only in between.
LeaseHandedAway bool
// 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
// LeaseCut composes the list of the lease write (novox/hq ADR 0582 clause 3, issue 774): the controller may neither
// write the lease's key nor update, purge or delete its bucket, and the grants part may write the key `holder`.
// Set by the grants part from the write's second step until a hand-back composes without it (clause 5); the
// controller's composer never sets it.
LeaseCut bool
}
// Users is every user the composed file should contain, in the order it will be written.
@@ -173,7 +157,7 @@ type Records struct {
// correct.
func Users(r Records) ([]Principal, error) {
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes), LeaseCut: r.LeaseCut}}
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
served := servedReadsOf(r)
logs := DeclaredLogs(r)
@@ -191,14 +175,10 @@ func Users(r Records) ([]Principal, error) {
}
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, 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), 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] {
@@ -266,7 +246,6 @@ func Users(r Records) ([]Principal, error) {
}
if d.Module == GrantsModule {
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
out[len(out)-1].LeaseCut = r.LeaseCut
grants = len(out) - 1
}
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
@@ -290,8 +269,7 @@ func Users(r Records) ([]Principal, error) {
}
}
for _, node := range sortedCopy(r.Enrolling) {
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: servesVerb(r, EnrolmentRedeem),
LeaseHandedAway: r.LeaseHandedAway})
out = append(out, Principal{Kind: KindEnrolment, Node: node})
}
for _, person := range sortedNames(r.People) {
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
@@ -302,13 +280,6 @@ func Users(r Records) ([]Principal, error) {
out = append(out, p)
}
// 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
}
}
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
if grants >= 0 {
out[grants].CreatesConsumers = AccountConsumers(out)
@@ -408,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"
+4 -29
View File
@@ -43,7 +43,7 @@ func createsDeclared(p Principal, pattern string) bool {
if isController(p, pattern) {
return true
}
if removesAssignmentsStream(p, pattern) || setsLeaseExpiry(p, pattern) {
if removesAssignmentsStream(p, pattern) {
return true
}
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
@@ -96,19 +96,6 @@ 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, 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 {
@@ -171,8 +158,7 @@ var WritersTable = []WriterRow{
// subject is this machine's engine's alone.
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: writesTheLease,
Shared: "from the lease write's cut the controller writes it no more and the grants part writes its key holder alone (novox/hq ADR 0582 clause 3)"},
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",
@@ -202,7 +188,7 @@ var WritersTable = []WriterRow{
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
Writes: createsDeclared,
Shared: "the grants part creates a stream a manifest declares, by its name, 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), and updates the lease's bucket for its expiry alone (novox/hq ADR 0582 clause 3)"},
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 controller asks",
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
@@ -231,16 +217,6 @@ var WritersTable = []WriterRow{
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)},
{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,
@@ -312,8 +288,7 @@ var WritersTable = []WriterRow{
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 +
", and each node's engine its node's key, until the lease write (ADR 0565 clause 6)",
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
-238
View File
@@ -1,238 +0,0 @@
package catalogue
import (
"fmt"
"slices"
"sort"
"strings"
)
// **What still depends on the controller, and the lease write's check** (novox/hq ADR 0571 §13, ADR 0582 clause 2,
// issue 774): every declaration of a manifest other than the controller's on the controller (a read of one of its
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, a use of its seat), judged
// against the named, temporary list of what another accepted record keeps until the lease write. The grants part's
// grants_lease_check runs it over every manifest the indexer holds; ManifestRefusals runs it over a repository's
// manifests, in the test ADR 0571 §13 names in mesh-catalog, mesh-delivery and mesh-tools. A read of the lease key is no
// dependency: it names the lease's holder, whoever that is, and parts read it to learn that the write happened.
// ControllerLease is the controller's lease as a reader names it (`<module>.<state>`): no dependency.
const ControllerLease = ControllerSeatName + ".lease"
// The kinds of a dependency on the controller, as a manifest declares them.
const (
DependsRead = "read"
DependsInvoke = "invoke"
DependsConsume = "consume"
DependsUses = "uses"
DependsWaits = "waits"
)
// The phases of the lease write's check (ADR 0571 §13): before the lease write's own delivery the whole named list is
// allowed; that delivery drops every entry but the three kept to the write itself, and its check runs with those alone.
const (
PhaseBefore = "before-delivery"
PhaseWrite = "write"
)
// Dependency is one declaration of a manifest other than the controller's on the controller: a read of one of its
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, or a send to its seat.
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"`
// Kept is the record that keeps it until the lease write, from the named list; empty when none does.
Kept string `json:"kept,omitempty"`
// ToTheWrite says it is one of the three kept to the write itself, which the lease write's own delivery keeps.
ToTheWrite bool `json:"to_the_write,omitempty"`
}
// KeptDependency is one entry of the named, temporary list ADR 0571 §13 allows: a read, invoke or consume another
// accepted record keeps until the lease write, with that record. ToTheWrite marks the three kept to the write itself.
type KeptDependency struct {
Module, Kind, What, Record string
ToTheWrite bool
}
// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. It
// holds the three kept to the write itself and nothing else: the lease write's own delivery dropped every other entry
// (ADR 0571 §13; issue 774), so its check runs with these alone in either phase. The third, the bus node agent's
// following of the controller's user list (ADR 0571 §8, ADR 0547 §8), is no manifest's declaration but the
// node-engine's own, so no manifest matches it; it stays named here so the list reads as the record has it.
var KeepList = []KeptDependency{
{Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true},
{Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true},
{Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true},
}
// EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved.
// The controller's `grants` is one: ADR 0571 §9 has no manifest read it from L7's delivery, which changes ADR 0567 §10
// and ADR 0547 §5's shadow compare, so neither the assigner's nor the grants part's read of it is kept.
var EndedBy = map[string]string{
DependsRead + " mesh-controller.grants": "ADR 0571 §9: no manifest reads the controller's grants; the grants part's key is the one read",
DependsInvoke + " seat:mesh-controller.hand-act": "ADR 0565 §11: every recorder calls mesh-hand-acts",
DependsInvoke + " seat:mesh-controller.warranted": "ADR 0565 §11: every recorder calls mesh-hand-acts",
DependsInvoke + " seat:mesh-controller.root-free": "ADR 0571 §11: each machine's engine answers root-free (seat:node-engine.root-free@machine)",
DependsInvoke + " seat:mesh-controller.held": "ADR 0571 §2: held is the seat mesh-artifact-sweep's",
}
// keptFor is the list's entry for one dependency, or nil.
func keptFor(d Dependency) *KeptDependency {
for i, k := range KeepList {
if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What {
return &KeepList[i]
}
}
return nil
}
// onController says whether a name `<owner>.<rest>` is the controller's.
func onController(name string) bool { return strings.HasPrefix(name, ControllerSeatName+".") }
// 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, judged by no record as a dependency.
func invokesController(invoke string) bool {
if seat, _, ok := SeatInvoke(invoke); ok {
return seat == ControllerSeatName
}
return onController(invoke)
}
// Dependencies are every declaration on the controller in the manifests given, by module, each with the list's entry
// that keeps it. The controller's own manifest is not judged, nor a read of its lease key: it names the lease's holder,
// whoever that is, and parts read it to learn that the write happened (ADR 0571 §13).
func Dependencies(manifests map[string]Manifest) []Dependency {
var out []Dependency
add := func(module, by, kind, what string) {
d := Dependency{Module: module, By: by, Kind: kind, What: what}
if k := keptFor(d); k != nil {
d.Kept, d.ToTheWrite = k.Record, k.ToTheWrite
}
if !slices.Contains(out, d) {
out = append(out, d)
}
}
for name, m := range manifests {
if name == ControllerSeatName || m.Module == ControllerSeatName {
continue
}
for _, r := range m.Reads {
if onController(r) && r != ControllerLease {
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 == ControllerSeatName {
add(name, "", DependsUses, u)
}
}
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
}
// Verdict is the lease write's check in one phase.
type Verdict struct {
Phase string `json:"phase"`
Passes bool `json:"passes"`
Reasons []string `json:"reasons"`
}
// Coverage is how much of the mesh's manifests the dependencies were derived from: the manifests read, the modules the
// indexer holds (-1 when its keys could not be listed, which Unread then names), and each manifest that could not be
// read.
type Coverage struct {
Read, Indexed int
Unread []Unread
}
// Unread is one manifest, or one listing of them, that could not be read, and why.
type Unread struct {
Input string `json:"input"`
Why string `json:"why"`
}
// Judge runs the lease write's check over the dependencies (ADR 0571 §13) in one phase: before the lease write's own
// delivery, every dependency must be on the named list; at the write, only the three kept to the write itself may stay.
// It passes only over every module the indexer holds: no manifest read, fewer read than the indexer holds, or one that
// could not be read fails it, since what was not read could hide a dependency. A lease already handed away leaves
// nothing to write.
func Judge(deps []Dependency, phase string, cov Coverage, handedAway bool, leaseSays string) Verdict {
v := Verdict{Phase: phase, Reasons: []string{}}
if handedAway {
v.Reasons = append(v.Reasons, "there is nothing to write: "+leaseSays)
}
switch {
case cov.Read == 0:
v.Reasons = append(v.Reasons, fmt.Sprintf("no manifest was read from the indexer (it holds %d modules): with nothing read, nothing on the controller can be judged absent", max(cov.Indexed, 0)))
case cov.Indexed >= 0 && cov.Read < cov.Indexed:
v.Reasons = append(v.Reasons, fmt.Sprintf("only %d of the %d modules the indexer holds had their manifest read, so what the others declare on the controller is not known", cov.Read, cov.Indexed))
}
for _, d := range deps {
who := d.Module
if d.By != "" {
who += " (" + d.By + ")"
}
switch {
case d.Kept == "":
why := fmt.Sprintf("%s declares %s %s, which no record keeps until the lease write (ADR 0571 §13)", who, d.Kind, d.What)
if ended := EndedBy[d.Kind+" "+d.What]; ended != "" {
why += "; ended by " + ended
}
v.Reasons = append(v.Reasons, why)
case phase == PhaseWrite && !d.ToTheWrite:
v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, kept by %s only until the lease write's own delivery, which drops it", who, d.Kind, d.What, d.Kept))
}
}
for _, g := range cov.Unread {
v.Reasons = append(v.Reasons, fmt.Sprintf("%s could not be read (%s), so what it declares on the controller is not known", g.Input, g.Why))
}
v.Passes = len(v.Reasons) == 0
return v
}
// ManifestRefusals is the lease write's check over a repository's manifests, by module name, in one phase (PhaseBefore
// or PhaseWrite): each reason the check gives, none when it passes. Every manifest given counts as read: the caller reads
// them from its own tree, and none given fails it.
func ManifestRefusals(manifests map[string]Manifest, phase string) []string {
return Judge(Dependencies(manifests), phase, Coverage{Read: len(manifests), Indexed: -1}, false, "").Reasons
}
-103
View File
@@ -1,103 +0,0 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
// Issue 774 (ADR 0571 §13, ADR 0582 clause 2): the lease write's check judges every manifest's dependency on the
// controller against the named list, at the write (the three kept to it alone) and before its delivery. Moved with the
// judge from mesh-grants' leasecheck_test.go (TestTheLeaseCheckJudgesEveryDependencyOnTheController's manifest rows and
// TestTheKeepListNamesItsRecordsAndThreeToTheWrite), whose manifests were mesh-grants' fixtures; the conditions row is
// mesh-conditions' manifest at mesh-catalog ce3bedd5 (module.json:31-32).
func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) {
toTheWrite := 0
for _, k := range KeepList {
if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" {
t.Errorf("entry %+v names no record, module or declaration", k)
}
if k.ToTheWrite {
toTheWrite++
}
}
if toTheWrite != 3 || len(KeepList) != 3 {
t.Errorf("%d entries, %d kept to the write itself: ADR 0571 §13 keeps the three alone from the write's delivery", len(KeepList), toTheWrite)
}
type want struct {
deps []string // "<module> <kind> <what> <kept by>", kept by the record or "unkept"
write string // a reason the check at the write names, or "" when it passes
before string // a reason the check before the delivery names, or "" when it passes
}
for _, row := range []struct {
name string
manifests map[string]string
want want
}{
// 774/S4/AC2.
{"a read of the lease alone is no dependency, and the controller's own manifest is not judged",
map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`,
"mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`},
want{}},
// 774/S4/AC1, 774/S30/AC1: the keeper's read of the controller's conditions put back is refused in either phase,
// since the lease write's delivery dropped its entry (it was kept by ADR 0564 §9 before).
{"the keeper's read of the controller's conditions, put back, is refused",
map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`},
want{deps: []string{"mesh-conditions read mesh-controller.conditions unkept"},
write: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write",
before: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write"}},
// 774/S30/AC1: the three kept to the write pass in either phase.
{"the artifact sweep's collect is kept to the write itself",
map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","version":"1","invokes":["seat:mesh-controller.collect"]}`},
want{deps: []string{"artifact-store-tools invoke seat:mesh-controller.collect kept by ADR 0571 §4, §13"}}},
{"the builder's consume of artifacts-gone is kept to the write itself",
map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`},
want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}},
{"a read no record keeps fails both phases, named",
map[string]string{"mod-u": `{"module":"mod-u","version":"1","reads":["mesh-controller.declared-packages"]}`},
want{deps: []string{"mod-u read mesh-controller.declared-packages unkept"},
write: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write",
before: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write"}},
{"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none",
map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
`"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`,
"node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`},
want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept",
"mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"},
write: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps until the lease write (ADR 0571 §13); ended by ADR 0571 §11",
before: "node-tools declares waits mesh-controller.checked"}},
{"a process's own invoke of the controller is its module's dependency, named by the process",
map[string]string{"mod-p": `{"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"]}}]}`},
want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"},
write: "mod-p (process worker) declares invoke seat:mesh-controller.held", before: "mod-p (process worker)"}},
{"no manifest fails both phases", map[string]string{}, want{write: "no manifest was read", before: "no manifest was read"}},
} {
manifests := map[string]Manifest{}
for name, raw := range row.manifests {
m, err := Decode([]byte(raw))
if err != nil {
t.Fatalf("%s: %s: %v", row.name, name, err)
}
manifests[name] = m
}
var deps []string
for _, d := range Dependencies(manifests) {
kept := "unkept"
if d.Kept != "" {
kept = "kept by " + d.Kept
}
deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept)
}
if !slices.Equal(deps, row.want.deps) {
t.Errorf("%s: dependencies %q, want %q", row.name, deps, row.want.deps)
}
for phase, reason := range map[string]string{PhaseWrite: row.want.write, PhaseBefore: row.want.before} {
got := ManifestRefusals(manifests, phase)
if (reason == "") != (len(got) == 0) || !slices.ContainsFunc(got, func(r string) bool { return strings.Contains(r, reason) }) && reason != "" {
t.Errorf("%s: at %s refused %q, want %q", row.name, phase, got, reason)
}
}
}
}
-1
View File
@@ -9,7 +9,6 @@ import (
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
t.Run("the seat mesh-grants promises the lease write", grantsSeatPromisesTheLeaseWrite)
seat, ok := SeatNamed(DeliverySeat)
if !ok {
t.Fatal("the delivery seat is not in the set")
-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, and whether issuing is on and why (the lease). 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)
}
-53
View File
@@ -1,53 +0,0 @@
package catalogue
// **The lease write, a verb of the seat `mesh-grants`** (novox/hq ADR 0582 clause 1, issue 774): the grants part
// holds the seat once for the mesh and, on the operator's warrant, hands the controller's lease to itself and back.
// It serves two verbs and says three events, none carrying a secret:
//
// - lease-handed: the lease names mesh-grants, written on the operator's warrant (clause 3);
// - lease-altered: the lease key or its bucket's expiry differs from what mesh-grants wrote (clause 4);
// - removal-failed: the day-later removal of the controller's consumer or stream failed (clause 6).
// GrantsSeat is the seat the grants part (mesh-grants) holds.
const GrantsSeat = "mesh-grants"
// The seat's verbs.
const (
GrantsVerbLeaseWrite = "lease-write"
GrantsVerbLeaseHandBack = "lease-hand-back"
)
// The seat's events.
const (
GrantsEventLeaseHanded = "lease-handed"
GrantsEventLeaseAltered = "lease-altered"
GrantsEventRemovalFailed = "removal-failed"
)
// grantsDecision is the record that made the seat.
const grantsDecision = "novox/hq ADR 0582"
// grantsEvents are the seat's events, in the order the write meets them.
func grantsEvents() []string {
return []string{GrantsEventLeaseHanded, GrantsEventLeaseAltered, GrantsEventRemovalFailed}
}
// grantsVerbs are the seat's verbs (ADR 0582 clauses 1, 2, 3, 5).
func grantsVerbs() []Verb {
return []Verb{
{Name: GrantsVerbLeaseWrite, Description: "Hand the controller's lease to mesh-grants, kept for good. Refused, " +
"naming the check, until the lease check at phase write passes and every condition it does not judge reads " +
"green. Otherwise the operator is asked once; on the warrant the checks are read again, the controller's " +
"service is stopped, the bus's user list is composed without the controller's write on the lease, the lease's " +
"expiry is removed and the key holder names mesh-grants, said as lease-handed and recorded as a hand act. A no " +
"or an expiry of the ask acts on nothing.",
Input: schema(map[string]string{"why": "why the lease is handed now, shown to the operator and kept"},
[]string{"why"})},
{Name: GrantsVerbLeaseHandBack, Description: "Give the lease back to the controller, for a day after the write: " +
"asked and warranted the same way, it restores the lease's fifteen-second expiry, deletes the key holder, " +
"gives the controller its rights back once the list is loaded, and starts the controller. Refused once the " +
"day-later removal ran: the controller would start without its consumer and stream.",
Input: schema(map[string]string{"why": "why the lease goes back, shown to the operator and kept"},
[]string{"why"})},
}
}
-68
View File
@@ -1,68 +0,0 @@
package catalogue
import (
"reflect"
"strings"
"testing"
)
// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774, 774/S1/AC2): its holder serves lease-write and
// lease-hand-back, both a condition of holding, and nothing else; the seat says lease-handed, lease-altered and
// removal-failed. A case of TestTheDeliverySeatPromisesChecks, the seat-verb test, within the test budget.
func grantsSeatPromisesTheLeaseWrite(t *testing.T) {
seat, ok := SeatNamed(GrantsSeat)
if !ok {
t.Fatal("the seat mesh-grants is not in the set")
}
if seat.Scope != ScopeMesh || seat.Delivers != "" {
t.Errorf("mesh-grants is at scope %q delivering %q, want mesh and nothing", seat.Scope, seat.Delivers)
}
if got, want := VerbNames(seat.Serves), []string{"lease-write", "lease-hand-back"}; !reflect.DeepEqual(got, want) {
t.Errorf("mesh-grants serves %v, want %v", got, want)
}
if want := []string{"lease-handed", "lease-altered", "removal-failed"}; !reflect.DeepEqual(seat.Emits, want) {
t.Errorf("mesh-grants says %v, want %v", seat.Emits, want)
}
for _, v := range seat.Serves {
if v.Optional {
t.Errorf("%s is optional: the holder ships with the seat, so every verb is a condition of holding", v.Name)
}
props, _ := v.Input["properties"].(map[string]any)
if req, _ := v.Input["required"].([]string); len(props) != 1 || !reflect.DeepEqual(req, []string{"why"}) {
t.Errorf("%s takes %v requiring %v, want only a why", v.Name, props, req)
}
}
claim := func(serves ...string) Manifest {
return Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeMesh, Serves: serves}}}
}
for _, c := range []struct {
name string
m Manifest
refuse string // empty: held
}{
{"both verbs", claim("lease-write", "lease-hand-back"), ""},
{"without the hand-back", claim("lease-write"), "lease-hand-back"},
{"a verb the seat does not promise", claim("lease-write", "lease-hand-back", "lease-take"), "lease-take"},
{"at node scope", Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeNode,
Serves: []string{"lease-write", "lease-hand-back"}}}}, "scope"},
} {
err := CanHold(c.m, seat)
switch {
case c.refuse == "" && err != nil:
t.Errorf("%s: refused: %v", c.name, err)
case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)):
t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.refuse)
}
}
// No other seat changed: the set is the one before it with mesh-grants added beside mesh-hand-acts.
var names []string
for _, s := range DefaultSeats() {
names = append(names, s.Name)
}
at := strings.Index(strings.Join(names, " "), HandActsSeat+" "+GrantsSeat+" ")
if at < 0 {
t.Errorf("mesh-grants does not follow mesh-hand-acts in the seat set: %v", names)
}
}
+2 -11
View File
@@ -31,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 " +
@@ -55,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.",
+6 -57
View File
@@ -233,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).
@@ -348,11 +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()},
// The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to
// itself and back on the operator's warrant.
{Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()},
// 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)
}
-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
@@ -598,14 +596,6 @@ var ControllerVerbs = []Verb{
//
// **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 {
-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",
+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 -39
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
-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()
+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) {
-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 {
+1 -14
View File
@@ -242,22 +242,9 @@ func TestRoundTrip(t *testing.T) {
t.Fatal(err)
}
back, err := DecodeGrantsUserList(raw)
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants || back.LeaseHanded {
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
t.Fatalf("decoded %+v, %v", back, err)
}
// ADR 0582 clause 3 (issue 774, 774/S3): a key written before the flag existed reads not handed; one written with
// it reads handed.
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"}`)); err != nil || old.LeaseHanded {
t.Errorf("a key from before the lease write decodes %+v, %v", old, err)
}
g.LeaseHanded = true
if raw, err := EncodeGrantsUserList(g); err != nil || !bytes.Contains(raw, []byte(`"lease-handed":true`)) {
t.Errorf("the handed flag is written as %s, %v", raw, err)
} else if back, err := DecodeGrantsUserList(raw); err != nil || !back.LeaseHanded {
t.Errorf("the handed flag reads back %+v, %v", back, err)
}
g.LeaseHanded = false
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 {
-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 {
-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")
}
+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 {
+13 -86
View File
@@ -651,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 {
@@ -689,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 = "" },
+2 -2
View File
@@ -48,8 +48,8 @@ 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"
-3
View File
@@ -369,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
-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)
}
}
-4
View File
@@ -65,10 +65,6 @@ type GrantsUserList struct {
// LeftOut are the users the list leaves out for want of a hash in the vault.
LeftOut []string `json:"left_out"`
At time.Time `json:"at"`
// LeaseHanded says the controller's lease is handed to the grants part (novox/hq ADR 0582 clause 3, ADR 0571 clause
// 8): from then on the bus node follows this list alone and never falls back to the controller's. A key written
// before it reads false.
LeaseHanded bool `json:"lease-handed,omitempty"`
}
// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow.
+15 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/membership"
@@ -16,10 +17,10 @@ import (
// not a membership is handed to onBad (nil to ignore it) and skipped.
//
// It reads the membership from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7),
// watched so the value there now comes first and each change after it. Nothing else: the subject the grants part
// published on is no longer heard (ADR 0571 clause 10, issue 774), since it stops publishing at the lease write, and
// the assignments stream is no longer read (research 072 row 33). A state not there or not readable is a refusal to
// follow, returned: there is nothing else to follow.
// watched so the value there now comes first and each change after it; and it hears the subject the grants part
// publishes on until the lease write. The assignments stream is no longer read: the bus module's tools delete it
// at the lease write (research 072 row 33). A state not there yet, or not readable, is told to onBad and the
// subject is followed alone. The returned error is a refusal to follow at all.
func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(error)) error {
if c.account.Node == "" || c.account.Module == "" {
return fmt.Errorf("the account names no node or module, so it has no membership")
@@ -37,20 +38,27 @@ func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(er
}
apply(issued)
}
subject := membership.Subject(c.account.Node, c.account.Module)
if _, err := c.nc.Subscribe(subject, func(m *nats.Msg) { take(m.Data) }); err != nil {
return fmt.Errorf("cannot follow %s: %w", subject, err)
}
key := membership.GrantsKey(c.account.Node, c.account.Module)
js, err := jetstream.New(c.nc)
if err != nil {
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
bad(fmt.Errorf("cannot read %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
kv, err := js.KeyValue(ctx, membership.GrantsBucket)
if err != nil {
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
bad(fmt.Errorf("cannot read %s of %s yet (%v); following %s alone", key, membership.GrantsBucket, err, subject))
return nil
}
w, err := kv.Watch(context.Background(), key)
if err != nil {
return fmt.Errorf("cannot watch %s of %s: %w", key, membership.GrantsBucket, err)
bad(fmt.Errorf("cannot watch %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
return nil
}
go func() {
for e := range w.Updates() {
+9 -41
View File
@@ -3,20 +3,14 @@
package process
import (
"context"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// A process connected pinned to a test bus follows the membership the grants part writes for its node and module in
// its state memberships, and reports what is not one (novox/hq issue 1633, AC2); it no longer hears mesh.assignment,
// where the grants part stops publishing at the lease write (ADR 0571 clause 10; issue 774, 774/S5/AC1). Without the
// state it refuses to follow, naming it.
// A process connected pinned to a test bus hears the membership published for its node and module, and
// ignores what is not one (novox/hq issue 1633, AC2).
func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
url, fingerprint := selfSignedServer(t)
account := mailSenderAccount(t, url, fingerprint)
@@ -26,52 +20,26 @@ func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
}
defer conn.Close()
got := make(chan membership.Membership, 4)
got := make(chan membership.Membership, 1)
bad := make(chan error, 4)
follow := func() error {
return conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e })
}
if err := follow(); err == nil || !strings.Contains(err.Error(), membership.GrantsBucket) {
t.Fatalf("followed with no state memberships: %v", err)
}
js, err := jetstream.New(conn.nc)
if err != nil {
if err := conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e }); err != nil {
t.Fatal(err)
}
ctx := context.Background()
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: membership.GrantsBucket})
if err != nil {
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte("not json")); err != nil {
t.Fatal(err)
}
key := membership.GrantsKey("novox", "mesh-mail")
if _, err := kv.Put(ctx, key, []byte("not json")); err != nil {
t.Fatal(err)
}
if err := follow(); err != nil {
t.Fatal(err)
}
// Published on the subject, as the grants part did until the write: not heard.
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.9"]}`)); err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, key, membership.EncodeGrantsMembership(membership.Membership{Node: "novox", Module: "mesh-mail",
Mesh: []string{"10.0.0.1"}})); err != nil {
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.1"]}`)); err != nil {
t.Fatal(err)
}
select {
case m := <-got:
if m.Module != "mesh-mail" || len(m.Mesh) != 1 || m.Mesh[0] != "10.0.0.1" {
if m.Module != "mesh-mail" || len(m.Mesh) != 1 {
t.Fatalf("membership: %+v", m)
}
case <-time.After(5 * time.Second):
t.Fatal("no membership followed")
}
select {
case m := <-got:
t.Errorf("a second membership was applied, from the subject: %+v", m)
case <-time.After(300 * time.Millisecond):
t.Fatal("no membership heard")
}
if len(bad) == 0 {
t.Error("the value that is not a membership was not reported")
t.Error("the body that is not a membership was not reported")
}
}
-20
View File
@@ -283,24 +283,4 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
if gets != 3 {
t.Fatalf("a kept value was read by key %d times, want once, and the missing one twice", gets)
}
// Issue 1954: a state the process alone writes reads its own put and delete back from the copy at once, and the
// watch's changes of that key up to the write are not taken over it; a later change is.
_ = handles["placement"](StateChange{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 5})
if err := w.Wrote("placement", "own", []byte("mine"), 7); err != nil {
t.Fatal(err)
}
_ = w.Wrote("placement", "gone", []byte("x"), 8)
_ = w.Removed("placement", "gone")
for _, c := range []StateChange{{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 6},
{Key: "own", Op: OpPut, Value: []byte("mine"), Revision: 7}, {Key: "gone", Op: OpPut, Value: []byte("x"), Revision: 8}} {
_ = handles["placement"](c)
}
own, _, _ := w.Get("placement", "own")
if _, found, _ := w.Get("placement", "gone"); found || string(own) != "mine" {
t.Fatalf("after its own writes and the watch's older changes the copy holds own=%s, gone found %v", own, found)
}
_ = handles["placement"](StateChange{Key: "gone", Op: OpPut, Value: []byte("again"), Revision: 9})
if v, _, _ := w.Get("placement", "gone"); string(v) != "again" {
t.Fatalf("a change after its own delete is not taken: %s", v)
}
}
+5 -54
View File
@@ -31,12 +31,8 @@ type watchedState struct {
ready chan struct{} // closed once the watch's current values are all in the copy, or it was refused
err error
mu sync.Mutex
values map[string][]byte
// revs is, by key, the revision its value in the copy is at, and latest the highest revision the copy has seen: a
// change the watch hands at a revision not newer than its key's is one the copy already took (Wrote, Removed).
revs map[string]uint64
latest uint64
mu sync.Mutex
values map[string][]byte
then []func(StateChange) error
started bool // the current values are in, so a change runs then at once
pending []StateChange // the current values, run through then once all are in
@@ -81,7 +77,7 @@ func (w *WatchedStates) watched(state string, then func(StateChange) error) (*wa
}
return s, nil
}
s = &watchedState{ready: make(chan struct{}), values: map[string][]byte{}, revs: map[string]uint64{}}
s = &watchedState{ready: make(chan struct{}), values: map[string][]byte{}}
if then != nil {
s.then = []func(StateChange) error{then}
}
@@ -89,17 +85,11 @@ func (w *WatchedStates) watched(state string, then func(StateChange) error) (*wa
w.mu.Unlock()
err := w.watch(state, func(c StateChange) error {
s.mu.Lock()
switch {
case c.Revision != 0 && c.Revision <= s.revs[c.Key]:
// Already in the copy, or older than what is: this process wrote the key since (Wrote, Removed).
case c.Op == OpPut:
if c.Op == OpPut {
s.values[c.Key] = append([]byte(nil), c.Value...)
s.revs[c.Key] = c.Revision
default:
} else {
delete(s.values, c.Key)
s.revs[c.Key] = c.Revision
}
s.latest = max(s.latest, c.Revision)
if !s.started {
s.pending = append(s.pending, c)
s.mu.Unlock()
@@ -250,44 +240,5 @@ func (w *WatchedStates) Once(state, key string) ([]byte, bool, error) {
return v, true, nil
}
// Wrote takes a put this process made into the copy at once, at the revision the bus answered (0 when not known): a
// state the process alone writes (a holder's own) is read back from the copy right after its own write, before the
// watch hands the change back, and the watch's older changes of the key are not taken over it (issue 1954). The state
// is watched first when it is not yet.
func (w *WatchedStates) Wrote(state, key string, value []byte, revision uint64) error {
return w.took(state, key, value, revision, true)
}
// Removed takes a delete this process made into the copy at once, as Wrote takes a put.
func (w *WatchedStates) Removed(state, key string) error { return w.took(state, key, nil, 0, false) }
func (w *WatchedStates) took(state, key string, value []byte, revision uint64, put bool) error {
s, err := w.watched(state, nil)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
switch {
case put && revision != 0:
if revision <= s.revs[key] {
return nil // the watch handed a newer change of the key already
}
s.values[key] = append([]byte(nil), value...)
s.revs[key] = revision
s.latest = max(s.latest, revision)
default:
// A delete's revision is not answered, nor a put's at times: it is newer than every revision the copy has seen,
// so the watch's changes of the key up to that one are not taken over it.
if put {
s.values[key] = append([]byte(nil), value...)
} else {
delete(s.values, key)
}
s.revs[key] = s.latest
}
return nil
}
// Direct is a key read by key now, not from the copy: a value that changes and is read one at a time (a build).
func (w *WatchedStates) Direct(state, key string) ([]byte, bool, error) { return w.get(state, key) }