Merge pull request 'Design 47's last milestones as one release: the contracts for health, control records, provisioning and history leaving the controller (issues 774, 1126, 1952, 1953, 1954, 1955, 1289)' (#133) from feat/774-l3-reland into main

This commit was merged in pull request #133.
This commit is contained in:
2026-10-30 11:01:40 +00:00
91 changed files with 6383 additions and 573 deletions
+11 -3
View File
@@ -228,8 +228,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
_ = json.Unmarshal(r, &t)
return t.Passed, nil
}
// Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
// level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
// The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level
// there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every
// test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of
// the level and not only the first (issue 1921); the level passes only when each of its steps did.
all := true
if s.Digest == nil {
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
@@ -244,6 +246,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
}
all = all && passed
}
if !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
}
return all, nil
}
@@ -279,7 +284,10 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
if err != nil {
return false, fmt.Errorf("the %s level: %w", level, err)
}
all = all && passed // the packages left still run (issue 1921)
all = all && passed // past the change's own packages the others still run (issue 1921)
if priority && !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
}
return all, nil
}
+52 -30
View File
@@ -113,7 +113,7 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
}
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921), and runs no
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no
// level after one that did not pass (ADR 0484 clause 13).
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
r := &recorder{
@@ -129,8 +129,7 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
got := strings.Join(r.calls, "\n")
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
digest {"level":"unit","workspace":"/w"}
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
}
@@ -139,7 +138,7 @@ test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
}
u := a.Levels[0]
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
t.Fatalf("the unit level %+v", u)
}
@@ -254,41 +253,64 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
}
}
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
// check runs every module directory after one fails too.
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
}
for i, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
t.Errorf("result %d %s, want a failure", i, raw)
// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the
// change touched are two test results of the one level. A Node check does the same with its module directories.
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) {
for _, row := range []struct {
name string
changed []string
tests map[string]bool
want []string // the packages tested, in order
}{
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
{"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}},
{"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}},
} {
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests,
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}}
if _, ok := row.tests["x/pkg/c"]; ok {
r.digests["x/pkg/c"] = "d-c"
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
var got []string
for _, c := range r.calls[1:] {
if strings.HasPrefix(c, "test ") {
var a struct{ Packages []string }
_ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a)
got = append(got, strings.Join(a.Packages, " "))
}
}
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
}
a := out.(delivery.CheckAnswer)
failed := false
for _, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
_ = json.Unmarshal(raw, &res)
failed = failed || !res.Passed
}
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
}
}
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": false}}
s := r.steps()
s.Digest = nil
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 {
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels)
}
}
+268 -1
View File
@@ -13,7 +13,9 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/nats-io/nats.go/jetstream"
)
@@ -33,8 +35,17 @@ var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATE
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
// (issue 1790) is the other row beyond, matched by createdAdvisory.
//
// And the watchman's dead letters and watchdogs (novox/hq ADR 0564 clauses 4, 5, issue 1952 S5): its own consumer on
// DEAD_LETTER_NOTICES, a read of DEAD_LETTERS, and the words its watchdogs hear, the controller's jobs until the lease
// write.
func beyondTheControllers(user string) map[string]bool {
out := map[string]bool{}
if strings.HasSuffix(user, "."+WatchmanModule) {
for _, s := range append(WatchmanDeadLetterGrants(nil), WatchmanHears()...) {
out[s] = true
}
}
node, ok := strings.CutPrefix(user, "node.")
if !ok {
return out
@@ -48,6 +59,19 @@ func beyondTheControllers(user string) map[string]bool {
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
func mayBeyondTheControllers(user string) map[string]bool {
// A runtime's read of its membership from the grants part's state (novox/hq ADR 0571 clause 7): its own key, or the
// tool runner's node's.
if parts := strings.Split(user, "."); len(parts) == 2 && parts[0] != "node" {
key := user
if parts[1] == RuntimeModule {
key = parts[0] + ".*"
}
out := map[string]bool{}
for _, s := range readGrants(GrantsMembershipsBucket, key) {
out[s] = true
}
return out
}
if _, ok := strings.CutPrefix(user, "node."); !ok {
return nil
}
@@ -65,7 +89,13 @@ func engineBeyond(user, g string) bool {
}
return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) ||
strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) ||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".")
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
// And its own health report (issue 774).
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
strings.Contains(g, "KV_"+provisions.ProvisionsBucket)
}
// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream
@@ -548,6 +578,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
}
}
// 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"},
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
@@ -555,3 +587,238 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}
})
}
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
func healthChecks(t *testing.T) {
t.Helper()
announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"),
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
for _, c := range []struct {
reader, read string
may bool
}{
{"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true},
{"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, {ArtifactStoreTools, ControllerLease, true},
{"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false},
{"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false},
// ADR 0565 clauses 10, 11 (issue 1953 L5-S4): mesh-hand-acts reads the controller's log beside its own.
{"mesh-hand-acts", ControllerHandActs, true},
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
} {
granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced)
if (len(granted) == 1) != c.may {
t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may)
}
}
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts", ControllerConditions}},
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}},
// Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them.
{Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}},
{Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}},
{Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}},
{Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}},
// ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider
// read them.
{Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"),
bucket("mesh-provisioner", "registry")}},
{Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
{Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
// The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3).
{Module: "mesh-provisioner-client", ProvisionsNode: true},
// ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders.
{Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}},
Reads: []string{delivery.SeatHoldersRead}},
{Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}},
// ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them.
{Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}},
{Module: "restic", Reads: []string{"disk-load.readings"}, State: []Bucket{bucket("restic", "good")}},
// ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks
// the builder's tool.
{Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}},
{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}},
{Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}},
{Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}},
Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}},
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools",
Removes: []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs}}, {Process: "other"}}},
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
}}})
if err != nil {
t.Fatal(err)
}
may := map[string][]string{
"a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"},
"a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
"a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database",
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"},
"a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"},
"a.disk-load": {"$KV.disk-load_readings.a.pg.data"},
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak",
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak",
// The needs, for the client before V3, until the lease write (review round 3).
"$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"},
// ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it.
"a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"},
"a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"},
"a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data",
// ADR 0571 clause 7: its own membership from the grants part's state.
"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"},
"a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta",
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1",
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"},
"a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"},
"a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"},
WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule,
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"},
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER",
"$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"},
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts"},
}
mayNot := map[string][]string{
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
"a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"},
"a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data",
"$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools",
"$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"},
// Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570
// clauses 1, 8).
"a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"},
"a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak",
"mesh.seat.mesh-artifact-sweep.event.artifacts-gone"},
// Each writes its own state alone, and these users are composed.
"a.disk-load": {"$KV.restic_good.a.pg.data"},
"a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"},
// Another machine's plans are not its to read, nor another machine's needs, which it keeps for the client before
// V3 until the lease write.
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak",
"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"},
// Only the assigner writes seat-holders.
"a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"},
// Only disk-load writes its readings.
"a.restic": {"$KV.disk-load_readings.a.pg.data"},
"a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
// Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10).
"a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch",
"$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"},
// The watchman never deletes or changes the controller's consumer (clause 5).
// Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's
// (the lead, 2026-10-29).
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x",
"$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER",
// In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29).
"$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x",
"mesh.again.a_m.x"},
WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule},
// The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts
// remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them
// 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"},
"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"},
}
// ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes.
if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}},
Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes ||
m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead {
t.Errorf("the indexer's membership issues the logs %+v", m.Logs)
}
// ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the
// assigner writes its policies.
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
continue
}
// ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none.
if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") ||
MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) {
t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish)
}
// Issue 774: a node's engine writes its own health report's key and no other node's.
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) {
t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish)
}
if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") ||
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)
}
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())
}
}
// 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).
if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete {
t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge)
}
// The tools account's secret is the module's own broker-<binary>, which its manifest's own-secrets names and the
// vault keeps by that name (AccountSecret).
if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools",
URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" {
t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid())
}
seen := map[string]bool{}
for _, u := range users {
name := u.Username()
if u.Module == WatchmanModule && u.Kind == KindModule {
name = WatchmanModule
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if name == WatchmanModule {
// It hears its watchdogs' words on core subjects (Limits 3, 11).
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
if !MaySubscribe(perms, subject) {
t.Errorf("the watchman may not hear %s", subject)
}
}
// Never the bus's API audit, which carries every API answer in the account (the lead, 2026-10-29).
if MaySubscribe(perms, "$JS.EVENT.ADVISORY.API") || MaySubscribe(perms, "$JS.EVENT.ADVISORY.API.x") {
t.Error("the watchman hears the bus's API audit")
}
if !MayPublish(perms, "$JS.API.DIRECT.GET.PROV_APPLIED.prov.applied.g14.keycloak") {
t.Error("the watchman may not read the newest provisioner report")
}
}
subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...)
denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...)
if len(may[name+" sources"]) > 0 {
seen[name+" sources"] = true
}
for _, subject := range subjects {
seen[name] = true
if !MayPublish(perms, subject) {
t.Errorf("%s may not publish %s", name, subject)
}
}
for _, subject := range denied {
seen[name] = true
if MayPublish(perms, subject) {
t.Errorf("%s may publish %s", name, subject)
}
}
}
// Every row names a user that was composed, in either table: a row for a user absent would pass for nothing.
for _, table := range []map[string][]string{may, mayNot} {
for name := range table {
if !seen[name] {
t.Errorf("no user %s was composed", name)
}
}
}
}
+27
View File
@@ -3,6 +3,8 @@ package buscompose
import (
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module
@@ -22,6 +24,16 @@ const (
// EngineActsBucket is that state's bucket.
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the
// node's name, written by that node's engine alone.
var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState)
// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone,
// and its read.
func EnginePackagesGrants(node string) []string {
return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...)
}
// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it
// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the
// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR
@@ -41,5 +53,20 @@ func engineGrants(p Principal) (pub, sub []string) {
if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil {
pub = append(pub, own...)
}
// And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the
// consuming engine checks the vault's name and generation of what it is handed).
if safeSubject.MatchString(p.Node) {
pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...)
}
// And its node's own health report, its key `<node>` alone (issue 774, the operator's alert table): what the watchman
// reads for the machine's alerts.
if safeSubject.MatchString(p.Node) {
pub = append(pub, append([]string{"$KV." + delivery.NodeHealthBucket + "." + p.Node},
readGrants(delivery.NodeHealthBucket, p.Node)...)...)
}
// 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)...)
}
return append(pub, tp...), append(sub, ts...)
}
+172
View File
@@ -0,0 +1,172 @@
package buscompose
import (
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
)
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5).
//
// - The condition keeper reads the controller's open conditions until the lease write, for what clauses 8 and 9 carry
// from them, and mesh-healers the controller's hand-act log, for S15 (Limits 2): both buckets the controller
// announces readable by nobody, so each is read by its named reader alone. The keeper, mesh-healers and the
// watchman read the controller's lease (Limits 8). Nobody reads mesh-healers' `would` (clause 8): what the healers
// would have done reaches no condition. Every observer's `findings`, the healers' `attempts`, the wants and the
// haves are read through `reads` as any announced state is; and no module writes another's state, so an observer
// can never write the keeper's buckets (clause 2).
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). It
// lists and shows DEAD_LETTERS, which is all it does before the lease write.
// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes`
// (catalogue.BusAccounts), which may remove only the consumer `controller` on DEAD_LETTER_NOTICES and keys of the
// node-engine's acts bucket (Limits 4), never the backup's account `broker`, which only reads (ADR 0235).
// The states the composer names its readers for, beside the lease (NamedReaders).
var (
// ControllerConditions is the controller's open conditions, read by the keeper alone (ADR 0564 clauses 8, 9).
ControllerConditions = ControllerSeat + ".conditions"
// ControllerHandActs is the controller's hand-act log, read by mesh-healers alone of the four health holders (ADR
// 0564 Limits 2).
ControllerHandActs = ControllerSeat + ".hand-acts"
// HealersWould is what mesh-healers would have done while the lease names a controller: read by nobody (clause 8).
HealersWould = health.HealersModule + "." + health.WouldState
)
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, issue 1952
// notes 6 and 7), each removal its grant: the controller's consumer on DEAD_LETTER_NOTICES read and deleted (the lease
// write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills. A
// removal outside the set is refused, as the catalogue refuses it (catalogue.BusRemovals).
func BusRemovalGrants(removes []string) ([]string, error) {
var out []string
for _, r := range removes {
switch r {
case catalogue.RemovesControllerDeadLetterConsumer:
out = append(out, "$JS.API.CONSUMER.INFO."+DeadLetterNoticesStream+"."+ControllerName,
"$JS.API.CONSUMER.DELETE."+DeadLetterNoticesStream+"."+ControllerName)
case catalogue.RemovesEngineActs:
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
default:
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r,
strings.Join(catalogue.BusRemovals, ", "))
}
}
return out, nil
}
// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one
// exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4).
func removesEngineActs(p Principal, pattern string) bool {
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>"
}
// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no
// controller and `controller` is gone (ADR 0564 clause 5).
const WatchmanDeadLetterConsumer = WatchmanModule
// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least
// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller`
// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept
// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the
// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name.
// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read
// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may
// neither delete a message nor publish `mesh.again.>`.
func WatchmanDeadLetterGrants(sources []string) []string {
own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer
out := []string{
"$JS.API.STREAM.INFO." + DeadLetterNoticesStream,
"$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName,
"$JS.API.CONSUMER.CREATE." + own,
"$JS.API.CONSUMER.INFO." + own,
"$JS.API.CONSUMER.MSG.NEXT." + own,
"$JS.ACK." + own + ".>",
"$JS.API.STREAM.INFO." + DeadLettersStream,
"$JS.API.STREAM.MSG.GET." + DeadLettersStream,
deadLetterPrefix + ">",
// And the provisioner clients' reports, the newest per subject read at its start (S21, ADR 0362 §3).
"$JS.API.STREAM.INFO." + ProvAppliedStream,
"$JS.API.STREAM.MSG.GET." + ProvAppliedStream,
"$JS.API.DIRECT.GET." + ProvAppliedStream + ".>",
}
for _, s := range DeadLetterSources(sources) {
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s)
}
return out
}
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
// 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 {
return []string{
"mesh.mod.power.event.*",
"mesh.mod.*.event.provisioner.failing",
"mesh.mod.*.event.provisioner.recovered",
"mesh.control.*.tools-alive",
"mesh.seat.mesh-self-check.event." + health.EventChecked,
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
"mesh.mod.nats.event.bus.slow-consumer",
"mesh.mod.nats.event.bus.connection-closed",
ProvAppliedSubjects,
deadLetterPrefix + ">",
}
}
// DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a
// bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern.
func DeadLetterSources(names []string) []string {
seen := map[string]bool{}
var out []string
for _, n := range names {
switch {
case seen[n], !safeSubject.MatchString(n), strings.HasPrefix(n, "KV_"), strings.HasPrefix(n, "OBJ_"),
n == DeadLettersStream, n == DeadLetterNoticesStream:
continue
}
seen[n] = true
out = append(out, n)
}
sort.Strings(out)
return out
}
// deadLetterSourcesOf are the streams the records' consumers give up on: EVENTS, the work queue of every seat that
// accepts work, and every module's and process's own work queue.
func deadLetterSourcesOf(r Records) []string {
out := []string{EventsStream}
for _, mods := range r.Assigned {
for _, d := range mods {
for _, s := range append(append([]Seat(nil), d.Holds...), d.Uses...) {
if len(s.Accepts) > 0 {
out = append(out, seatStreamName(s.Name))
}
}
for _, q := range d.Queues {
out = append(out, q.Stream)
}
for _, pr := range d.Processes {
for _, q := range pr.Queues {
out = append(out, q.Stream)
}
}
}
}
return DeadLetterSources(out)
}
// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which
// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the
// controller's in L4.
func keepsDeadLetters(p Principal, pattern string) bool {
return isController(p, pattern) ||
(p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">")
}
+62 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"sort"
"strings"
"time"
"github.com/nats-io/nats.go/jetstream"
)
@@ -14,8 +15,10 @@ import (
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
// its stream and subjects, the controller creates it from the catalogue on every raise and from
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
// is gone is reported, as a bucket is. Unlike a bucket, a log is its owner's alone: no other module
// reads it, so there is no read of a log to grant or issue.
// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's
// `reads` names it, `<module>.<log>` (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): by direct gets, and through an
// ordered consumer of its own it creates and lets go of to read from a time or the newest per key and then follow new
// entries; never a durable consumer, never a write.
//
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
// is exactly what is sent; jetstream.go is the part that asks a server.
@@ -37,6 +40,8 @@ type Log struct {
Name string
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
MaxMiB int
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
MaxAgeDays int
}
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
@@ -74,7 +79,7 @@ func (l Log) Why() string {
}
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
// headers at most, that refuses a new entry when full rather than
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
// is how the runtime reads it.
@@ -85,7 +90,7 @@ func (l Log) Config() jetstream.StreamConfig {
Subjects: []string{l.Subject() + ".>"},
Storage: jetstream.FileStorage,
Retention: jetstream.LimitsPolicy,
MaxAge: 0,
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
MaxMsgs: -1,
MaxMsgsPerSubject: -1,
MaxBytes: l.MaxBytes(),
@@ -135,6 +140,59 @@ func logGrants(module string, names []string) []string {
return out
}
// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `<module>.<log>`.
func DeclaredLogs(r Records) map[string]bool {
out := map[string]bool{}
for _, ds := range r.Assigned {
for _, d := range ds {
for _, l := range d.Logs {
out[d.Module+"."+l.Name] = true
}
}
}
return out
}
// withReadLogs is d with ReadLogs set: of its reads, those naming a log of logs.
func withReadLogs(d Declared, logs map[string]bool) Declared {
d.ReadLogs = nil
for _, r := range d.Reads {
if logs[strings.TrimSpace(r)] {
d.ReadLogs = append(d.ReadLogs, strings.TrimSpace(r))
}
}
return d
}
// readLogGrants is what a reader of other modules' logs publishes, for each `<module>.<log>` read: the stream's info,
// direct gets (an entry by sequence, the last of a key), and an ordered consumer of its own (created with any name and
// filter, pulled, read and deleted). No append, no purge, no delete of an entry: the log's stream refuses those anyway.
func readLogGrants(reads []string) []string {
var out []string
for _, r := range reads {
module, name, ok := strings.Cut(r, ".")
if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
continue
}
stream := LogStreamName(module, name)
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream, "$JS.API.DIRECT.GET."+stream+".>",
"$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.MSG.NEXT."+stream+".*",
"$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*")
}
return out
}
// readLogsIssuedFor is every other module's log a module's code reads, as its membership lists them: by the read's
// name, never written.
func readLogsIssuedFor(d Declared) []LogIssued {
var out []LogIssued
for _, r := range d.ReadLogs {
module, name, _ := strings.Cut(r, ".")
out = append(out, LogIssued{Name: r, Stream: LogStreamName(module, name), Subject: LogSubject(module, name)})
}
return out
}
// logNames is the local names of a module's logs.
func logNames(logs []Log) []string {
out := make([]string, 0, len(logs))
+5 -2
View File
@@ -24,6 +24,9 @@ type Placements struct {
Interchangeable map[string]bool
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
Kinds []KindHeld
// Logs is every log a module placed anywhere declares, `<module>.<log>` (DeclaredLogs): a read naming one is issued
// as a log read (novox/hq ADR 0571 clause 1).
Logs map[string]bool
}
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
@@ -58,7 +61,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
}
}
m.State = stateIssuedFor(d, node)
m.Logs = logsIssuedFor(d)
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
m.Streams = streamsIssuedFor(d)
m.Hears = HeardIssuedFor(node, d)
m.Waits = waitsIssuedFor(d)
@@ -103,7 +106,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)}
for node, declared := range r.Assigned {
for _, d := range declared {
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
+43
View File
@@ -181,6 +181,8 @@ type Principal struct {
KeyedReads []KeyedRead
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
Logs []string
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
ReadLogs []string
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
// subjects it was granted (novox/hq ADR 0363).
Streams []ModuleStream
@@ -196,6 +198,12 @@ type Principal struct {
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
// else — not its declarations, which the node's tool runtime serves for it.
SnapshotsTheBus bool
// 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
// 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
// WitnessesController is a machine principal whose node-engine witnesses the controller's upgrades:
// the machine runs the controller (novox/hq ADR 0236, lease/witness.go). It may read the lease's one
@@ -655,6 +663,19 @@ func PermissionsFor(p Principal) (Permissions, error) {
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
}
if p.Kind == KindProcess && len(p.Removes) > 0 {
pub, err := BusRemovalGrants(p.Removes)
if err != nil {
return Permissions{}, err
}
sort.Strings(pub)
if err := CheckWriters(p, pub); err != nil {
return Permissions{}, err
}
// Its own inbox for the answers, and nothing else (Limits 4).
return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil
}
var pub, sub []string
switch p.Kind {
case KindController:
@@ -988,6 +1009,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
if p.Module == WatchmanModule {
sub = append(sub, BusConnectSubject, BusDisconnectSubject)
pub = append(pub, BusConnzSubject)
// And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5).
pub = append(pub, WatchmanDeadLetterGrants(p.DeadLetterSources)...)
// And the words its watchdogs hear (ADR 0564 clause 4, Limits 3, 11).
sub = append(sub, WatchmanHears()...)
}
// The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5;
// the operator, 2026-10-25 22:31): one per subject, `mesh.assignment.<node>.<module>`, reading the last one of each
@@ -1040,6 +1065,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
// directly from the stream and followed live. Nothing else's.
sub = append(sub, MembershipSubject(p.Node, p.Module))
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(p.Node, p.Module))
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): the read that replaces
// the stream's at the lease write, when the grants part stops publishing there.
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
// grant a person gets and derived the same way, so "what may this module ask" is
@@ -1157,6 +1185,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
sub = append(sub, stateCreatedHeard(kept)...)
// And its logs (novox/hq ADR 0297): appended to and read directly, its own alone.
pub = append(pub, logGrants(p.Module, p.Logs)...)
pub = append(pub, readLogGrants(p.ReadLogs)...)
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
// (novox/hq ADR 0259 §3).
@@ -1204,6 +1233,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
// that varies is the module, so the pattern is the machine's own assignments.
sub = append(sub, "mesh.assignment."+p.Node+".*")
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".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.
pub = append(pub, "mesh.control."+p.Node+".tools-alive")
@@ -1260,6 +1290,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
// runtime's to keep, from the logs each membership lists.
for _, d := range p.Carries {
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
pub = append(pub, readLogGrants(d.ReadLogs)...)
}
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
@@ -1705,6 +1736,18 @@ func CreateConsumerGrants(consumers []ModuleConsumer) []string {
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
const GrantsModule = "mesh-grants"
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
// membership (Membership) as the assignments stream carries it, written only when it differs. A module's runtime reads
// its own key, the tool runner its node's; the grants part alone writes it.
const (
GrantsMembershipsState = "memberships"
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
GrantsMembershipsRead = GrantsModule + "." + GrantsMembershipsState
)
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
func MembershipKey(node, module string) string { return node + "." + module }
// The bus's own events, imported into MESH from the default system account `$SYS` under subjects of MESH's own
// (novox/hq ADR 0431 §1, issue 1147): the server says an account's connections opening and closing only to the system
// account, and the list of an account's connections only to a request made there. `$SYS` holds no user, so nobody
+13 -5
View File
@@ -3,9 +3,11 @@ package buscompose
import (
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/nats-io/nats.go"
)
@@ -34,9 +36,10 @@ var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `<node>.<module>` (novox/hq ADR
// 0570 clause 2). **Named one by one**, as the controller's asks are: a domain that moves to the
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
var ProvisionerReads = []string{ConfigResolvedBucket}
var ProvisionerReads = []string{ConfigResolvedBucket, provisions.PlansBucket}
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
@@ -68,8 +71,13 @@ var (
)
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
// client cannot write another machine's needs, or say another machine applied a file.
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A client
// cannot write another machine's needs, or say another machine applied a file.
//
// **The needs stay granted for one release, and are removed after the lease write** (review round 3 of design 47's
// release): the client's V3 reads its machine's plans from the seat mesh-provisioner's `plans` (novox/hq ADR 0570 clause
// 2) and records no needs, but the client before it still runs on a node until its delivery reaches it, and it writes
// them.
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
+41 -1
View File
@@ -3,8 +3,11 @@ package buscompose
import (
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/handacts"
"git.novox.be/novox/mesh-sdk/go/health"
)
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes
@@ -77,12 +80,22 @@ func grantsOwnObjects(p Principal, pattern string) bool {
// readers too (announce.State.Readers); both are honoured.
const VaultBusAccounts = "mesh-vault.bus-accounts"
// ArtifactStoreTools is the module holding the seat mesh-artifact-sweep (novox/hq ADR 0571 clause 2).
const ArtifactStoreTools = "artifact-store-tools"
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`: the grants part's wanted accounts
// are read by the vault alone (novox/hq ADR 0561 §2).
// The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which
// reads the catalogue's placement alone from then (ADR 0549 §5, issue 774).
//
// The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the
// watchman read the lease; the keeper alone the controller's conditions; mesh-healers and mesh-hand-acts its hand-act log
// (ADR 0565 clauses 10, 11: every reader of acts reads both logs); and nobody the healers' `would` (health_grants.go).
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat},
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}}
ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule,
// ADR 0571 clause 2: the artifact store's seat sweeps only once the lease names no controller.
ArtifactStoreTools},
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {}}
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
// that names none.
@@ -113,3 +126,30 @@ func withNamedReaders(d Declared) Declared {
d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...)
return d
}
// **The hearings the grants part withholds, on the bus** (issue 1955 L7, mesh-delivery e564eaf): a hearing of a restricted
// stream a module asks in the tool runner, withheld because only an account of its own may hear it (ADR 0376 §2), kept
// in the grants part's state `withheld`, one key `<module>.<stream, lower case>.<machine>` (the controller's condition id
// of it), written on change only and deleted once heard; the grants part's tools read it (D-heard was dropped on
// 2026-10-30, so no other module reads it).
const (
GrantsWithheldState = "withheld"
GrantsWithheldBucket = GrantsModule + "_" + GrantsWithheldState
GrantsWithheldRead = GrantsModule + "." + GrantsWithheldState
WithheldVersion = 1
)
// WithheldHearing is one withheld hearing: the machine, the module, the restricted stream and its emitter, the plain
// words of it and the remedy (Withheld.Said), and when the grants part first wrote it as it stands.
type WithheldHearing struct {
V int `json:"v"`
Node string `json:"node"`
Module string `json:"module"`
Stream string `json:"stream"`
Emitter string `json:"emitter"`
Said string `json:"said"`
At time.Time `json:"at"`
}
// WithheldKey is a withheld hearing's key: `<module>.<stream, lower case>.<machine>`.
func WithheldKey(w Withheld) string { return w.Module + "." + strings.ToLower(w.Stream) + "." + w.Node }
+14 -4
View File
@@ -64,6 +64,9 @@ type Declared struct {
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
ReadsRefused []string
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
ReadLogs []string
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
KeyedReads []KeyedRead
@@ -105,6 +108,9 @@ type ProcessPrincipal struct {
Emits []string
// Queues are the module's own work-queue streams this process is the one consumer of.
Queues []QueueOwner
// Removes are what its account may remove from the bus, as its bus declares them (catalogue.BusRemovals; novox/hq
// ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants.
Removes []string
// State is the module's own state, by local name, this process reads and writes.
State []string
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
@@ -154,6 +160,7 @@ func Users(r Records) ([]Principal, error) {
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
served := servedReadsOf(r)
logs := DeclaredLogs(r)
grants := -1
for _, node := range sortedCopy(r.Nodes) {
witness := false
@@ -195,7 +202,7 @@ func Users(r Records) ([]Principal, error) {
for _, d := range r.Assigned[node] {
// A state with named readers is read by them alone (ADR 0547 clause 9): a read named for another module is
// granted nothing, here and in the runtime carrying the module.
d = withNamedReaders(d)
d = withReadLogs(withNamedReaders(d), logs)
// **A process account is its own, delivered in its own secret** (broker-<process id>, novox/hq
// ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module
// itself has an account, and is never carried by the machine's runtime, which runs as the
@@ -204,7 +211,7 @@ func Users(r Records) ([]Principal, error) {
out = append(out, Principal{
Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process,
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
Invokes: pr.Invokes,
Invokes: pr.Invokes, Removes: pr.Removes,
})
}
if runtimeHere && d.Module == RuntimeModule {
@@ -223,12 +230,15 @@ func Users(r Records) ([]Principal, error) {
Kind: KindModule, Node: node, Module: d.Module,
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
})
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
if d.Module == WatchmanModule {
out[len(out)-1].DeadLetterSources = deadLetterSourcesOf(r)
}
if d.Module == GrantsModule {
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
grants = len(out) - 1
@@ -244,7 +254,7 @@ func Users(r Records) ([]Principal, error) {
var carried []Declared
for _, d := range r.Assigned[node] {
if d.RunsAs == "" && runnerServes(d) {
d = withNamedReaders(d)
d = withReadLogs(withNamedReaders(d), logs)
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
d.Hears = ComposedHears(d)
carried = append(carried, d)
+15 -2
View File
@@ -220,7 +220,8 @@ var WritersTable = []WriterRow{
// and delivered again or dropped only through its verb, with why.
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
Others: "read, delivered again or dropped through the controller's dead-letters verb",
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: keepsDeadLetters,
Shared: "the watchman keeps the copies from the controller's lease write, the controller before it; delivering again stays the controller's (novox/hq ADR 0564 clause 5)"},
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
// anything else write them is refused at composition. Built so far: the assignments and each registered
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
@@ -247,9 +248,21 @@ var WritersTable = []WriterRow{
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
// The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the
// node-engine's declared state, created by the grants part, written by each node's engine under its own keys.
// A node's own health report (issue 774): its engine's own key, read by the watchman.
{State: "a machine's health report", Writer: "the node-engine of the machine, its key `<node>` alone",
KeptIn: "key-value " + delivery.NodeHealthBucket, Others: "read by the watchman through `reads`",
Subjects: kvOf(delivery.NodeHealthBucket), Writes: ownNodeKeys},
// A node's declared packages (novox/hq ADR 0565 clause 6): its engine's own key, read by pacman's verbs.
{State: "a machine's declared packages", Writer: "the node-engine of the machine, its key `<node>` alone",
KeptIn: "key-value " + EnginePackagesBucket, Others: "read by pacman through `reads`",
Subjects: kvOf(EnginePackagesBucket), Writes: ownNodeKeys},
{State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `<node>.>`, compare-and-set",
KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts",
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), Writes: ownNodeKeys},
Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket),
Writes: func(p Principal, pattern string) bool {
return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern)
},
Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"},
// The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each
// object under the digest of its content; a node-engine reads one by its name, with no list and no write.
{State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +
+30 -18
View File
@@ -126,23 +126,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
continue
}
made := by[a.Name]
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
loads := BundleLoads(m, a, run)
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
// it reached it by, and a manifest carrying that address names an installation —
// registration refused node-tools for exactly this on 2026-10-02. The build record
@@ -152,7 +136,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
Binary: BinaryOf(a),
Binary: BinaryOf(a), Bus: a.Bus,
})
}
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
@@ -715,3 +699,31 @@ func undeliveredBundles(m Manifest) []string {
}
return problems
}
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
return loads
}
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
+98
View File
@@ -0,0 +1,98 @@
package catalogue
import (
"fmt"
"regexp"
"strings"
)
// **The data items of a node's modules, placed, read from the composition** (novox/hq ADR 0571 clause 3, ADR 0233 §22 to
// §25): disk-load measures each item every module on its node declares, and the node-backup holder, mounts and nfs-server
// read the readings. Each item is its module's declaration with its path resolved to this node's place, its protection
// and how it is measured: the same lines the node-backup holder is handed as `${backup:data}`, composed by the same
// function, so a reader that composes from the bus and a holder handed the file read one list.
// PlacedDataItem is one data item a module on a node declares, as placed on that node.
type PlacedDataItem struct {
Module string `json:"module"`
ID string `json:"item"`
Class string `json:"class"`
// Path is the item's place on the node; CoveredBy the path whose snapshot keeps it (the item, or the item its dump
// writes into), empty when it is not backed up.
Path string `json:"path"`
CoveredBy string `json:"covered_by,omitempty"`
// Protection is backup, redundancy, both or none; Measure walk, dataset or shallow.
Protection string `json:"protection,omitempty"`
Measure string `json:"measure,omitempty"`
}
var (
dataModuleHeader = regexp.MustCompile(`^#\s*([a-z0-9][a-z0-9-]*)$`)
dataSafePath = regexp.MustCompile(`^/[^\s'"\\$` + "`" + `]*$`)
)
// ParseDataItems reads the node-backup holder's data text (`${backup:data}`: a `# <module>` line, then one line per item,
// `item <id> <class> <path> <covered-by or -> <protection> <measure>`) into each module's items, in order. A line it does
// not read is refused, naming the module: a path with a space or a quote is never handed to a measuring shell.
func ParseDataItems(text string) (map[string][]PlacedDataItem, error) {
out := map[string][]PlacedDataItem{}
module := ""
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if m := dataModuleHeader.FindStringSubmatch(line); m != nil {
module = m[1]
continue
}
if strings.HasPrefix(line, "#") || module == "" {
continue
}
f := strings.Fields(line)
if len(f) < 5 || len(f) > 7 || f[0] != "item" || !dataSafePath.MatchString(f[3]) || (f[4] != "-" && !dataSafePath.MatchString(f[4])) {
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
}
it := PlacedDataItem{Module: module, ID: f[1], Class: f[2], Path: f[3], Measure: MeasureWalk}
if len(f) >= 6 {
it.Protection = f[5]
}
if len(f) == 7 {
it.Measure = f[6]
}
if it.Measure != MeasureWalk && it.Measure != MeasureDataset && it.Measure != MeasureShallow {
return nil, fmt.Errorf("%s declares a data line that is not read: %s", module, line)
}
if f[4] != "-" {
it.CoveredBy = f[4]
}
out[module] = append(out[module], it)
}
return out, nil
}
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
func (r Resolution) DataItems(with Rendering) (items map[string][]PlacedDataItem, unplaced []string, err error) {
left := r.LeftOut(with.Settings, with.Adopted)
for module, why := range with.NotPlaced {
if _, already := left[module]; !already {
left[module] = why
}
}
modules := make([]Manifest, 0, len(r.Modules))
for _, m := range r.Modules {
if _, isLeft := left[m.Module]; isLeft {
m = backupView(m)
}
modules = append(modules, m)
}
text, unplaced, err := seatContributions(modules, Manifest{}, BackupSeat+":"+BackupKindData, with,
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
if err != nil {
return nil, unplaced, err
}
items, err = ParseDataItems(text)
return items, unplaced, err
}
+10 -26
View File
@@ -1,7 +1,6 @@
package catalogue
import (
"encoding/json"
"slices"
"strings"
"testing"
@@ -46,31 +45,6 @@ func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
if it, _ := m.DataItem("dumps"); it.BackedUp() {
t.Fatal("a rebuildable item that says none is backed up")
}
// Issue 774's new words first: an offer's credential form and an artifact's bus are accepted and change nothing: the
// manifest that says them composes byte-equal to the one that does not, and no form still means a pair.
plain := strings.Replace(declaredStore, `"resources":[`, `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/x","binary":"x","loads":["x"]}]},"resources":[`, 1)
for _, form := range []string{"pair", "none"} {
worded := strings.Replace(plain, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"`+form+`"}}]`, 1)
worded = strings.Replace(worded, `"loads":["x"]}`, `"loads":["x"],"bus":{"state":["acts"],"removes":["engine-acts"]}}`, 1)
a, b := mustParse(t, plain), mustParse(t, worded)
if b.Provides[0].Credential != nil || b.CredentialFormOf("postgres-database") != a.CredentialFormOf("postgres-database") ||
a.CredentialFormOf("postgres-database") != CredentialFormPair || len(b.Build.Artifacts[0].Bus) == 0 {
t.Fatalf("form %s: offer %+v, form %q against %q", form, b.Provides[0], b.CredentialFormOf("postgres-database"),
a.CredentialFormOf("postgres-database"))
}
b.Build.Artifacts[0].Bus = nil
ra, erra := (Resolution{Node: "anchor", Modules: []Manifest{a}}).Compose(Rendering{})
rb, errb := (Resolution{Node: "anchor", Modules: []Manifest{b}}).Compose(Rendering{})
ja, _ := json.Marshal(ra.Resources)
jb, _ := json.Marshal(rb.Resources)
if erra != nil || errb != nil || string(ja) != string(jb) || len(ra.Resources) == 0 {
t.Fatalf("form %s composes %s against %s (%v, %v)", form, jb, ja, errb, erra)
}
}
if _, err := ParseManifest([]byte(strings.Replace(declaredStore, `"scope":"mesh"}]`, `"scope":"mesh","credential":{"form":"shared"}}]`, 1))); err == nil ||
!strings.Contains(err.Error(), `the form "shared", not "pair" or "none"`) {
t.Fatalf("a form the release does not know: %v", err)
}
}
// Every rule of the section itself, refused at parse in the words of the module.
@@ -218,6 +192,16 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
if data != want {
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
}
// ADR 0571 clause 3: the same text, read into items, as disk-load and the backup holder read it; a path a shell
// would split is refused.
items, err := ParseDataItems(data)
if err != nil || len(items) != 3 || items["media"][0] != (PlacedDataItem{Module: "media", ID: "films", Class: "irreplaceable",
Path: "/tank/films", Protection: "redundancy", Measure: MeasureDataset}) || items["pg"][0].CoveredBy != "/srv/store/dumps" {
t.Fatalf("the data lines read as %+v: %v", items, err)
}
if _, err := ParseDataItems("# a\nitem x cache /a b - none walk\n"); err == nil {
t.Fatal("a path with a space was read")
}
if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil {
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
}
+16 -22
View File
@@ -700,13 +700,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
handsSecrets := with.Handing(m)
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
if handsSecrets {
owner := m.SecretsOwner
if m.Module == RuntimeModule && r.Account != "" {
owner = r.Account
}
if o := m.OwnSecrets[name].Owner; o != "" {
owner = o
}
owner := r.ownSecretOwner(m, name)
first = append(first, ownedBy(owner, map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
}))
@@ -718,13 +712,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if string(box) == string(ForeseenVaultBox) {
*foreseen = append(*foreseen, m.Module+"/"+name)
}
owner := m.SecretsOwner
if m.Module == RuntimeModule && r.Account != "" {
owner = r.Account
}
if o := m.OwnSecrets[name].Owner; o != "" {
owner = o
}
owner := r.ownSecretOwner(m, name)
first = append(first, ownedBy(owner, map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
}))
@@ -754,14 +742,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// the manifest, because a manifest cannot say ${machine:account} safely — a node with no
// account has nothing to resolve it to, and then the runtime runs as root and the file
// stays root's.
owner := m.SecretsOwner
if m.Module == RuntimeModule && r.Account != "" {
owner = r.Account
}
if o := m.OwnSecrets[name].Owner; o != "" {
owner = o
}
first = append(first, ownedBy(owner, map[string]any{
first = append(first, ownedBy(r.ownSecretOwner(m, name), map[string]any{
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
}))
}
@@ -1677,6 +1658,19 @@ func (r Resolution) provisionsAs(m Manifest) string {
return m.SecretsOwner
}
// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the
// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR
// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none).
func (r Resolution) ownSecretOwner(m Manifest, name string) string {
if o := m.OwnSecrets[name].Owner; o != "" {
return o
}
if r.Account != "" && (m.Module == RuntimeModule || ToolsBusSecret(m, name)) {
return r.Account
}
return m.SecretsOwner
}
// grantPath is where one consumer's sealed credential lands on the providing machine.
//
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
+25
View File
@@ -1,5 +1,7 @@
package catalogue
import "fmt"
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
@@ -105,6 +107,29 @@ func SeatSays(emits []string, event string) bool {
return false
}
// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead,
// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A
// consumer of `<seat>.<event>` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish
// on `mesh.seat.<seat>.event.<event>`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught
// here, when the manifest is read at the build, not when nothing is heard on the machines.
func seatEventProblems(m Manifest) []string {
var problems []string
for _, c := range m.Claims {
seat, known := SeatNamed(c.Name)
if !known || len(seat.Emits) == 0 {
continue
}
for _, e := range m.Emits {
if SeatSays(seat.Emits, e) {
problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+
"say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+
"no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e))
}
}
}
return problems
}
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
// `>` or `**` the rest.
func topicMatches(pattern, event string) bool {
+19 -3
View File
@@ -42,9 +42,10 @@ func consumerOn(t *testing.T, m Manifest, w World) Resolution {
node := workstation()
node.At = "workstation.internal"
stores := shelf(m,
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
Credential: &OfferCredential{Form: CredentialFormPair}}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormPair}}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
r, err := Resolve(stores, []string{m.Module}, node, w)
if err != nil {
@@ -130,7 +131,10 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
return mustParse(t, raw+`}`)
}
for credential, want := range map[string]string{
``: CredentialFormPair,
// No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it.
``: "",
`,"credential":{"form":"pair"}`: CredentialFormPair,
`,"credential":{"form":"none"}`: CredentialFormNone,
`,"credential":{"own":"admin"}`: CredentialFormShared,
`,"credential":{"own":"token"}`: CredentialFormGiven,
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
@@ -153,6 +157,18 @@ func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
t.Errorf("a module offering nothing has the credential form %q", got)
}
// The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none.
for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form",
`,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`,
`,"credential":{"form":"none"}`: ""} {
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}`
var m Manifest
_ = json.Unmarshal([]byte(raw), &m)
problems := strings.Join(CredentialFormProblems(m), "; ")
if (refused == "") != (problems == "") || !strings.Contains(problems, refused) {
t.Errorf("%q: %q, want %q", credential, problems, refused)
}
}
}
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
+9 -6
View File
@@ -24,12 +24,15 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
if string(out) != `["changes",{"name":"moves","max-mib":2048}]` {
t.Fatalf("written back as %s", out)
}
// Issue 774's new words first: max-age-days is accepted and written back; nothing reads it yet.
aged, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
if out, _ := json.Marshal(aged.Logs); err != nil || aged.Logs[0].MaxAgeDays != 400 || string(out) != `[{"name":"acts","max-age-days":400}]` {
t.Fatalf("max-age-days read as %+v, written %s: %v", aged.Logs, out, err)
// 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it.
aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
if err != nil || aged.Logs[0].MaxAgeDays != 400 {
t.Fatalf("an aged log read as %+v, %v", aged.Logs, err)
}
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} {
if out, _ := json.Marshal(aged.Logs); string(out) != `[{"name":"acts","max-age-days":400}]` {
t.Fatalf("an aged log written back as %s", out)
}
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} {
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
}
@@ -48,7 +51,7 @@ func TestALogIsNamedLocallyAndCapped(t *testing.T) {
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`},
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
} {
_, err := ParseManifest([]byte(c.manifest))
+50 -25
View File
@@ -287,18 +287,12 @@ type OfferCredential struct {
Own string `json:"own,omitempty"`
IssuedBy string `json:"issued-by,omitempty"`
Tool string `json:"tool,omitempty"`
// Form is the release's word for an offer that names no own secret and no issuer (novox/hq ADR 0570; issue 774's
// new words first): CredentialFormPair or CredentialFormNone. Accepted and read by nothing yet: an offer that says
// only its form is parsed as one that says no credential, which is a pair, as before.
// Form says, for an offer that names no own secret and no issuer, which it is: CredentialFormPair, a login of
// each consumer's own, or CredentialFormNone, no credential at all (the lead, 2026-10-29, for novox/hq ADR 0570):
// never left to a default.
Form string `json:"form,omitempty"`
}
// The forms an offer's credential may say by itself (OfferCredential.Form).
const (
CredentialFormWordPair = "pair"
CredentialFormWordNone = "none"
)
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
const IssuedBySoftware = "software"
@@ -372,6 +366,9 @@ const (
// CredentialFormGiven is given by a person (novox/hq ADR 0337): the offer's credential, or the own secret it
// shares, is issued outside the mesh; the mesh never makes it.
CredentialFormGiven = "given"
// CredentialFormNone is an offer whose consumers present no credential: its outcome is the provider's have, and
// the vault mints nothing for it.
CredentialFormNone = "none"
)
// CredentialFormOf is the credential form this module's offer of a provision declares, read from the offer's
@@ -385,7 +382,7 @@ func (m Manifest) CredentialFormOf(provision string) string {
}
switch c := o.Credential; {
case c == nil:
return CredentialFormPair
return "" // undeclared: refused at the catalogue (CredentialFormProblems), never read as a pair
case c.IssuedBy == IssuedOutside:
return CredentialFormGiven
case c.IssuedBy == IssuedBySoftware:
@@ -395,11 +392,33 @@ func (m Manifest) CredentialFormOf(provision string) string {
case c.Own != "":
return CredentialFormShared
}
return CredentialFormPair
return o.Credential.Form
}
return ""
}
// CredentialFormProblems refuses an offer that does not say its credential's form (the lead, 2026-10-29): an own
// secret, an issuer, or `form` pair or none, and `form` only where neither of the others is said.
func CredentialFormProblems(m Manifest) []string {
var problems []string
for _, o := range m.Provides {
c := o.Credential
switch {
case c == nil || (c.Own == "" && c.IssuedBy == "" && c.Form == ""):
problems = append(problems, fmt.Sprintf("%s provides %q and says no credential form: say `credential` with "+
"`form` pair (a login of each consumer's own) or none (no credential), or the own secret or issuer it is",
m.Module, o.Name))
case c.Form != "" && (c.Own != "" || c.IssuedBy != ""):
problems = append(problems, fmt.Sprintf("%s provides %q with `form` beside an own secret or an issuer; `form` "+
"says only pair or none", m.Module, o.Name))
case c.Form != "" && c.Form != CredentialFormPair && c.Form != CredentialFormNone:
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name,
c.Form, CredentialFormPair, CredentialFormNone))
}
}
return problems
}
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
func (m Manifest) ProvisionsSharing(own string) []string {
var out []string
@@ -1021,6 +1040,8 @@ type Bundle struct {
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
Binary string `json:"binary,omitempty"`
// Bus is the artifact's bus account, kept on the resolved manifest so the grant composition reads it (BusAccounts).
Bus *ProcessBus `json:"bus,omitempty"`
}
// Build says how to produce this module's artifacts from its source.
@@ -1158,10 +1179,10 @@ type Artifact struct {
// delivered version. A bundle that carried the package's name was delivered correctly, reported
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
Binary string `json:"binary,omitempty"`
// Bus is the release's word for a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; issue 774's new words
// first): accepted as it is written and read by nothing yet.
Bus json.RawMessage `json:"bus,omitempty"`
// Bus is a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): read as a process's
// (ProcessBus), a principal named after the bundle's binary (BusAccounts), its secret broker-<binary>. A tools bundle
// is run by the node's tool runtime per call, so it declares its account here and runs no idle service for it.
Bus *ProcessBus `json:"bus,omitempty"`
// URL is where an archive's upstream file is fetched from, for an archive that is a release
// somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6).
@@ -1783,6 +1804,10 @@ func (m Manifest) marshalFields() ([]byte, error) {
return json.Marshal(keys)
}
// CredentialProvisionPrefix starts the name of a provision's credential, the vault's provision beside it (novox/hq ADR
// 0570 clause 6): `secret-<provision>`.
const CredentialProvisionPrefix = "secret-"
// SecretFile is one file a module is given a credential in: the local name it goes by inside
// the module (empty for the ordinary one-file case, where the requirement's name serves) and where.
type SecretFile struct {
@@ -1940,11 +1965,17 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
"%s says its instances are %q; the one word is %q, for a module that is the same on every machine",
m.Module, m.Instances, InstancesInterchangeable))
}
for i, offer := range m.Provides {
for _, offer := range m.Provides {
p := offer.Name
if !name.MatchString(p) {
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
}
// A provision's credential is a provision of its own, `secret-<provision>` (novox/hq ADR 0570 clause 6, the
// lead 2026-10-29): no module provides a name of that form, so the two keys never meet.
if strings.HasPrefix(p, CredentialProvisionPrefix) {
problems = append(problems, fmt.Sprintf("%s provides %q; a name starting %q is the mesh's for a provision's "+
"credential, kept by the vault (novox/hq ADR 0570)", m.Module, p, CredentialProvisionPrefix))
}
// A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it
// leaves room for the shortest identity the mesh makes, or it would refuse every consumer.
if id := offer.Identity; id != nil && !id.None {
@@ -1993,15 +2024,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
} else if offer.Credential != nil {
own, declared := m.OwnSecrets[offer.Credential.Own]
switch {
case offer.Credential.Own == "" && offer.Credential.IssuedBy == "" && offer.Credential.Tool == "" &&
offer.Credential.Form != "":
// The form alone (issue 774's new words first): pair or none, accepted and meaning what no credential
// meant before, a pair (the offer is read as saying none).
if f := offer.Credential.Form; f != CredentialFormWordPair && f != CredentialFormWordNone {
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, p, f,
CredentialFormWordPair, CredentialFormWordNone))
}
m.Provides[i].Credential = nil
case offer.Credential.Own == "" && offer.Credential.Form != "":
// The form alone, pair or none (CredentialFormProblems judges it).
case offer.Credential.Own == "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential that names no own secret", m.Module, p))
@@ -2110,6 +2134,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
problems = append(problems, declaredSeatProblems(m)...)
problems = append(problems, RunsAsProblems(m)...)
problems = append(problems, ProcessBusProblems(m)...)
problems = append(problems, seatEventProblems(m)...)
problems = append(problems, ReadsHolderProblems(m)...)
problems = append(problems, FilesHolderProblems(m)...)
if m.Computed != "" && len(m.Resources) > 0 {
+184 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
)
@@ -29,6 +30,188 @@ type ProcessBus struct {
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
// seat:<seat>.<verb> (as Manifest.Invokes), or * for every tool on the mesh.
Invokes []string `json:"invokes,omitempty"`
// Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR
// 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process.
Removes []string `json:"removes,omitempty"`
}
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: the controller's consumer on
// DEAD_LETTER_NOTICES (the lease write's step, clause 5), and keys of the node-engine's acts bucket. A further consumer
// or bucket is a change of this set, through a decision.
const (
RemovesControllerDeadLetterConsumer = "DEAD_LETTER_NOTICES.controller"
RemovesEngineActs = "mesh-host_acts"
)
// BusRemovals is the closed set of what a bus account may remove.
var BusRemovals = []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs}
// BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564
// Limits 4), by its id (the process resource's, or the bundle's binary), with what its bus says.
type BusAccount struct {
ID string
Bus ProcessBus
// Tools says it is a tools bundle's, run by the node's tool runtime, not a process the node runs.
Tools bool
}
// BusAccounts is every own bus account of the module, processes first in resource order, then tools bundles in artifact
// order: what the grant composition composes a principal for, each delivered in its own secret broker-<id>. A tools
// bundle's is read from the repository's build, or from the resolved manifest's bundles.
func (m Manifest) BusAccounts() []BusAccount {
var out []BusAccount
buses := m.ProcessBuses()
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
if b, ok := buses[id]; ok && fmt.Sprint(r["type"]) == "process" {
out = append(out, BusAccount{ID: id, Bus: b})
}
}
seen := map[string]bool{}
if m.Build != nil {
for _, a := range m.Build.Artifacts {
if a.Bus != nil && a.Kind == ArtifactBundle && BinaryOf(a) != "" && !seen[BinaryOf(a)] {
seen[BinaryOf(a)] = true
out = append(out, BusAccount{ID: BinaryOf(a), Bus: *a.Bus, Tools: true})
}
}
}
for _, b := range m.Bundles {
if b.Bus != nil && b.Binary != "" && !seen[b.Binary] {
seen[b.Binary] = true
out = append(out, BusAccount{ID: b.Binary, Bus: *b.Bus, Tools: true})
}
}
return out
}
// ToolsBusSecret says name is the own secret a tools bundle's bus account is held in: `broker-<binary>` for a bundle
// whose bus the manifest declares (ADR 0564 Limits 4). Its file belongs to the runtime's account, which runs the bundle.
func ToolsBusSecret(m Manifest, name string) bool {
for _, a := range m.BusAccounts() {
if a.Tools && name == "broker-"+a.ID {
return true
}
}
return false
}
// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared
// as an own secret `broker-<binary>`, at `${dir:<id>}/broker-<binary>` for a directory resource every parent of which the
// runtime's account may pass (passableDir); the file itself is 0600 and the runtime account's.
func toolsSecretProblems(m Manifest) []string {
var problems []string
for _, a := range m.BusAccounts() {
if !a.Tools {
continue
}
name := "broker-" + a.ID
own, declared := m.OwnSecrets.Plain()[name]
if !declared {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+
"account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name))
continue
}
dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}")
if !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") || !passableDir(m, dir, 0) {
problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s, which the node's tool runtime cannot "+
"reach: a tools bundle's account lies in a directory resource the runtime's account may pass, every parent "+
"included, such as {\"id\":\"tools-secret\",\"type\":\"directory\",\"path\":\"%s/%s-tools\",\"mode\":\"0711\"} "+
"with the secret at ${dir:tools-secret}/%s; never under the module's own or mesh place, which are its "+
"own (novox/hq ADR 0564 Limits 4)", m.Module, name, own.Path, meshStateRoot, m.Module, name))
}
}
return problems
}
// meshStateRoot is the mesh's directory of every module's mesh place on a node of the default data root: root's, mode
// 0755, so the runtime's account passes it, while each module's own place beneath it is 0700.
const meshStateRoot = defaultDataRoot + "/" + placeMesh
// passableDir says the runtime's account may pass the directory resource id and every parent the mesh makes for it: its
// own mode lets others pass (the last digit odd), and it is either stated directly under meshStateRoot or beneath a
// placed directory that is passable in turn. A pathless directory resolves under the module's own root or its mesh
// place, which are the module's own, so it never is.
func passableDir(m Manifest, id string, depth int) bool {
if depth > 4 {
return false
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != id {
continue
}
mode, _ := r["mode"].(string)
if mode == "" || !strings.ContainsAny(mode[len(mode)-1:], "1357") {
return false
}
path, _ := r["path"].(string)
switch {
case strings.HasPrefix(path, "${dir:"):
parent, _, _ := strings.Cut(strings.TrimPrefix(path, "${dir:"), "}")
return passableDir(m, parent, depth+1)
case strings.HasPrefix(path, "/"):
return filepathDir(strings.TrimRight(path, "/")) == meshStateRoot
}
return false
}
return false
}
func filepathDir(p string) string {
if i := strings.LastIndex(p, "/"); i > 0 {
return p[:i]
}
return "/"
}
// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle
// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a
// module that does not hold mesh-broker. Every other field is judged as a process's.
func toolsBusProblems(m Manifest) []string {
var problems []string
var artifacts []Artifact
if m.Build != nil {
artifacts = m.Build.Artifacts
}
claimsBroker := false
for _, c := range m.Claims {
claimsBroker = claimsBroker || c.Name == "mesh-broker"
}
for _, a := range artifacts {
if a.Bus == nil {
continue
}
if a.Kind != ArtifactBundle || BinaryOf(a) == "" {
problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+
"binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
continue
}
b := *a.Bus
if len(b.Queues) > 0 || b.Asks {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+
"bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
}
for _, r := range b.Removes {
if !slices.Contains(BusRemovals, r) {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+
"(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", ")))
}
}
if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) {
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+
"module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name))
}
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "process" {
continue
}
if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 {
problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+
"may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"]))
}
}
return problems
}
// UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or
@@ -87,7 +270,7 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus {
// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or
// seat verb's shape on invokeProblems.
func ProcessBusProblems(m Manifest) []string {
var problems []string
problems := append(toolsBusProblems(m), toolsSecretProblems(m)...)
streams := map[string]bool{}
for _, s := range m.Streams {
streams[s.Name] = true
+59
View File
@@ -47,6 +47,65 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) {
!b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) {
t.Fatalf("the bus read as %+v", b)
}
// 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a
// process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the
// bus module's (mesh-broker) alone.
toolsIn := func(claims, bus, mode, where string) string {
return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` +
`"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` +
`"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode +
`","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` +
`"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}`
}
tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") }
broker := `[{"name":"mesh-broker","scope":"mesh"}]`
for _, c := range []struct {
name, manifest, refused string
}{
{name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts"]}`)},
{name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"},
{name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"},
{name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"},
{name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"},
{name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"},
// The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29).
{name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700",
"/var/lib/mesh/nats-tools"), refused: "cannot reach"},
// Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29).
{name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711",
"${dir:mesh-state}/tools"), refused: "cannot reach"},
} {
m, err := ParseManifest([]byte(c.manifest))
if c.refused != "" {
if err == nil || !strings.Contains(err.Error(), c.refused) {
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
}
continue
}
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
accounts := m.BusAccounts()
if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools ||
!strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs}) {
t.Errorf("%s: the accounts are %+v", c.name, accounts)
}
// Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay
// its own.
if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" ||
(Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" {
t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools"))
}
// Resolved, the build goes and its bundles keep the bus (build.go).
resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}}
if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" {
t.Errorf("%s: resolved, the accounts are %+v", c.name, got)
}
}
if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools {
t.Errorf("the process's account is %+v", got)
}
}
func strSliceEqual(a, b []string) bool {
+6
View File
@@ -282,6 +282,12 @@ func CatalogueProblems(shelf Shelf) []string {
problems = append(problems, RestrictedHearingProblems(shelf)...)
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...)
// Every offer says its credential's form, never left to a default (the lead, 2026-10-29, for novox/hq ADR 0570).
for _, module := range shelfOrder(shelf) {
problems = append(problems, CredentialFormProblems(shelf[module])...)
// And its data in the bus's predefined forms, a stream of its own refused (the lead, 2026-10-29).
problems = append(problems, PredefinedFormProblems(shelf[module])...)
}
// Who declares what, and who declared it first.
declaredBy := map[string]string{}
+2 -2
View File
@@ -109,7 +109,7 @@ func TestAClaimMustMatchTheDeclaredScope(t *testing.T) {
func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
// It delivers the bus, so its holder provides the bus — the rule this check now enforces.
m := Manifest{Module: "nats",
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormNone}}},
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
if got := problemsFor(t, Shelf{"nats": m}); got != "" {
t.Fatalf("a mesh seat was refused by the derived check: %s", got)
@@ -186,7 +186,7 @@ func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
// A store whose bus seat delivers something this module does provide.
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
raw := []byte(`{"module":"a-bus","version":"1",` +
`"provides":[{"name":"mesh-bus","scope":"mesh"}],` +
`"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` +
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
m, err := ParseManifest(raw)
+12
View File
@@ -188,6 +188,18 @@ func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) {
if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 {
t.Fatalf("a claim on a declared seat was refused: %v", problems)
}
// The second of a kind (issue 1952; the lead, 2026-10-29): a holder that lists its seat's event among its own emits
// would say it where no consumer hears it, so the manifest is refused, naming the seat's subject; the seat's
// holder saying nothing of its own is accepted.
holder := func(emits string) []byte {
return []byte(`{"module":"mesh-self-check","version":"1","emits":` + emits + `,"claims":[{"name":"mesh-self-check","scope":"mesh","serves":["doctor","probes"]}]}`)
}
if _, err := ParseManifest(holder(`["checked"]`)); err == nil || !strings.Contains(err.Error(), "mesh.seat.mesh-self-check.event.checked") {
t.Errorf("a seat's event listed as the module's own: %v", err)
}
if _, err := ParseManifest(holder(`[]`)); err != nil {
t.Errorf("a holder saying its seat's event on the seat was refused: %v", err)
}
}
// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing
+28
View File
@@ -630,3 +630,31 @@ func uncovered(patterns []string) []string {
slices.Sort(out)
return out
}
// **A module keeps its data in the bus's predefined forms** (the lead and the operator, 2026-10-29; the operator's rule of
// 2026-10-22, "data storage is done using our predefined nats bus options"): a state, a log or an object store, which the
// runtime reads and writes for its tools and other modules read by a declared `reads`. A stream of its own is outside
// them and refused, naming it. StreamsKeptUntilMoved are the streams declared before this check, each allowed by name
// until its module moves it; the list only shrinks.
var StreamsKeptUntilMoved = map[string][]string{
"brother": {"device-asks"},
"mesh-build-go": {"toolchains-go"},
"mesh-build-node": {"toolchains-node"},
"mesh-mail": {"mail", "mail-sends"},
"only-office": {"convert-asks"},
"mesh-delivery-assigner": {"placement"},
"mesh-delivery-checker": {"checks"},
"mesh-delivery-forge-bridge": {"forge"},
}
// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it.
func PredefinedFormProblems(m Manifest) []string {
var out []string
for _, s := range m.Streams {
if !slices.Contains(StreamsKeptUntilMoved[m.Module], s.Name) {
out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+
"a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name))
}
}
return out
}
+8
View File
@@ -96,6 +96,14 @@ func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 {
t.Fatalf("one owner refused: %v", got)
}
// And a stream of its own is outside the bus's predefined forms (the lead, 2026-10-29): refused, naming it, unless it
// is one kept by name until its module moves it.
if !strings.Contains(got, `a keeps the stream "mail", outside the bus's predefined forms`) {
t.Fatalf("a stream of its own was not refused: %s", got)
}
if got := PredefinedFormProblems(Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 {
t.Fatalf("a stream kept until it moves was refused: %v", got)
}
}
// The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4).
+44
View File
@@ -3,6 +3,7 @@ package catalogue
import (
"fmt"
"strings"
"time"
)
// What the mesh does when a module's build moves (novox/hq ADR 0236, extending ADR 0162 §3 and ADR
@@ -118,3 +119,46 @@ func (m Manifest) irreplaceable() string {
}
return ""
}
// PersonPolicy is a person's choice of a module's upgrade policy, as the assigner keeps it in its state `policies`, one
// key per module (novox/hq ADR 0565 clause 7, issue 1953 L5-S2): the policy, why, who as the runtime proved the caller,
// and when. Set and taken back through `mesh-wants.upgrade`, each recorded as a hand act.
type PersonPolicy struct {
V int `json:"v"`
Module string `json:"module"`
Policy string `json:"policy"`
Why string `json:"why"`
By string `json:"by"`
At time.Time `json:"at"`
}
// PersonPolicyVersion is the version of a person's policy's layout.
const PersonPolicyVersion = 1
// Check refuses a choice the assigner could not keep: a policy that is not roll, together or record, one other than
// roll without why, or one with no person.
func (p PersonPolicy) Check() error {
switch {
case p.Module == "":
return fmt.Errorf("a person's policy names its module")
case p.Policy != PolicyRoll && p.Policy != PolicyTogether && p.Policy != PolicyRecord:
return fmt.Errorf("%s: a policy is %q, %q or %q, not %q", p.Module, PolicyRoll, PolicyTogether, PolicyRecord, p.Policy)
case strings.TrimSpace(p.Why) == "":
return fmt.Errorf("%s: a person's policy says why", p.Module)
case strings.TrimSpace(p.By) == "":
return fmt.Errorf("%s: a person's policy names who chose it", p.Module)
}
return nil
}
// Upgrade is a module's policy and where it comes from (ADR 0565 clause 7): the one derivation every reader takes,
// `mesh-wants.upgrade` and each node agent's judgement of a move alike. A person's choice over the module's word, its
// irreplaceable data and the default; the bus is never rolled, whoever says so (ADR 0236). By names the person when one
// chose.
func Upgrade(m Manifest, chosen *PersonPolicy) (policy, from, why, by string) {
policy, from, why = DerivedUpgrade(m)
if from == FromBus || chosen == nil {
return policy, from, why, ""
}
return chosen.Policy, FromPerson, chosen.Why, chosen.By
}
+21 -4
View File
@@ -32,11 +32,28 @@ func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) {
if policy != c.policy || from != c.from {
t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from)
}
// ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the
// derivation as it was.
person := &PersonPolicy{V: PersonPolicyVersion, Module: c.m.Module, Policy: PolicyTogether, Why: "one zone", By: "person.jochen"}
policy, from, _, by := Upgrade(c.m, person)
wantPolicy, wantFrom, wantBy := PolicyTogether, FromPerson, "person.jochen"
if c.from == FromBus {
wantPolicy, wantFrom, wantBy = c.policy, FromBus, ""
}
if policy != wantPolicy || from != wantFrom || by != wantBy {
t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by)
}
if policy, from, _, _ := Upgrade(c.m, nil); policy != c.policy || from != c.from {
t.Errorf("%s with no choice: %s from %s", c.name, policy, from)
}
}
}
// A policy other than roll says why, and an unknown one is refused.
func TestAnUpgradePolicySaysWhy(t *testing.T) {
for _, p := range []PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: PolicyRecord, By: "p"},
{Module: "m", Policy: PolicyRecord, Why: "x"}} {
if p.Check() == nil {
t.Errorf("%+v was accepted", p)
}
}
// A policy other than roll says why, and an unknown one is refused.
for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} {
if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 {
t.Errorf("%+v was accepted", u)
+116
View File
@@ -130,6 +130,22 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
n.Kept = kept
}
}
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
for i := range reading.Nodes {
n := &reading.Nodes[i]
if n.Name != node || n.RunsTools == nil {
continue
}
kept := make(map[string]catalogue.Manifest, len(n.Kept))
for m, man := range n.Kept {
if man.Stubbed && man.RunsAs == "" {
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
}
kept[m] = man
}
n.Kept = kept
}
isCarried := func(module, id string) bool {
if module == "" {
module, _ = declaration.ModuleGuess(id)
@@ -293,6 +309,106 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
return false
}
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
type resource struct {
ID string `json:"id"`
Module string `json:"module"`
Type string `json:"type"`
Path string `json:"path"`
Source string `json:"source"`
Digest string `json:"digest"`
Env map[string]string `json:"env"`
}
var env map[string]string
out := map[string][]catalogue.Bundle{}
for _, c := range running {
for _, raw := range c.Resources {
var r resource
if json.Unmarshal(raw, &r) != nil {
continue
}
owner := r.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(r.ID)
}
switch {
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
env = r.Env
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
if r.Path == catalogue.BundlePath(owner, name) {
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
}
}
}
}
if env == nil {
return nil
}
var words map[string]map[string]string
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
module, path, ok := strings.Cut(entry, "=")
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
continue
}
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
bundles := out[module]
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
if !ok || i < 0 {
continue // no archive of it runs: nothing to load
}
var own map[string]string
for w, v := range words[module] {
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
continue // the mesh's to say, composed again
}
if own == nil {
own = map[string]string{}
}
own[w] = v
}
bundles[i].Loads = append(bundles[i].Loads, load)
bundles[i].Env = own
}
for _, bundles := range out {
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
}
return out
}
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
var out []catalogue.Bundle
for _, b := range runs {
if len(b.Loads) == 0 {
for _, built := range man.Bundles {
if built.Name == b.Name {
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
}
}
if man.Build != nil {
run := catalogue.RunByAResource(man)
for _, a := range man.Build.Artifacts {
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
}
}
}
}
out = append(out, b)
}
return out
}
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
// alone (issue 1126).
+86
View File
@@ -8,6 +8,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
@@ -96,6 +97,91 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
t.Errorf("a node the mesh does not have was composed")
}
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
// held module's new build's bundles are not delivered.
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
store := catalogue.Manifest{Module: "artifacts", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
if err != nil {
t.Fatal(err)
}
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
runtimeRuns := func(dropped bool) delivery.Composed {
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
if dropped {
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
}
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
}
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
for _, row := range []struct {
name string
set func(n *Node) delivery.Composed // and what runs of lamp
fps map[string]string
dropped bool
}{
{"folded at a build of the old form", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, false},
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, true},
{"held, its new build not delivered", func(n *Node) delivery.Composed {
n.Held = map[string]bool{"lamp": true}
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
return lr
}, map[string]string{"lamp": fpFor('1')}, false},
} {
tools := aMesh(t)
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
anchor := nodeOf(t, &tools, "anchor")
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
anchor.SealingKey = keyOf(t, "anchor")
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
var env map[string]any
for _, c := range out {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Env map[string]any `json:"env"`
}
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
env = r.Env
}
}
}
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
}
}
}
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
+8
View File
@@ -23,6 +23,10 @@ const (
// Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
// refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
Foreseeing
// Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
// for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
// The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
Planning
)
// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
@@ -344,6 +348,10 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c
continue
}
sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
if !held && mode == Planning {
mark(m.Module, name)
continue
}
if !held && mode == Foreseeing {
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
// named; refused by it -- refused here, the same words (novox/hq issue 275).
+54
View File
@@ -308,6 +308,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
}
}
if node == s.Node {
// What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126).
n.RunsTools = compose.ToolsRunning(s.Running)
for _, module := range s.InOwn {
if n.InOwn == nil {
n.InOwn = map[string]bool{}
@@ -424,6 +426,48 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
return out, skipped, held, err
}
// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it:
// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret.
func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) {
in, err := Input(ctx, r, mesh, s)
if err != nil {
return nil, err
}
return PlansOf(in, s.Node)
}
// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the
// input holds none for stands in, so no value and no record of one is needed.
func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
return plansIn(in, node, compose.Planning)
}
// 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) {
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)
}
with := rendered.Rendering
with.Generators = gens
composed, err := resolved.Plan.Compose(with)
if err != nil {
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
}
return composed.Plans, nil
}
// parser reads a manifest as the indexer does, against the mesh's predefined keys when given.
func parser(mesh Mesh) func([]byte) (catalogue.Manifest, error) {
return func(raw []byte) (catalogue.Manifest, error) {
@@ -809,3 +853,13 @@ func membershipOf(own delivery.Composed) string {
}
return ""
}
// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans
// composes (novox/hq ADR 0571 clause 3): disk-load measures these.
func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) {
in, err := Input(ctx, r, mesh, s)
if err != nil {
return nil, nil, err
}
return compose.DataItems(in, s.Node)
}
+111
View File
@@ -14,6 +14,7 @@ import (
"os"
"reflect"
"slices"
"sort"
"strings"
"testing"
@@ -694,6 +695,47 @@ func TestInput(t *testing.T) {
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
}
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
noSecrets := movedMesh(true)
noSecrets.own = nil
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatalf("the plans from the bus: %v", err)
}
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
if err != nil {
t.Fatal(err)
}
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
gj, _ := json.MarshalIndent(seat, "", " ")
wj, _ := json.MarshalIndent(controllers, "", " ")
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
}
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil || len(unplaced) > 0 || len(items) == 0 {
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
}
for module, its := range items {
for _, it := range its {
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
t.Errorf("%s's item %+v is not placed", module, it)
}
}
}
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
// mesh-provisioner and disk-load, the lead 2026-10-29).
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
for _, body := range noSecrets.archives {
return body, nil
}
return nil, errors.New("no archive")
}), Mesh{}, Scope{Node: "n1"})
if err != nil || !reflect.DeepEqual(viaStates, items) {
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
}
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
@@ -739,3 +781,72 @@ func TestInput(t *testing.T) {
}
}
}
// mapStates are read states by name, key to value, as a module's runtime gives them.
type mapStates map[string]map[string][]byte
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
v, ok := m[state][key]
return v, ok, nil
}
func (m mapStates) Keys(state string) ([]string, error) {
var out []string
for k := range m[state] {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// statesOf is b laid out as the states StatesReader reads.
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
t.Helper()
out := mapStates{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if out[state] == nil {
out[state] = map[string][]byte{}
}
out[state][key] = raw
}
placements, _ := b.Placements(ctx)
for node, p := range placements {
k, _ := delivery.PlacementKey(node)
raw, err := delivery.EncodePlacement(p)
put(PlacementRead, k, raw, err)
}
records, _ := b.Records(ctx)
for node, r := range records {
k, _ := delivery.HaveNodeKey(node)
rec := r
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(HaveRead, k, raw, err)
}
modules, _ := b.Modules(ctx)
for name, m := range modules {
raw, err := delivery.EncodeModule(m)
put(ModulesRead, name, raw, err)
var mf struct{ Module string }
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
k, _ := delivery.ManifestsKey(m.Manifest)
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
put(ManifestsRead, k, raw, err)
}
for key, build := range b.builds {
module, fp, _ := strings.Cut(key, " ")
k, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(BuildsRead, k, raw, err)
}
for nm, values := range b.values {
for k, v := range values {
value, _ := json.Marshal(v)
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
put(ResolvedRead, nm+"."+k, raw, err)
}
}
return out
}
@@ -0,0 +1,183 @@
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
package frombustest
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"embed"
"encoding/json"
"errors"
"strings"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
//go:embed testdata
var testdata embed.FS
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
type Bus struct {
Placement map[string]delivery.Placement
Record map[string]delivery.NodeRecord
Module map[string]delivery.Module
Manifests map[string][]byte
Value map[string]map[string]any // by node.module
Own []compose.OwnSecretRow
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
Archives map[string][]byte // by digest
Serves map[string]map[string]any // another node's values, by node.module
Held bool
}
var _ frombus.Reader = (*Bus)(nil)
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.Placement, nil
}
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.Manifests[digest]
if !ok {
return nil, errors.New("no such manifest")
}
return raw, nil
}
func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) {
return b.Value[node+"."+module], nil
}
func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil }
func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
var out []compose.Credential
var issued []compose.Issued
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
if b.Held {
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
}
}
return out, issued, nil
}
func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
return b.Builds[module+" "+fp], nil
}
func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) {
body, ok := b.Archives[digest]
if !ok {
return nil, errors.New("the store holds no such blob")
}
return body, nil
}
func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
out := map[string]any{}
for _, k := range keys {
if v, ok := b.Serves[node+"."+module][k]; ok {
out[k] = v
}
}
return out, nil
}
// Node is the one node of the mesh Provisioned composes.
const Node = "n1"
// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's
// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans
// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider.
func Provisioned() (*Bus, error) {
read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) }
resticRaw, err := read("catalogue-c05aa5e6-restic.module.json")
if err != nil {
return nil, err
}
var restic map[string]any
if err := json.Unmarshal(resticRaw, &restic); err != nil {
return nil, err
}
restic["requires"] = []string{"postgres-database"}
if resticRaw, err = json.Marshal(restic); err != nil {
return nil, err
}
clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json")
if err != nil {
return nil, err
}
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"serves":{"artifact-store":{"port":5000}}}`)
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
`"serves":{"postgres-database":{"port":5432}}}`)
var packed bytes.Buffer
zw := gzip.NewWriter(&packed)
tw := tar.NewWriter(zw)
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
text, err := read("catalogue-c05aa5e6-restic.files/" + f)
if err != nil {
return nil, err
}
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
return nil, err
}
if _, err := tw.Write(text); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
archive := delivery.ManifestDigest(packed.Bytes())
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
fps := FPs()
b := &Bus{
Placement: map[string]delivery.Placement{Node: {Node: Node,
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
Builds: map[string]*delivery.BuilderBuild{
"restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
"store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
"postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
},
Archives: map[string][]byte{archive: packed.Bytes()},
Held: true,
}
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
b.Manifests[delivery.ManifestDigest(raw)] = raw
}
return b, nil
}
// FPs are the fingerprints Provisioned's modules are built at.
func FPs() map[string]string {
return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
"store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
}
@@ -0,0 +1,26 @@
package frombustest
import (
"context"
"testing"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
)
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
// provisioner and names its postgres-database, provided by postgres on n1.
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil {
t.Fatal(err)
}
restic, handed := plans["restic"]
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
t.Fatalf("the plans are %+v", plans)
}
}
@@ -0,0 +1,60 @@
{
"module": "mesh-provisioner-client",
"version": "1",
"claims": [
{
"name": "node-provisioner",
"scope": "node"
}
],
"own-secrets": {
"provisioner-broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-provisioner-client",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-provisioner-client",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-provisioner-client"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-provisioner-client",
"place": ".",
"unknown": "program"
},
{
"id": "client",
"type": "process",
"name": "mesh-provisioner-client",
"artifact": "client",
"run": [
"./mesh-provisioner-client"
],
"env": {
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
"MESH_PROVISIONER_STATE": "${dir:state}"
},
"user": "mesh-provisioner-client",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "client",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-provisioner-client",
"binary": "mesh-provisioner-client"
}
]
}
}
@@ -0,0 +1,2 @@
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
${backup:lines}
@@ -0,0 +1,2 @@
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
${backup:data}
@@ -0,0 +1,6 @@
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
daily=${config:keep-daily}
weekly=${config:keep-weekly}
monthly=${config:keep-monthly}
hand=${config:keep-hand-taken-days}
@@ -0,0 +1,199 @@
{
"module": "restic",
"version": "1",
"moved": true,
"claims": [
{
"name": "node-backup",
"scope": "node",
"serves": [
"backed-up",
"now",
"restore"
]
}
],
"emits": [
"backed-up"
],
"tools": [
"backup_health",
"backup_delete_retired",
"backup_deleted"
],
"own-secrets": {
"repository": "${dir:state}/repository.secret"
},
"data": {
"own": [
{
"id": "repository",
"path": "${dir:repository}",
"class": "rebuildable",
"backup": "none",
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
},
{
"id": "state",
"path": "${dir:state}",
"class": "cache",
"why": "what the last nights and measurements were; the next night writes it again"
}
]
},
"config": {
"keep-daily": {
"type": "number",
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 14
},
"keep-weekly": {
"type": "number",
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 8
},
"keep-monthly": {
"type": "number",
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 6
},
"keep-hand-taken-days": {
"type": "number",
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 7
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": ".",
"unknown": "program"
},
{
"id": "repository",
"type": "directory",
"mode": "0700",
"unknown": "program"
},
{
"id": "declared",
"type": "file",
"path": "${dir:state}/backups.conf",
"mode": "0600",
"template": "files/backups.conf"
},
{
"id": "data-declared",
"type": "file",
"path": "${dir:state}/data.conf",
"mode": "0600",
"template": "files/data.conf"
},
{
"id": "retention",
"type": "file",
"path": "${dir:state}/retention.conf",
"mode": "0644",
"trusted": true,
"template": "files/retention.conf"
},
{
"id": "tool",
"type": "package",
"package": "restic"
},
{
"id": "sqlite",
"type": "package",
"package": "sqlite"
},
{
"id": "night",
"type": "process",
"name": "restic-night",
"artifact": "tools",
"run": [
"./restic-backups",
"night"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "tool",
"tool": "backup_health",
"interval": "60s",
"timeout": "20s",
"looks": 2,
"grace": "60s"
}
},
{
"id": "measure",
"type": "process",
"name": "restic-measure",
"artifact": "tools",
"run": [
"./restic-backups",
"measure"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/restic-backups",
"binary": "restic-backups",
"loads": [
"restic-backups"
],
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
+199
View File
@@ -0,0 +1,199 @@
package frombus
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
// runtime states and an HTTP fetch are compose/fromruntime's.
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The states a module names in its manifest's `reads` to compose through StatesReader.
const (
PlacementRead = delivery.PlacementRead
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
BuildsRead = delivery.BuilderModule + ".builds"
HaveRead = "node-engine.have"
ResolvedRead = "mesh-config.resolved"
)
// StatesReads are those reads, in a manifest's order.
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
type States interface {
Get(state, key string) (value []byte, found bool, err error)
Keys(state string) ([]string, error)
}
// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
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.
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
type statesReader struct {
s States
fetch Fetch
}
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
keys, err := r.s.Keys(read)
if err != nil {
return fmt.Errorf("%s: %w", read, err)
}
for _, k := range keys {
v, found, err := r.s.Get(read, k)
if err != nil {
return fmt.Errorf("%s %s: %w", read, k, err)
}
if found {
visit(k, v)
}
}
return nil
}
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
out := map[string]delivery.Placement{}
err := r.each(PlacementRead, func(k string, v []byte) {
if node, err := delivery.ParsePlacementKey(k); err == nil {
if p, err := delivery.DecodePlacement(v); err == nil && p.Node == node {
out[node] = p
}
}
})
return out, err
}
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
out := map[string]delivery.NodeRecord{}
err := r.each(HaveRead, func(k string, v []byte) {
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
out[node] = *h.Record
}
}
})
return out, err
}
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
out := map[string]delivery.Module{}
err := r.each(ModulesRead, func(k string, v []byte) {
if module, err := delivery.ParseModulesKey(k); err == nil {
if m, err := delivery.DecodeModule(v); err == nil {
out[module] = m
}
}
})
return out, err
}
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
key, err := delivery.ManifestsKey(digest)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(ManifestsRead, key)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
}
m, err := delivery.DecodeManifest(v)
if err != nil {
return nil, err
}
return []byte(m.JSON), nil
}
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
var out map[string]any
prefix := node + "." + module + "."
err := r.each(ResolvedRead, func(k string, v []byte) {
name, ok := strings.CutPrefix(k, prefix)
if !ok || strings.Contains(name, ".") {
return
}
if value, laid := catalogue.LaidValue(v); laid {
if out == nil {
out = map[string]any{}
}
out[name] = value
}
})
return out, err
}
func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
return nil, nil
}
func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
return nil, nil, nil
}
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(BuildsRead, key)
if err != nil || !found {
return nil, err
}
b, err := delivery.DecodeBuilderBuild(v)
if err != nil {
return nil, err
}
return &b, nil
}
// archiveWithin bounds one read of a templates archive.
const archiveWithin = 30 * time.Second
func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
if r.fetch == nil {
return nil, fmt.Errorf("this holder reads no archive (%s)", url)
}
fetching, cancel := context.WithTimeout(ctx, archiveWithin)
defer cancel()
body, err := r.fetch(fetching, url)
if err != nil {
return nil, err
}
sum := sha256.Sum256(body)
if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
}
return body, nil
}
func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
all, err := r.Values(ctx, node, module)
var out map[string]any
for _, k := range keys {
if v, ok := all[k]; ok {
if out == nil {
out = map[string]any{}
}
out[k] = v
}
}
return out, err
}
+49
View File
@@ -0,0 +1,49 @@
// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a
// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched
// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command.
package fromruntime
import (
"context"
"fmt"
"io"
"net/http"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
)
// States are a bundle's read states through its runtime.
type States struct{}
// Get is a key's value, false when it has none.
func (States) Get(state, key string) ([]byte, bool, error) {
e, err := stdio.State(state).Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// Keys are a state's keys.
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
// Reader is frombus's Reader over this bundle's read states, fetching archives over HTTP.
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered %s", url, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 256<<20))
}
+6
View File
@@ -210,6 +210,12 @@ type Node struct {
// (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
// never drops it.
Held map[string]bool
// RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the
// node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a
// build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no
// bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost
// the folded systemd-resolved, novox's the folded nats).
RunsTools map[string][]catalogue.Bundle
}
// Place is where a node is, as its placement says (inv.Overlay).
+16
View File
@@ -333,3 +333,19 @@ func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string
sort.Strings(out)
return out
}
// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the
// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load
// reads the list from the bus with no composed file. Unplaced names each item left out and why.
func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) {
c := newComposer(in)
resolved, err := c.planFor(node)
if err != nil {
return nil, nil, err
}
rendered, err := c.render(node, Planning)
if err != nil {
return nil, nil, err
}
return resolved.Plan.DataItems(rendered.Rendering)
}
+1 -1
View File
@@ -71,7 +71,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
return Rendered{}, err
}
r.Needed = needed
if mode == Foreseeing || len(foreseenOwn) > 0 {
if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 {
r.Foreseen = foreseenOwn
}
var names []string
+5 -2
View File
@@ -141,11 +141,14 @@ const (
RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary
RuleGrant = 6 // the module's credential for F exists (grants.<module>.<fp>)
RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6)
// RuleProvisions: every provision the module requires on that node is provided (novox/hq ADR 0570 §4): a want's
// words name the key and its state that hold it, as "the database of keycloak on novox is asked of postgres".
RuleProvisions = 8
)
func checkRule(rule int, of string) error {
if rule < RuleBuildDone || rule > RuleComposed {
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the seven (1 to 7)", of, rule)
if rule < RuleBuildDone || rule > RuleProvisions {
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the eight (1 to 8)", of, rule)
}
return nil
}
+1 -1
View File
@@ -147,7 +147,7 @@ func TestAssignerValuesRefused(t *testing.T) {
{Removed: true, FP: fp2of('a')},
{FP: fp2of('a'), Newest: fp2of('b')},
{FP: fp2of('a'), Newest: fp2of('a'), Rule: 3},
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8},
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9},
{FP: fp2of('a'), Composed: hashOf('1'), Hold: true},
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3},
{Removed: true, Put: true},
+132
View File
@@ -0,0 +1,132 @@
package delivery
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// **The control records' layouts, out of the controller** (novox/hq ADR 0565 clauses 2, 3 and 6; issue 1953 L5-S2): what
// the indexer keeps of the catalogue's predefined list and of each open pull request's config declarations, which
// mesh-config reads in place of the controller's mirrors and `pull-declares`; and each node's declared packages, which
// its engine writes in place of the controller's mirror.
// The indexer's two states (clause 2).
const (
// IndexerPredefinedState holds one key, PredefinedListKey: the catalogue's predefined.json at the newest merge taken.
IndexerPredefinedState = "predefined"
PredefinedListKey = "list"
// IndexerConfigCandidatesState holds one key per open pull request (ConfigCandidateKey).
IndexerConfigCandidatesState = "config-candidates"
)
// PredefinedKept is the indexer's key `predefined.list`: the list whole, as predefined.json holds it, with the merge it
// was read at. A merge older than the one the key names never replaces it (ADR 0348 §7).
type PredefinedKept struct {
V int `json:"v"`
Commit string `json:"commit"`
At time.Time `json:"at"`
List json.RawMessage `json:"list"`
}
// ConfigDeclaration is one config key a module's manifest declares, in the layout of the controller's declared-config
// mirror (mesh-controller internal/inventory DeclaredMirrored) unchanged, so mesh-config reads the indexer's as it read
// the mirror's.
type ConfigDeclaration struct {
Kind string `json:"kind"`
Default json.RawMessage `json:"default"`
Why string `json:"why"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum json.RawMessage `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Trusted bool `json:"trusted,omitempty"`
Predefined string `json:"predefined,omitempty"`
Required bool `json:"required,omitempty"`
Runtime bool `json:"runtime,omitempty"`
}
// ConfigCandidate is one open pull request's key of `config-candidates`: its head, and per module the config
// declarations the head's manifests add or change against the trunk's, an empty set when none. Written at each new
// head and each trunk merge that changes a module it names; deleted when the pull request merges or closes.
type ConfigCandidate struct {
V int `json:"v"`
Pull string `json:"pull"`
Head string `json:"head"`
Declares map[string]map[string]ConfigDeclaration `json:"declares"`
At time.Time `json:"at"`
}
// ConfigCandidateKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form mesh-config
// keeps a pull request's values under.
func ConfigCandidateKey(pull string) (string, error) {
owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/")
repo, number, ok2 := strings.Cut(rest, "#")
if !ok || !ok2 || owner == "" || repo == "" || number == "" || strings.ContainsAny(owner+repo+number, ".*> ") {
return "", fmt.Errorf("delivery: %q is not a pull request, owner/repository#number", pull)
}
return owner + "." + repo + "." + number, nil
}
// The node-engine's packages (clause 6): its state `packages`, one key, the node's name, written by that node's engine
// alone.
const EnginePackagesState = "packages"
// PackageDeclared is one package a node's modules declare, the controller's declared-packages mirror's entry unchanged
// (mesh-controller internal/inventory PackageDeclared): its name, the modules that declare it that way, sorted, and
// whether they declare it absent.
type PackageDeclared struct {
Package string `json:"package"`
Modules []string `json:"modules"`
Absent bool `json:"absent"`
}
// DeclaredPackages is one node's key of `packages`, the mirror's value unchanged: its packages sorted by name, absent
// after present. Fixed fields and no time, so the same declaration is the same bytes (ADR 0289 §1).
type DeclaredPackages struct {
Node string `json:"node"`
Packages []PackageDeclared `json:"packages"`
}
// mesh-config's state of the approved values held for a pull request (novox/hq ADR 0565 clause 5): read by the checker
// and the assigner's composition check, which lay a pull request's own held values over its modules' values.
const (
MeshConfigKeptState = "kept"
MeshConfigKeptRead = "mesh-config." + MeshConfigKeptState
)
// KeptValue is one approved held value, mesh-config's layout unchanged (modules/mesh-config pulls.go): kept apart from
// the values mesh-config resolves, laid only over the composition of its own pull request at its head.
type KeptValue struct {
Pull string `json:"pull"`
Head string `json:"head"`
Digest string `json:"declaration"`
Node string `json:"node"`
Module string `json:"module"`
Key string `json:"key"`
// Value is the value held, as the operator approved it.
Value json.RawMessage `json:"value"`
By string `json:"by"`
Via string `json:"via"`
Why string `json:"why"`
// Approved is who approved it, as the proposal kept it.
Approved string `json:"approved"`
Proposal string `json:"proposal"`
At time.Time `json:"at"`
}
// HeldValueKey is a held value's key in `kept`: the pull request's key (ConfigCandidateKey's form), the node, the module and
// the config key, joined by dots, as mesh-config writes it.
func HeldValueKey(pull, node, module, key string) (string, error) {
p, err := ConfigCandidateKey(pull)
if err != nil {
return "", err
}
return p + "." + node + "." + module + "." + key, nil
}
// The assigner's state of a person's upgrade policy, one key per module (clause 7; catalogue.PersonPolicy).
const AssignerPoliciesState = "policies"
+150
View File
@@ -0,0 +1,150 @@
package delivery
import (
"encoding/json"
"strings"
"testing"
"time"
)
// ADR 0565 clauses 2 and 6 (issue 1953 L5-S2): the control records' layouts read what their writers write. The
// packages value is the controller's declared-packages mirror's, as its writer's own test pins it (mesh-controller
// internal/inventory/declared_packages_test.go at 0fbb5ef3); the config declaration is its declared-config mirror's
// (DeclaredMirrored); a pull request's key is mesh-config's form.
func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
for _, c := range []struct {
name string
raw string
into any
}{
{"declared packages", `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
`{"package":"nano","modules":["web"],"absent":true}]}`, &DeclaredPackages{}},
{"a config candidate", `{"v":1,"pull":"novox/mesh-catalog#42","head":"abc","declares":{"mail":{"quota":{"kind":"config",` +
`"default":10,"why":"a quota","type":"integer","minimum":1}}},"at":"2026-10-29T12:00:00Z"}`, &ConfigCandidate{}},
{"a held value", `{"pull":"novox/mesh-catalog#42","head":"abc","declaration":"sha256:d","node":"novox","module":"mail",` +
`"key":"quota","value":20,"by":"person.jochen","via":"telegram","why":"more room","approved":"the operator",` +
`"proposal":"p-1","at":"2026-10-29T12:00:00Z"}`, &KeptValue{}},
// A node's health report as mesh-host 65fbc1e6 encodes it (its test's look, struct order kept).
{"a node's health report", `{"v":1,"node":"anchor","network":{"state":"unhealthy","since":"2026-10-29T20:00:00Z",` +
`"parts":[{"part":"resolv-conf","state":"unhealthy","writer":"NetworkManager","owner":"dnsmasq","since":"2026-10-29T20:00:00Z"}]},` +
`"peers":[{"key":"A=","addresses":["10.10.0.2/32"],"seen":false},{"key":"B=","seen":true}],` +
`"units":{"state":"degraded","failed":[{"unit":"cups.service","scope":"system","since":"2026-10-29T20:00:00Z"}]},` +
`"relogin":[{"account":"jochen","module":"brother","resource":"brother.scanner","reason":"relogin needed: the session began before scanner","since":"2026-10-29T20:00:00Z"}],` +
`"found":[{"module":"photos","resource":"photos.library","path":"/srv/photos","reason":"used as found: owned by root","since":"2026-10-29T20:00:00Z"}],` +
`"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
// The engine's verdict on an account declared never to become root (ADR 0266), and its contract.
{"a node's health report, the agent account judged", `{"v":1,"node":"anchor","root":[{"account":"agent",` +
`"module":"claude-code","resource":"claude-code.agent","state":"healthy","since":"2026-10-29T20:00:00Z"}],` +
`"contract":13,"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
// A part pointing at another machine names it (issue 774): the hub's tunnel, held at the hub's machine.
{"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{}},
{"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 {
t.Fatalf("%s: %v", c.name, err)
}
again, _ := json.Marshal(c.into)
var want, got any
_ = json.Unmarshal([]byte(c.raw), &want)
_ = json.Unmarshal(again, &got)
if string(jsonOf(want)) != string(jsonOf(got)) || ((c.name == "declared packages" || strings.HasPrefix(c.name, "a node's health report")) && string(again) != c.raw) {
t.Errorf("%s reads back as %s", c.name, again)
}
}
if a, b := (NodeHealth{Node: "anchor", At: time.Now()}), (NodeHealth{Node: "anchor"}); !SameNodeHealth(a, b) ||
SameNodeHealth(a, NodeHealth{Node: "anchor", Peers: []PeerSeen{{Key: "A="}}}) {
t.Error("two reports differing only in when they were written are not the same, or differing ones are")
}
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
t.Error("a report naming no 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"}}},
"novox": {Node: "novox", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "mesh-broker", Scope: "mesh", Module: "nats"},
{Claim: "photo-gallery", Scope: "mesh", Module: "photos"}}},
})
if o := held["photo-gallery"]; !o.Outside || len(o.Holders) != 1 || held["mesh-broker"].Outside {
t.Errorf("a claim outside the set is %+v", o)
}
keys := map[string][]byte{}
for k, v := range held {
keys[k], _ = json.Marshal(v)
}
if h, err := HoldersOf(keys, "mesh-dns-resolver", ""); err != nil || len(h) != 2 || h[0].Node != "ace" {
t.Errorf("a replicated seat's holders are %v, %v", h, err)
}
if h, _ := HoldersOf(keys, "node-files", "ace"); len(h) != 1 || h[0].Module != "mesh-files" || len(held) != 4 {
t.Errorf("a node seat's holder is %v, keys %v", h, held)
}
// ADR 0571 clauses 4, 5: the modules and the machines, from the indexer's and the placement seat's keys.
if m, err := ModuleNames(map[string][]byte{"letta": nil, "gitea": nil}); err != nil || strings.Join(m, ",") != "gitea,letta" {
t.Errorf("the modules are %v, %v", m, err)
}
if _, err := ModuleNames(map[string][]byte{"a.b": nil}); err == nil {
t.Error("a key that is no module's was read as one")
}
if n := MachineNames(map[string][]byte{"placement.novox": nil, "placement.ace": nil, "proposal.p1": nil}); strings.Join(n, ",") != "ace,novox" {
t.Errorf("the machines are %v", n)
}
if h, _ := HoldersOf(keys, "node-files", "novox"); h != nil {
t.Errorf("a node seat nobody holds there is held by %v", h)
}
// ADR 0571 clause 1: the builder's log, a start and an end, and the duration between them.
at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
start := BuildLogEntry{Event: BuildStartedEvent, Module: "letta", FP: "src2:a", Source: "abc", Repository: "novox/mesh-catalog",
Directory: "modules/letta", Commit: "abc123", Node: "ace", At: at}
end := start
end.Event, end.At, end.Outcome, end.Manifest, end.Against = BuildEndedEvent, at.Add(3*time.Minute), "done", "sha256:m", []string{"artifact-store://base/image@sha256:b"}
end.Made = []BuiltArtifact{{Name: "image", Kind: "image", Digest: "sha256:1", Reference: "artifact-store://letta/image@sha256:1"}}
for _, e := range []BuildLogEntry{start, end} {
raw, err := EncodeBuildLogEntry(e)
back, derr := DecodeBuildLogEntry(raw)
if err != nil || derr != nil || back.Event != e.Event || back.Outcome != e.Outcome || back.Directory != "modules/letta" ||
back.Repository != "novox/mesh-catalog" || back.Commit != "abc123" {
t.Errorf("%s read back as %+v, %v %v", e.Event, back, err, derr)
}
}
if d, ok := BuildDuration(start, end); !ok || d != 3*time.Minute {
t.Errorf("the build took %s, %v", d, ok)
}
older := end
older.At, older.FP = at.Add(-time.Hour), "src2:b"
other := end
other.Module = "mesh-catalog"
if n := NewestPerModule([]BuildLogEntry{older, end, other}); n["letta"].FP != "src2:a" || len(n) != 2 {
t.Errorf("each module's newest is %v", n)
}
if e := EndedSince([]BuildLogEntry{end, start, older}, at.Add(-2*time.Hour)); len(e) != 2 || e[0].FP != "src2:b" {
t.Errorf("the ended builds since are %v", e)
}
if k, err := BuildLogKey("letta"); k != "letta" || err != nil || BuildLogRead != "mesh-delivery-builder.build-log" {
t.Errorf("the key is %q, %v", k, err)
}
if _, err := BuildLogKey("a.b"); err == nil {
t.Error("a module with a dot was taken as a key")
}
for _, edit := range []func(*BuildLogEntry){func(e *BuildLogEntry) { e.Outcome = "" }, func(e *BuildLogEntry) { e.Commit = "" },
func(e *BuildLogEntry) { e.Event, e.Outcome, e.Against = BuildStartedEvent, "", nil }} {
bad := end
edit(&bad)
if _, err := EncodeBuildLogEntry(bad); err == nil {
t.Errorf("%+v was encoded", bad)
}
}
if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil {
t.Errorf("a held value's key is %q, %v", k, err)
}
for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} {
if got, err := ConfigCandidateKey(pull); got != want || (want == "") != (err != nil) {
t.Errorf("%s keyed %q, %v", pull, got, err)
}
}
}
func jsonOf(v any) []byte {
b, _ := json.Marshal(v)
return b
}
+19
View File
@@ -33,6 +33,22 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
if got.Manifests["a"] != oddManifest || !SameForge(got, withV(f)) {
t.Errorf("the message changed on the way: %q", got.Manifests["a"])
}
// ADR 0565 §2 (issue 1953 D1): a merged message carries the predefined list exact, or says the merge holds none; a
// pull message carries neither.
f.Predefined = []byte(`{"domain":{"type":"string"}}`)
if data, err := EncodeForge(f); err != nil {
t.Fatal(err)
} else if got, err := DecodeForge(data); err != nil || string(got.Predefined) != `{"domain":{"type":"string"}}` {
t.Errorf("the predefined list read back as %s, %v", got.Predefined, err)
}
f.Predefined, f.PredefinedRead = nil, true
if _, err := EncodeForge(f); err != nil {
t.Errorf("a merge holding no predefined list: %v", err)
}
pull := Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: PullOpened, PredefinedRead: true}
if _, err := EncodeForge(pull); err == nil || !strings.Contains(err.Error(), "only a merged message") {
t.Errorf("a pull message carrying the predefined list: %v", err)
}
}
func withV(f Forge) Forge { f.V = Version; return f }
@@ -50,6 +66,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
// No repository holds placement (ADR 0567 §11): a message carrying placement files is refused, on a merge or a pull.
"placement files": {func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) }, "mesh-placement"},
"placements read": {func(f *Forge) { f.RetiredRead = true }, "mesh-placement"},
// ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both.
"predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"},
"predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"},
} {
f := mergedWithInputs()
tc.mut(&f)
+277
View File
@@ -0,0 +1,277 @@
package delivery
import (
"encoding/json"
"fmt"
"sort"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **Who holds which seat, and the builder's log** (novox/hq ADR 0571 clauses 1 and 4, issue 1955).
// SeatHoldersState is the state the seat mesh-placement's holder writes of who holds which seat on which node (clause 4):
// one key per seat held once for the mesh, `<seat>`, and one per node of a seat held on every node, `<seat>.<node>`.
const (
SeatHoldersState = "seat-holders"
SeatHoldersBucket = AssignerModule + "_" + SeatHoldersState
SeatHoldersRead = AssignerModule + "." + SeatHoldersState
)
// SeatHolder is one module holding a seat on a node.
type SeatHolder struct {
Module string `json:"module"`
Node string `json:"node"`
}
// SeatHolders is one key of seat-holders: the seat, its scope, the node for a node seat's key, and its holders
// (several only for a replicated mesh seat), sorted. Outside says the claim names no seat of the set (catalogue.Seats):
// a module registered before the set closed, or built outside the indexed repositories, keeps its claim listed, as the
// controller's `seats` listed it under outside.
type SeatHolders struct {
V int `json:"v"`
Seat string `json:"seat"`
Scope string `json:"scope"`
Node string `json:"node,omitempty"`
Outside bool `json:"outside,omitempty"`
Holders []SeatHolder `json:"holders"`
}
// SeatHoldersKey is a seat's key: `<seat>` for a seat held once for the mesh (node empty), `<seat>.<node>` for a node
// seat on one node.
func SeatHoldersKey(seat, node string) (string, error) {
if err := checkName("seat", seat); err != nil {
return "", err
}
if node == "" {
return seat, nil
}
if err := checkName("node", node); err != nil {
return "", err
}
return seat + "." + node, nil
}
// SeatHoldersOf is the state composed from every node's placement (ADR 0567 §1): each holding by the seat it claims,
// keyed as SeatHoldersKey says, with the module holding it and its node. A claim naming a seat's former name is keyed by
// the seat's name (ADR 0122); one naming no seat is kept, marked Outside. Deterministic, so a writer writes only a key
// that differs.
func SeatHoldersOf(placements map[string]Placement) map[string]SeatHolders {
out := map[string]SeatHolders{}
nodes := make([]string, 0, len(placements))
for n := range placements {
nodes = append(nodes, n)
}
sort.Strings(nodes)
for _, n := range nodes {
for _, h := range placements[n].Holds {
node := ""
if h.Scope == "node" {
node = n
}
seat, known := catalogue.SeatNamed(h.Claim)
name := h.Claim
if known {
name = seat.Name
}
key, err := SeatHoldersKey(name, node)
if err != nil {
continue
}
sh := out[key]
sh.V, sh.Seat, sh.Scope, sh.Node, sh.Outside = Version, name, h.Scope, node, !known
sh.Holders = append(sh.Holders, SeatHolder{Module: h.Module, Node: n})
out[key] = sh
}
}
for k, sh := range out {
sort.Slice(sh.Holders, func(i, j int) bool {
return sh.Holders[i].Node+"/"+sh.Holders[i].Module < sh.Holders[j].Node+"/"+sh.Holders[j].Module
})
out[k] = sh
}
return out
}
// HoldersOf reads who holds seat (on node, for a node seat) from the state's keys as a reader watched them: the node's
// key first, then the seat's. None when nobody holds it.
func HoldersOf(keys map[string][]byte, seat, node string) ([]SeatHolder, error) {
for _, n := range []string{node, ""} {
key, err := SeatHoldersKey(seat, n)
if err != nil {
if n == "" {
return nil, err
}
continue
}
raw, ok := keys[key]
if !ok {
continue
}
var sh SeatHolders
if err := json.Unmarshal(raw, &sh); err != nil {
return nil, fmt.Errorf("delivery: the holders of %s are not readable: %w", key, err)
}
if sh.V != Version {
return nil, fmt.Errorf("delivery: the holders of %s are of version %d, and this reads %d", key, sh.V, Version)
}
return sh.Holders, nil
}
return nil, nil
}
// The builder's log (clause 1; the lead and the operator, 2026-10-29): the builder's own module log (ADR 0297) `build-log`,
// kept 30 days (max-age-days), one entry under the key `<module>` when a build of a fingerprint starts and one when it
// ends, so the newest entry of a key is the module's newest start or end. The builder's tools read it through the
// runtime's log verbs; another module reads it by naming BuildLogRead in its manifest's `reads`: a bundle through the
// runtime (stdio.Log(BuildLogRead)), a process on its own credential (process.Conn.ReadLog), each by newest per key,
// from a time, or following new entries. The catalogue module asks the builder's tool BuildsSinceTool instead.
const (
BuilderLog = "build-log"
BuildLogRead = BuilderModule + "." + BuilderLog
BuildStartedEvent = "build.started"
BuildEndedEvent = "build.ended"
// BuilderLogDays is how long the log keeps its entries.
BuilderLogDays = 30
// BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its
// argument `since` (RFC 3339), its answer BuildsSince.
BuildsSinceTool = "builder_builds_since"
)
// BuildLogKey is the key of a module's entries in the builder's log: the module's name, refused when a log key could not
// hold it.
func BuildLogKey(module string) (string, error) {
if err := checkName("module", module); err != nil {
return "", err
}
return module, nil
}
// BuildLogEntry is one entry of the builder's log: the module, the fingerprint, the source revision, where the module is
// built from (its repository, its directory there, the commit and the ref), the build node and the time; at the end,
// the outcome, the manifest built (its digest, read from the indexer's state `manifests` under ManifestsKey), what it was
// built against (the bases' references, as the controller's built-before said them) and each artifact made
// (BuiltArtifact, the done key's: its name, kind, digest and reference). The repository, directory and commit are what
// the indexer's PathOf, the outside-built registrations and the assigner's running commits read; the manifest, against
// and made what the catalogue module registers a build from (the lead, 2026-10-29).
type BuildLogEntry struct {
V int `json:"v"`
Event string `json:"event"`
Module string `json:"module"`
FP string `json:"fp"`
Source string `json:"source"`
Repository string `json:"repository"`
Directory string `json:"directory"`
Commit string `json:"commit"`
Ref string `json:"ref,omitempty"`
Node string `json:"node"`
At time.Time `json:"at"`
Outcome string `json:"outcome,omitempty"`
Manifest string `json:"manifest,omitempty"`
Against []string `json:"against,omitempty"`
Made []BuiltArtifact `json:"made,omitempty"`
}
// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held
// more than one answer carries (then ask again from the last one's time).
type BuildsSince struct {
Since time.Time `json:"since"`
Builds []BuildLogEntry `json:"builds"`
More bool `json:"more,omitempty"`
}
// NewestPerModule is each module's newest entry of entries (one kind, read from the last per subject or whole), by
// module: the reader's helper for the five newest and the running commits.
func NewestPerModule(entries []BuildLogEntry) map[string]BuildLogEntry {
out := map[string]BuildLogEntry{}
for _, e := range entries {
if have, ok := out[e.Module]; !ok || e.At.After(have.At) {
out[e.Module] = e
}
}
return out
}
// EndedSince is, of entries, the ended builds at or after since, oldest first: the reader's helper for history and for
// BuildsSinceTool's answer.
func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry {
var out []BuildLogEntry
for _, e := range entries {
if e.Event == BuildEndedEvent && !e.At.Before(since) {
out = append(out, e)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// EncodeBuildLogEntry is e as JSON at Version; it refuses an entry a reader could not place.
func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
e.V = Version
switch {
case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent:
return nil, fmt.Errorf("delivery: a build log entry is %s or %s, not %q", BuildStartedEvent, BuildEndedEvent, e.Event)
case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "":
return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time")
case e.Event == BuildEndedEvent && e.Outcome == "":
return nil, fmt.Errorf("delivery: a build's end names its outcome")
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0):
return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base or artifact")
}
return json.Marshal(e)
}
// DecodeBuildLogEntry reads one entry.
func DecodeBuildLogEntry(data []byte) (BuildLogEntry, error) {
var e BuildLogEntry
if err := json.Unmarshal(data, &e); err != nil {
return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry is not readable: %w", err)
}
if e.V != Version {
return BuildLogEntry{}, fmt.Errorf("delivery: a build log entry of version %d, and this reads %d", e.V, Version)
}
return e, nil
}
// BuildDuration is a build's duration, its end less its start (clause 1): false unless both are the same module's
// build of the same fingerprint, the start first.
func BuildDuration(start, end BuildLogEntry) (time.Duration, bool) {
if start.Event != BuildStartedEvent || end.Event != BuildEndedEvent || start.Module != end.Module || start.FP != end.FP ||
end.At.Before(start.At) {
return 0, false
}
return end.At.Sub(start.At), true
}
// **Which modules exist and which machines the mesh has, from the bus** (novox/hq ADR 0571 clauses 4, 5; the lead for a6,
// 2026-10-29): the tool runner reads the modules from the indexer's state `modules` and the machines from the placement
// seat's keys, and no longer asks the controller's `modules` or `nodes`.
// ModuleNames is every module the indexer holds, sorted, from its state `modules` as a reader holds its keys (key to
// value; a deleted key absent). A key that is not a module's is refused, naming it.
func ModuleNames(keys map[string][]byte) ([]string, error) {
out := make([]string, 0, len(keys))
for k := range keys {
module, err := ParseModulesKey(k)
if err != nil {
return nil, err
}
out = append(out, module)
}
sort.Strings(out)
return out, nil
}
// MachineNames is every machine the placement seat places modules on, sorted, from its state `placement` as a reader
// holds its keys: each `placement.<node>`. Keys of the state's other kinds (a proposal's) are passed over.
func MachineNames(keys map[string][]byte) []string {
out := []string{}
for k := range keys {
if node, err := ParsePlacementKey(k); err == nil {
out = append(out, node)
}
}
sort.Strings(out)
return out
}
+13 -4
View File
@@ -3,6 +3,7 @@ package delivery
import (
"bytes"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
@@ -84,9 +85,6 @@ func judged(from, to Composed) (Judgement, string) {
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
}
if from.Data != to.Data {
return JudgedMovesData, "its data declaration changes"
}
places := dataPlaces(before, after)
held := func(kind string) bool {
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
@@ -104,9 +102,20 @@ func judged(from, to Composed) (Judgement, string) {
if to.Data || from.Data {
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
}
// A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
// a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
// moving its data). It is said loudly in the words, and never hides an item that moved.
if len(moved) > 0 {
sort.Strings(moved)
return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ")
words := "it changes where its data is kept: " + strings.Join(moved, ", ")
if from.Data != to.Data {
words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
}
return JudgedMovesData, words
}
if from.Data != to.Data {
return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
"(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
}
return JudgedNeither, "it recreates no container and moves no data"
}
+5 -1
View File
@@ -48,7 +48,8 @@ func TestJudgeMove(t *testing.T) {
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
{"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false},
{"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false},
{"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
@@ -61,6 +62,9 @@ func TestJudgeMove(t *testing.T) {
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
}
if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") {
t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words)
}
}
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
t.Errorf("the node's own composition: %+v", got)
+75
View File
@@ -0,0 +1,75 @@
package delivery
import "time"
// **Whether a module is being delivered on a node, read from its want and its have** (novox/hq ADR 0564 clause 13 and
// Limits 12, ADR 0540 §7; issue 1952 S7): one derivation, used by the delivery view, which says a module `moving`,
// `settled` or `stuck` (the messenger's hold hears those), and by the condition keeper, which holds a module's own
// findings while it is delivered there and raises what still stands when the delivery ends. Before, it lived in the view
// alone (mesh-delivery-view internal/view motionOf), and a second reader would have judged a delivery its own way.
// The motion of a module on a node, as the view says it.
const (
MotionMoving = "moving"
MotionSettled = "settled"
MotionStuck = "stuck"
)
// The outcomes of a motion that settled or stuck.
const (
// OutcomeRunning is a settled module: the have reached the want, running and healthy.
OutcomeRunning = "running"
// OutcomeFailed is a have at the wanted fingerprint whose apply or health check failed.
OutcomeFailed = "failed"
// OutcomeStopped is a module held, or a want the watchman says stopped moving past its bound.
OutcomeStopped = "stopped"
)
// DefaultDeliveryBound is how long a module's delivery holds its own findings when its manifest names no bound (ADR 0564
// clause 13 (d)): past it, what is held is raised, saying the delivery did not end.
const DefaultDeliveryBound = 15 * time.Minute
// Motion is where one module's want stands on one node.
type Motion struct {
// State is MotionMoving, MotionSettled or MotionStuck; empty when there is no want to move to (none, removed, or
// naming no fingerprint).
State string
// Outcome and Words are a settled or stuck motion's: running; failed with the have's words; stopped with the have's
// words (held) or the watchman's (stopped moving).
Outcome string
Words string
// Delivering says the module is being delivered there (clause 13 (a)): its want names a fingerprint other than its
// have's, or its have's phase is applying.
Delivering bool
// Failed says the delivery ended failed (Limits 12): the node refused or failed its apply (the have's phase failed,
// at whichever fingerprint it kept), or the have at the wanted fingerprint is unhealthy. What a hold kept is raised
// at once. Apart from State, which says failed only at the wanted fingerprint, as the view always has.
Failed bool
}
// Ended says the delivery ended, failed or settled healthy at the want: what a hold kept is judged now.
func (m Motion) Ended() bool { return m.Failed || (m.State == MotionSettled && !m.Delivering) }
// MotionOf is the motion of a module on a node from its want (wanted: one was read), its have (has: one was read) and
// the watchman's words when it says the want stopped moving (stuck: it says so and the want has not moved since).
func MotionOf(w Want, wanted bool, h Have, has bool, stuckWords string, stuck bool) Motion {
if !wanted || w.Removed || w.FP == "" {
return Motion{}
}
m := Motion{Delivering: !has || h.FP != w.FP || h.Phase == PhaseApplying,
Failed: has && (h.Phase == PhaseFailed || (h.FP == w.FP && h.Health == HealthFailed))}
applied := has && h.FP == w.FP && (h.Composed == "" || h.Composed == w.Composed)
switch {
case applied && h.Phase == PhaseRunning && h.Health == HealthOK:
m.State, m.Outcome = MotionSettled, OutcomeRunning
case has && h.Phase == PhaseHeld:
m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, h.Detail
case has && h.FP == w.FP && (h.Health == HealthFailed || h.Phase == PhaseFailed):
m.State, m.Outcome, m.Words = MotionStuck, OutcomeFailed, h.Detail
case stuck:
m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, stuckWords
default:
m.State = MotionMoving
}
return m
}
+58
View File
@@ -0,0 +1,58 @@
package delivery
import "testing"
// 1952 S7 (ADR 0564 clause 13 (a), (c) and Limits 12; ADR 0540 §7): one derivation of whether a module is being
// delivered on a node, read by the view (moving, settled, stuck, as its motionOf said them) and by the condition keeper
// (delivering, failed, ended). Wants and haves as the assigner and the node-engine write them (assigner.go Want,
// delivery.go Have; agent.go writes a refusal as phase failed at the prior fingerprint).
func TestMotionOf(t *testing.T) {
want := Want{V: Version, FP: "fp-new", Composed: "sha256:new"}
at := func(fp, composed string, phase Phase, health Health, detail string) Have {
return Have{V: Version, FP: fp, Composed: composed, Phase: phase, Health: health, Detail: detail}
}
for _, c := range []struct {
name string
w Want
wanted bool
h Have
has bool
stuck string
state string
outcome string
delivering bool
failed bool
ended bool
}{
{name: "no want", h: at("fp-old", "", PhaseRunning, HealthOK, ""), has: true},
{name: "a removed want", w: Want{V: Version, Removed: true}, wanted: true},
{name: "a want no node has yet", w: want, wanted: true, state: MotionMoving, delivering: true},
{name: "the old fingerprint still runs", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true,
state: MotionMoving, delivering: true},
{name: "applying the wanted fingerprint", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseApplying, HealthUnknown, ""), has: true,
state: MotionMoving, delivering: true},
{name: "checking at the want: no longer delivering, not yet settled", w: want, wanted: true,
h: at("fp-new", "sha256:new", PhaseChecking, HealthUnknown, ""), has: true, state: MotionMoving},
{name: "running healthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthOK, ""), has: true,
state: MotionSettled, outcome: OutcomeRunning, ended: true},
{name: "running healthy at another composition", w: want, wanted: true, h: at("fp-new", "sha256:other", PhaseRunning, HealthOK, ""), has: true,
state: MotionMoving},
{name: "unhealthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthFailed, "exit 1"), has: true,
state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true},
{name: "its apply failed at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseFailed, HealthUnknown, "no image"), has: true,
state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true},
{name: "refused, the prior fingerprint kept", w: want, wanted: true,
h: at("fp-old", "sha256:old", PhaseFailed, HealthOK, "not composed: no grant"), has: true,
state: MotionMoving, delivering: true, failed: true, ended: true},
{name: "held on the node", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseHeld, HealthOK, "held by a person"), has: true,
state: MotionStuck, outcome: OutcomeStopped, delivering: true},
{name: "the watchman says it stopped moving", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true,
stuck: "ace to apply fp-new", state: MotionStuck, outcome: OutcomeStopped, delivering: true},
} {
m := MotionOf(c.w, c.wanted, c.h, c.has, c.stuck, c.stuck != "")
if m.State != c.state || m.Outcome != c.outcome || m.Delivering != c.delivering || m.Failed != c.failed || m.Ended() != c.ended {
t.Errorf("%s: %+v (ended %v), want state %q outcome %q delivering %v failed %v ended %v", c.name, m, m.Ended(),
c.state, c.outcome, c.delivering, c.failed, c.ended)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package delivery
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
)
// **A node's own health report on the bus** (issue 774, the operator's alert table of 2026-10-29): one key per node,
// `<node>`, of the node-engine's state `health` (NodeHealthBucket), written by that node's engine alone and only when
// 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). 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 (
NodeHealthState = "health"
NodeHealthBucket = "mesh-host_" + NodeHealthState
NodeHealthRead = "mesh-host." + NodeHealthState
)
// NodeHealthVersion is the version of NodeHealth's layout.
const NodeHealthVersion = 1
// NodeHealth is one node's health report, as its engine last found it.
type NodeHealth struct {
V int `json:"v"`
Node string `json:"node"`
// Network is the machine's networking, its parts without their streaks; nil where the engine does not judge it yet.
Network *NetworkHealth `json:"network,omitempty"`
// Peers are the mesh tunnel's peers on the last look that read them, sorted by key.
Peers []PeerSeen `json:"peers,omitempty"`
// Units is the machine's service managers: each failed unit no module places.
Units *UnitsHealth `json:"units,omitempty"`
// Relogin are the accounts whose running session needs a new login for a group it was given.
Relogin []NodeAccount `json:"relogin,omitempty"`
// Found are the directories used as found.
Found []NodeDirectory `json:"found,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.
Root []RootVerdict `json:"root,omitempty"`
// Contract is the engine's statement contract (mesh-host link's NodeKeyContract): a reader tells an engine older
// than a judging it reads (RootContract for Root) from one that judged nothing wrong.
Contract int `json:"contract,omitempty"`
// At is when the engine wrote this, on a change: never a reason to write.
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"`
Since time.Time `json:"since"`
Parts []NetworkPart `json:"parts"`
}
// NetworkPart is one part of a machine's networking, as its engine judged it on its second look.
type NetworkPart struct {
// Part is resolv-conf, names, tunnel, bus or route.
Part string `json:"part"`
State string `json:"state"`
// Reason is why it is not healthy, in words with no address, path or domain; Said the detail.
Reason string `json:"reason,omitempty"`
Said string `json:"said,omitempty"`
// Writer is the program that rewrote the resolver file, when it can be named; Owner the module whose file it is.
Writer string `json:"writer,omitempty"`
Owner string `json:"owner,omitempty"`
// Toward is what the failure points at: "hub", or each resolver's address that failed.
Toward []string `json:"toward,omitempty"`
// Targets are the mesh machines the failure points at, by node name, as the controller holds a part at the machine
// it points at: the hub's machine for "hub", and the machine whose tunnel address is each resolver's. Empty where the
// part is the machine's own: it points at nothing, at this machine, or at an address no other mesh machine has (the
// resolver a VPN client wrote in). Filled by the node-engine from its tunnel's peers, so the watchman needs no
// address of any machine (issue 774).
Targets []string `json:"targets,omitempty"`
Since time.Time `json:"since"`
Streak int `json:"streak,omitempty"`
}
// UnitsHealth is the machine's service managers (issue 315).
type UnitsHealth struct {
// State is running, degraded or unknown.
State string `json:"state"`
// Failed is every unit failed on two looks in a row that no module places.
Failed []FailedUnit `json:"failed"`
// Unread names a manager that could not be read, with why; Away an account's own manager not running.
Unread []string `json:"unread,omitempty"`
Away []string `json:"away,omitempty"`
}
// FailedUnit is one failed unit no module places.
type FailedUnit struct {
Unit string `json:"unit"`
// Scope is system, or user: an account's own manager.
Scope string `json:"scope"`
Load string `json:"load,omitempty"`
Result string `json:"result,omitempty"`
Resource string `json:"resource,omitempty"`
Since time.Time `json:"since"`
}
// PeerSeen is one peer of the mesh's tunnel: its public key, its allowed addresses, and whether this machine saw a
// handshake with it within five minutes.
type PeerSeen struct {
Key string `json:"key"`
Addresses []string `json:"addresses,omitempty"`
Seen bool `json:"seen"`
}
// NodeAccount is one account marked relogin needed, with the module whose resource it is and why.
type NodeAccount struct {
Account string `json:"account"`
Module string `json:"module,omitempty"`
Resource string `json:"resource,omitempty"`
Reason string `json:"reason"`
Since time.Time `json:"since"`
}
// RootContract is the statement contract from which an engine judges an account's root (mesh-host link.RootContract).
const RootContract = 3
// RootVerdict is the engine's verdict on one account declared never to become root without a person: healthy (no way
// found), unhealthy (its reason starts "can become root without a person"), or unknown while its setuid search has not
// completed, with the module and resource declaring it.
type RootVerdict struct {
Account string `json:"account"`
Module string `json:"module"`
Resource string `json:"resource"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Since time.Time `json:"since"`
}
// NodeDirectory is one directory used as found: the module declaring it, its resource and path, and why.
type NodeDirectory struct {
Module string `json:"module"`
Resource string `json:"resource"`
Path string `json:"path"`
Reason string `json:"reason"`
Since time.Time `json:"since"`
}
// EncodeNodeHealth is h as JSON at its version.
func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
h.V = NodeHealthVersion
if h.Node == "" {
return nil, errors.New("a node's health report names its node")
}
return json.Marshal(h)
}
// DecodeNodeHealth reads a report.
func DecodeNodeHealth(raw []byte) (NodeHealth, error) {
var h NodeHealth
if err := json.Unmarshal(raw, &h); err != nil {
return NodeHealth{}, fmt.Errorf("a node's health report is not readable: %w", err)
}
if h.V != NodeHealthVersion {
return NodeHealth{}, fmt.Errorf("a node's health report of version %d, and this reads %d", h.V, NodeHealthVersion)
}
return h, nil
}
// SameNodeHealth says two reports say the same, whenever each was written.
func SameNodeHealth(a, b NodeHealth) bool {
a.At, b.At = time.Time{}, time.Time{}
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
return errA == nil && errB == nil && bytes.Equal(ra, rb)
}
+18 -1
View File
@@ -364,12 +364,20 @@ func (p Placement) Held() []catalogue.Held {
return out
}
// KeptApart are the modules never placed on one node, each row naming the record that asks it (novox/hq ADR 0564 clause 6
// and Limits 5; issue 1952 S8): no manifest word says it, so the check below holds the table. mesh-watcher hears the
// self-check's `checked` from a second machine (ADR 0227 §6), which on the self-check's own node would fall silent with
// it.
var KeptApart = []struct{ A, B, Record string }{
{A: "mesh-watcher", B: "mesh-self-check", Record: "ADR 0227 §6, ADR 0564 clause 6"},
}
// CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by
// node, the modules the indexer holds and the nodes with a record. It returns every refusal, in node order, never only the first: a
// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which
// is held once per node;
// two nodes with one address on the private network; more than one hub.
// two nodes with one address on the private network; more than one hub; two modules KeptApart on one node.
// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its
// ForeseenPorts and Unbound), which the catalogue's composition check reads.
func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string {
@@ -411,6 +419,15 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
if !enrolled(node) {
refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node))
}
placedHere := map[string]bool{}
for _, m := range p.Modules {
placedHere[m.Module] = true
}
for _, apart := range KeptApart {
if placedHere[apart.A] && placedHere[apart.B] {
refusals = append(refusals, fmt.Sprintf("%s: %s and %s are never placed on one node (%s)", node, apart.A, apart.B, apart.Record))
}
}
hostPorts := map[int]string{}
for _, m := range p.Modules {
if !held(m.Module) && !ProvidedByMesh(m.Module) { // the mesh provides the private network itself
+4 -1
View File
@@ -179,6 +179,8 @@ func TestPlacement(t *testing.T) {
clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...)
clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}}
clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}}
// ADR 0564 clause 6, Limits 5 (issue 1952 S8): mesh-watcher and mesh-self-check are never placed on one node.
clash.Modules = append(clash.Modules, PlacedModule{Module: "mesh-watcher"}, PlacedModule{Module: "mesh-self-check"})
twice := novox
twice.Node = "stranger"
twice.Decided.Address = "10.77.0.2"
@@ -186,7 +188,8 @@ func TestPlacement(t *testing.T) {
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record",
"mesh-store, held once for the mesh, is held on novox and stranger",
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it",
"g14: mesh-watcher and mesh-self-check are never placed on one node (ADR 0227 §6, ADR 0564 clause 6)"} {
found := false
for _, line := range r {
found = found || strings.Contains(line, want)
+28
View File
@@ -542,6 +542,31 @@ type Forge struct {
// refused, loudly, rather than read with them dropped.
RetiredPlacements json.RawMessage `json:"placements,omitempty"`
RetiredRead bool `json:"placements-read,omitempty"`
// Predefined is the catalogue's predefined.json, exact, at the merge commit of a merged message (novox/hq ADR 0565
// §2): what the indexer keeps as its state `predefined`. PredefinedRead says the bridge read the merge and it holds
// no predefined.json. Carried on a merged message only; an older bridge sends neither, and the indexer keeps what it
// held.
Predefined json.RawMessage `json:"predefined,omitempty"`
PredefinedRead bool `json:"predefined-read,omitempty"`
}
// checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and
// a list said both present and absent.
func (f Forge) checkPredefined() error {
if len(f.Predefined) == 0 && !f.PredefinedRead {
return nil
}
if f.Kind != ForgeMerged {
return fmt.Errorf("delivery: only a merged message carries the predefined list, not a %s message", f.Kind)
}
if len(f.Predefined) > 0 && f.PredefinedRead {
return fmt.Errorf("delivery: a merged message carries the predefined list and says the merge holds none (predefined-read)")
}
var list map[string]json.RawMessage
if len(f.Predefined) > 0 && json.Unmarshal(f.Predefined, &list) != nil {
return fmt.Errorf("delivery: the predefined list a merged message carries is not one JSON object")
}
return nil
}
func (f Forge) check() error {
@@ -558,6 +583,9 @@ func (f Forge) check() error {
return fmt.Errorf("delivery: a forge message carries placement files, which no repository holds since ADR 0567: " +
"placement is the seat mesh-placement's state")
}
if err := f.checkPredefined(); err != nil {
return err
}
if err := checkRepository(f.Repository); err != nil {
return err
}
+14 -18
View File
@@ -8,8 +8,9 @@ import (
"time"
)
// 1153/AC1: the stuck key is the stopped key's bucket and key, split at the first dot, refused as a pulse key is.
// 1153/AC4: the watchman's module and its three buckets, each that module's state.
func TestWatchmanBuckets(t *testing.T) {
func TestStuckKey(t *testing.T) {
if WatchmanModule != "mesh-delivery-watchman" {
t.Fatalf("WatchmanModule = %q", WatchmanModule)
}
@@ -18,10 +19,6 @@ func TestWatchmanBuckets(t *testing.T) {
t.Errorf("the %s bucket is %q, not %s_%s", name, bucket, WatchmanModule, name)
}
}
}
// 1153/AC1: the stuck key is the stopped key's bucket and key, split at the first dot, refused as a pulse key is.
func TestStuckKey(t *testing.T) {
key, err := StuckKey(PresenceBucket, "ace")
if err != nil || key != "mesh-delivery-watchman_presence.ace" {
t.Fatalf("StuckKey = %q, %v", key, err)
@@ -47,7 +44,19 @@ func TestStuckKey(t *testing.T) {
}
// 1153/AC2: a stuck value round-trips with "v":1, and an unknown or missing version is refused loudly.
// 1153/AC3: equal stuck values are the same; one moved is not.
func TestStuckValue(t *testing.T) {
{
a := Stuck{V: Version, Bucket: "b", Key: "k", Waited: "w", Since: time.Unix(100, 0).UTC()}
b := a
if !SameStuck(a, b) {
t.Fatal("equal values are not the same")
}
b.Moved = true
if SameStuck(a, b) {
t.Fatal("a moved value is the same as a stuck one")
}
}
s := Stuck{Bucket: PresenceBucket, Key: "ace", Waited: "ace to come back online",
Since: time.Date(2026, 10, 18, 20, 0, 0, 0, time.UTC)}
raw, err := EncodeStuck(s)
@@ -73,16 +82,3 @@ func TestStuckValue(t *testing.T) {
}
}
}
// 1153/AC3: equal stuck values are the same; one moved is not.
func TestSameStuck(t *testing.T) {
a := Stuck{V: Version, Bucket: "b", Key: "k", Waited: "w", Since: time.Unix(100, 0).UTC()}
b := a
if !SameStuck(a, b) {
t.Fatal("equal values are not the same")
}
b.Moved = true
if SameStuck(a, b) {
t.Fatal("a moved value is the same as a stuck one")
}
}
+7 -4
View File
@@ -10,13 +10,16 @@ func hashOf(c byte) string { return "sha256:" + digestOf(c) }
// 1943/AC3: a want naming its fingerprint and the digest of the composition it allows round-trips, and so does one
// carrying the answer to a failed backup, and from ADR 0547's second milestone one naming the build alone or the yes
// a person gave; a digest not of the sha256
// form, a removed want naming a digest, an unknown backup answer and a rule past the seventh are refused.
// form, a removed want naming a digest, an unknown backup answer and a rule past the eighth are refused.
func TestAWantNamesItsComposition(t *testing.T) {
for _, w := range []Want{
{FP: fp2of('a'), Composed: hashOf('1')},
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupAgain},
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupSkip, Words: "the operator: go on without a backup"},
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleComposed, Words: "no composition of b for ace yet"},
// ADR 0570 §4 (issue 1954 D1): held for a provision its new build requires, its words naming the key.
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleProvisions,
Words: "the database of keycloak on novox is asked of postgres (novox.keycloak.postgres-database)"},
{Removed: true},
// ADR 0547 §2, §3 (issue 1126): from the second milestone a want names the build alone, and the yes a person gave.
{FP: fp2of('a'), Self: true},
@@ -44,7 +47,7 @@ func TestAWantNamesItsComposition(t *testing.T) {
{Want{Removed: true, Composed: hashOf('1')}, "removed want names no"},
{Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`},
{Want{Removed: true, Backup: BackupAgain}, "removed want names no"},
{Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8}, "rule 8"},
{Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 9}, "rule 9"},
}
for _, r := range refusals {
if _, err := EncodeWant(r.w); err == nil || !strings.Contains(err.Error(), r.why) {
@@ -54,8 +57,8 @@ func TestAWantNamesItsComposition(t *testing.T) {
if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + fp2of('a') + `"}`)); err == nil || !strings.Contains(err.Error(), "names no composition") {
t.Errorf("a want of release 5, naming no composition and not the node's own, was read: %v", err)
}
if RuleComposed != 7 {
t.Errorf("RuleComposed = %d, want the seventh rule", RuleComposed)
if RuleComposed != 7 || RuleProvisions != 8 {
t.Errorf("RuleComposed = %d and RuleProvisions = %d, want the seventh and the eighth", RuleComposed, RuleProvisions)
}
}
+158
View File
@@ -0,0 +1,158 @@
// Package handacts is the hand-act log's contract (novox/hq ADR 0565 clauses 10 and 11, issue 1953): the layout of an act,
// moved unchanged from the controller's (mesh-controller internal/link HandAct) so one reader reads both logs alike; the
// names of the seat mesh-hand-acts and of what its holder keeps; and the recorder every module records through, which
// asks the seat and, while no holder answers, the controller's verb until the controller hands its lease away, saying
// which log took the act.
package handacts
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// The seat, its holder and what it keeps (ADR 0565 clause 10).
const (
Seat = "mesh-hand-acts"
Module = "mesh-hand-acts"
// ActsLog is the log of every act, kept 400 days; WarrantsState one key `<asker>.<ask>` per warrant, made once.
ActsLog = "acts"
WarrantsState = "warrants"
// EventRecorded is said on the seat for every act, with the act as its body.
EventRecorded = "act-recorded"
// ControllerActs is the controller's log as a reader names it, read until its lease ends.
ControllerActs = "mesh-controller.hand-acts"
)
// KeptFor is how long an act is kept.
const KeptFor = 400 * 24 * time.Hour
// The verbs an act is recorded under, as the controller's log names them.
const (
VerbRecord = "hand-act record"
VerbDrill = "hand-act drill"
VerbWarrant = "warrant"
// CauseDrill is a drill's cause, never counted as a repair (S15); CauseOperatorAnswer an act the operator chose.
CauseDrill = "drill"
CauseOperatorAnswer = "operator-answer"
// KindRecordedBuilds is a push that only moved recorded builds: no repair (ADR 0242).
KindRecordedBuilds = "recorded-builds"
)
// Act is one entry of the log, the controller's HandAct unchanged.
type Act struct {
ID string `json:"id"`
At time.Time `json:"at"`
// By is who: the caller the runtime proved.
By string `json:"by"`
// Verb and Args are the act as given.
Verb string `json:"verb"`
Args []string `json:"arguments,omitempty"`
Why string `json:"why"`
// Cause is the condition kind, or a word the person gives; the verb's own name when neither.
Cause string `json:"cause"`
Condition string `json:"condition,omitempty"`
// Kind is what the act was read to be from what it did, never a word a person gives.
Kind string `json:"kind,omitempty"`
Carried []string `json:"carried,omitempty"`
// Via, Ask, Proofs and RequestedBy are an act the operator chose on a warrant.
Via string `json:"via,omitempty"`
Ask string `json:"ask,omitempty"`
Proofs []string `json:"proofs,omitempty"`
RequestedBy string `json:"requested-by,omitempty"`
// Outcome is what came of an act recorded after it was done.
Outcome string `json:"outcome,omitempty"`
// Log is which log holds it, set when it is read (LogSeat or LogController); never written.
Log string `json:"log,omitempty"`
}
// The logs an act may be held in.
const (
LogSeat = Seat
LogController = "mesh-controller"
)
// Repair says the act counts toward a cause repeated (S15): not a drill, not an operator's answer, not recorded builds.
func (a Act) Repair() bool {
return a.Cause != CauseDrill && a.Verb != VerbDrill && a.Cause != CauseOperatorAnswer && a.Kind != KindRecordedBuilds
}
// Request is one act a recorder records: what, why, its cause, the condition it addresses, and its verb in the log (a
// repair when empty).
type Request struct {
What string `json:"what"`
Why string `json:"why"`
Cause string `json:"cause,omitempty"`
Condition string `json:"condition,omitempty"`
Verb string `json:"verb,omitempty"`
}
// Taken is which log took an act, and what its holder answered.
type Taken struct {
Log string `json:"log"`
Answer json.RawMessage `json:"answer,omitempty"`
}
// Recorder records acts for a module (ADR 0565 clause 11): through the seat mesh-hand-acts, and while the seat has no
// holder that answers, through the controller's verb until the controller hands its lease away (the release's order
// gives no seat's holder before its callers; 00-plan.md, "Missing" 1).
type Recorder struct {
// Ask calls a seat verb by its address (`seat:<seat>.<verb>`): stdio.Ask through the runtime.
Ask func(key string, body any) (json.RawMessage, error)
// LeaseAway says the controller's lease is handed away: then nothing falls back to it. Nil is never handed away.
LeaseAway func() bool
}
// NoResponder says err is the bus finding nobody to answer: the seat has no holder now.
func NoResponder(err error) bool {
return err != nil && strings.Contains(err.Error(), "no responders")
}
// Record records one act.
func (r Recorder) Record(q Request) (Taken, error) {
if strings.TrimSpace(q.What) == "" || strings.TrimSpace(q.Why) == "" {
return Taken{}, errors.New("a hand act says what was done and why: nothing was recorded")
}
body := map[string]any{"what": q.What, "why": q.Why}
for k, v := range map[string]string{"cause": q.Cause, "condition": q.Condition, "verb": q.Verb} {
if v != "" {
body[k] = v
}
}
controller := map[string]any{"what": q.What, "why": q.Why, "cause": q.Cause}
if q.Condition != "" {
controller["condition"] = q.Condition
}
if q.Cause == "" {
controller["cause"] = q.Verb
}
return r.either("hand-act", body, "hand-act", controller)
}
// RecordWarrant records what the asking module did on the operator's warrant for its ask.
func (r Recorder) RecordWarrant(asker, ask string) (Taken, error) {
body := map[string]any{"asker": asker, "ask": ask}
return r.either("warranted", body, "warranted", body)
}
func (r Recorder) either(verb string, body any, controllerVerb string, controllerBody any) (Taken, error) {
answer, err := r.Ask("seat:"+Seat+"."+verb, body)
if err == nil {
return Taken{Log: LogSeat, Answer: answer}, nil
}
if !NoResponder(err) {
return Taken{}, fmt.Errorf("%s.%s refused: %w", Seat, verb, err)
}
if r.LeaseAway != nil && r.LeaseAway() {
return Taken{}, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing "+
"was recorded (%w)", Seat, err)
}
answer, cerr := r.Ask("seat:mesh-controller."+controllerVerb, controllerBody)
if cerr != nil {
return Taken{}, fmt.Errorf("%s has no holder that answers (%v), and the controller's %s refused too: %w", Seat, err,
controllerVerb, cerr)
}
return Taken{Log: LogController, Answer: answer}, nil
}
+74
View File
@@ -0,0 +1,74 @@
package handacts
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
)
// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4): an act reads the controller's entries unchanged, and a recorder
// records through the seat, through the controller's verb only while the seat has no holder and the lease is the
// controller's, saying which log took it, and refuses to record nowhere.
func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
// The shape of mesh-controller internal/link HandAct, as its bucket keeps an entry (handacts.go at 0fbb5ef3).
raw := `{"id":"act-1","at":"2026-10-28T09:00:00Z","by":"person.jochen","verb":"hand-act record","why":"a stuck consumer",` +
`"cause":"consumer-lost","condition":"bus.EVENTS.x.consumer-lost","via":"telegram","ask":"a-1","proofs":["P1"],` +
`"requested-by":"bus.EVENTS.x.consumer-lost","outcome":"done"}`
var a Act
if err := json.Unmarshal([]byte(raw), &a); err != nil {
t.Fatal(err)
}
again, _ := json.Marshal(a)
var want, got map[string]any
_ = json.Unmarshal([]byte(raw), &want)
_ = json.Unmarshal(again, &got)
if !reflect.DeepEqual(want, got) || !a.Repair() || (Act{Cause: CauseDrill}).Repair() || (Act{Verb: VerbDrill}).Repair() {
t.Errorf("the controller's entry reads back as %s", again)
}
noResponders := errors.New("nats: no responders available for request")
for _, c := range []struct {
name string
seat error
ctrl error
away bool
log string
refused string
asked string
warrants bool
}{
{name: "the seat answers", log: LogSeat, asked: "seat:mesh-hand-acts.hand-act"},
{name: "no holder yet", seat: noResponders, log: LogController, asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
{name: "no holder, the lease handed away", seat: noResponders, away: true, refused: "handed its lease away",
asked: "seat:mesh-hand-acts.hand-act"},
{name: "the seat refuses", seat: errors.New("why is required"), refused: "refused", asked: "seat:mesh-hand-acts.hand-act"},
{name: "neither answers", seat: noResponders, ctrl: noResponders, refused: "refused too",
asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
{name: "a warrant without a holder", seat: noResponders, warrants: true, log: LogController,
asked: "seat:mesh-hand-acts.warranted,seat:mesh-controller.warranted"},
} {
var asked []string
r := Recorder{LeaseAway: func() bool { return c.away }, Ask: func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key)
if strings.HasPrefix(key, "seat:mesh-hand-acts.") {
return json.RawMessage(`{}`), c.seat
}
return json.RawMessage(`{}`), c.ctrl
}}
var taken Taken
var err error
if c.warrants {
taken, err = r.RecordWarrant("mesh-config", "ask-1")
} else {
taken, err = r.Record(Request{What: "silenced", Why: "known", Cause: "silent", Verb: "conditions silence"})
}
if (c.refused != "") != (err != nil) || (err != nil && !strings.Contains(err.Error(), c.refused)) || taken.Log != c.log ||
strings.Join(asked, ",") != c.asked {
t.Errorf("%s: took %q, %v, asked %v", c.name, taken.Log, err, asked)
}
}
if _, err := (Recorder{}).Record(Request{What: "x"}); err == nil {
t.Error("an act with no why was recorded")
}
}
+288
View File
@@ -0,0 +1,288 @@
package health
// **A condition and its events, as the controller says them today** (novox/hq ADR 0564 clause 3, issue 1952): the body
// of `condition-raised`, `condition-changed` and `condition-cleared`, ported field for field from mesh-controller
// internal/conditions (condition.go, events.go, plain.go at 0fbb5ef3), so the condition keeper says them unchanged under
// the seat mesh-conditions and a reader (the router, the grants part, the healers) changes only the seat it hears.
//
// A condition is a durable fact about something the mesh owns that is wrong: raised when an observer's finding says so,
// kept with since-when, evidence and who can resolve it, said on the bus as it changes, and cleared when the finding
// goes, never by hand (ADR 0231 §14, ADR 0564 clause 2).
import (
"regexp"
"sort"
"strings"
"time"
)
// Severity is how soon the operator is needed: two levels, no more.
type Severity string
const (
// Urgent needs the operator now.
Urgent Severity = "urgent"
// Warning needs the operator when they can.
Warning Severity = "warning"
)
// The scopes a condition's key starts with: what kind of thing is wrong.
const (
ScopeMachine = "machine"
ScopePlan = "plan"
ScopeCall = "call"
ScopeBuild = "build"
ScopeMerge = "merge"
ScopeProvider = "provider"
ScopeSeat = "seat"
ScopeBus = "bus"
ScopeCore = "core"
ScopeProbe = "probe"
ScopeMesh = "mesh"
// ScopeDelivery is a delivery mesh-delivery owns, by its id (novox/hq ADR 0239).
ScopeDelivery = "delivery"
// ScopeModule is a module on a machine, by `<module>.<machine>` (novox/hq ADR 0240).
ScopeModule = "module"
)
// Scopes is every scope, in the order a person reads them.
var Scopes = []string{ScopeMachine, ScopePlan, ScopeCall, ScopeBuild, ScopeMerge, ScopeProvider,
ScopeSeat, ScopeBus, ScopeCore, ScopeProbe, ScopeMesh, ScopeDelivery, ScopeModule}
// Who resolves a condition.
const (
// ResolverSelf clears on observation: the signal returns, the probe passes.
ResolverSelf = "self"
// ResolverOperator needs a person: a healer's budget spent, or a repair that could only destroy.
ResolverOperator = "operator"
// ResolverAgent is work handed to an agent.
ResolverAgent = "agent"
)
// ResolverHealer is the resolver of a condition a registered healer works on.
func ResolverHealer(name string) string { return "healer:" + name }
// Subject is what the condition is about: its scope, its id within the scope, and the machine it concerns when there is
// one.
type Subject struct {
Scope string `json:"scope"`
ID string `json:"id"`
Machine string `json:"machine,omitempty"`
// Also are the other machines it concerns: a consumer's, for a provider failing it.
Also []string `json:"also,omitempty"`
}
// Evidence is one observation, as it was said.
type Evidence struct {
At time.Time `json:"at"`
Said string `json:"said"`
}
// Tried is one healer's try at a condition, as the condition carries it in `tried`: when, what it did, what came of it,
// and which healer (`healer H3`), never a person. The keeper writes it from the healers' attempts (ADR 0564 clause 7).
type Tried struct {
At time.Time `json:"at"`
What string `json:"what"`
Outcome string `json:"outcome"`
By string `json:"by"`
}
// Silence is a person saying they know: no messages until it ends. The condition stays open.
type Silence struct {
Until time.Time `json:"until"`
By string `json:"by"`
Why string `json:"why"`
Since time.Time `json:"since"`
}
// Gap is a stretch in which a fault was cleared, between its clearing and its reopening.
type Gap struct {
Cleared time.Time `json:"cleared"`
Reopened time.Time `json:"reopened"`
}
// Action is one answer the operator may give from a notification: a label, and the seat verb the operator channel calls
// with these arguments when it is chosen (novox/hq ADR 0253). Level is how much proof its answer needs (ADR 0234 §8,
// ADR 0259).
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
Machine string `json:"machine,omitempty"`
Arguments map[string]string `json:"arguments,omitempty"`
Level string `json:"level,omitempty"`
}
// The assurance levels an action's answer needs (novox/hq ADR 0234 §8).
const (
LevelAcknowledge = "acknowledge"
LevelApprove = "approve"
)
// The bounds a condition keeps, as the controller keeps them.
const (
// KeptEvidence is how many observations a condition keeps, newest first.
KeptEvidence = 10
// KeptTried is how many tries a condition keeps, newest last.
KeptTried = 10
// KeptGaps is how many gaps a condition keeps.
KeptGaps = 8
// MaxSilence is the longest a condition may be silenced at once.
MaxSilence = 7 * 24 * time.Hour
// ReopenWithin is how soon after it cleared a condition raised again is the same one again, its count increased,
// rather than news.
ReopenWithin = 10 * time.Minute
)
// Condition is one open condition, as the keeper keeps it and its events carry it: the controller's body, field for
// field (ADR 0564 clause 3). The keeper writes Epoch 0 (Limits 10).
type Condition struct {
Key string `json:"key"`
Kind string `json:"kind"`
Subject Subject `json:"subject"`
Severity Severity `json:"severity"`
Summary string `json:"summary"`
Headline string `json:"headline"`
Explanation string `json:"explanation"`
Resolved string `json:"resolved"`
Needs string `json:"needs"`
Actions []Action `json:"actions"`
Evidence []Evidence `json:"evidence"`
// Source is the observer's row, probe or event that raised it: `S1`, `D3`, `provisioner.failing`.
Source string `json:"source"`
Raised time.Time `json:"raised"`
LastObserved time.Time `json:"last-observed"`
// First and Gaps are the fault's earlier life: a reopening within ReopenWithin is the same fault.
First time.Time `json:"first,omitzero"`
Gaps []Gap `json:"gaps,omitempty"`
Observations int `json:"observations"`
Count int `json:"count"`
Tried []Tried `json:"tried,omitempty"`
Resolver string `json:"resolver"`
// Silenced is null when no silence is in force: said, not left out.
Silenced *Silence `json:"silenced"`
Epoch uint64 `json:"epoch"`
// LooksUnread says why Observations, LastObserved and Evidence are not known; the controller's alone.
LooksUnread string `json:"looks-unread,omitempty"`
}
// SilencedAt says whether a person's silence is in force at a moment.
func (c Condition) SilencedAt(now time.Time) bool {
return c.Silenced != nil && now.Before(c.Silenced.Until)
}
// Escalated says a healer tried and its budget is spent: the operator resolves it now.
func (c Condition) Escalated() bool { return c.Resolver == ResolverOperator && len(c.Tried) > 0 }
// Began is when this fault began as the mesh knows it: its first raising, a reopening within ReopenWithin being the
// same fault again.
func (c Condition) Began() time.Time {
if !c.First.IsZero() && c.First.Before(c.Raised) {
return c.First
}
return c.Raised
}
// ShowKeeper is the verb that shows more about a condition the keeper says (ADR 0564 Limits 9); a condition carried from
// the controller's bucket keeps the controller's (ShowController) until the lease write.
func ShowKeeper(key string) string { return "mesh-conditions.conditions key=" + key }
// ShowController is the controller's verb that shows more about a condition it raised.
func ShowController(key string) string { return "mesh-controller.conditions key=" + key }
// SilenceAction is the action that stops a condition's messages for a week, as the keeper offers it (ADR 0564 Limits 9:
// `mesh-conditions.silence`). An acknowledgement (ADR 0234 §8); its cause marks it as an answer.
func SilenceAction(key string) Action {
return Action{Label: "Silence for a week", Verb: "mesh-conditions.silence", Level: LevelAcknowledge,
Arguments: map[string]string{"key": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}}
}
// CauseOperatorAnswer is the cause an answer chosen on a notification is recorded with.
const CauseOperatorAnswer = "operator-answer"
// unsafeKey is anything a key may not hold: the bus takes letters, digits and `-_/=` in a key's tokens, and a `*` or `>`
// would make one a wildcard.
var unsafeKey = regexp.MustCompile(`[^A-Za-z0-9_=/-]`)
// Key composes a condition's key from its parts, `<scope>.<id>.<token>`, the controller's rule (condition.go Key): each
// token made safe for the bus, a character the bus would refuse becoming `_`, an empty token `_`. The id may hold several
// tokens joined by dots. A finding and the condition it raises have one key.
func Key(scope, id, token string) string {
var parts []string
for _, p := range append(append([]string{scope}, strings.Split(id, ".")...), token) {
p = unsafeKey.ReplaceAllString(strings.TrimSpace(p), "_")
if p == "" {
p = "_"
}
parts = append(parts, p)
}
return strings.Join(parts, ".")
}
// Order sorts conditions as `conditions` says them: urgent before warning, then oldest first, then by key.
func Order(list []Condition) {
sort.SliceStable(list, func(i, j int) bool {
if list[i].Severity != list[j].Severity {
return list[i].Severity == Urgent
}
if !list[i].Raised.Equal(list[j].Raised) {
return list[i].Raised.Before(list[j].Raised)
}
return list[i].Key < list[j].Key
})
}
// The events a condition's life emits, under the seat that keeps it (ADR 0564 clause 3: the controller's names).
const (
// EventRaised: a condition was raised, new or the same fault again within ReopenWithin (Change says which).
EventRaised = "condition-raised"
// EventChanged: its severity, its resolver, its silence or its words changed; not every observation.
EventChanged = "condition-changed"
// EventCleared: the finding went; the condition is removed and the transition kept in history.
EventCleared = "condition-cleared"
)
// Events is every event a condition's life emits.
var Events = []string{EventRaised, EventChanged, EventCleared}
// What changed, as an event's Change and a history entry say it.
const (
ChangeRaised = "raised"
ChangeReopened = "reopened"
ChangeSeverity = "severity"
ChangeResolver = "resolver"
ChangeSilenced = "silenced"
ChangeUnsilenced = "silence-ended"
ChangeCleared = "cleared"
// ChangeWords is what the operator needs, or the answers offered, said differently (novox/hq ADR 0258).
ChangeWords = "words"
)
// Event is the body of every condition event and the shape a history entry keeps: the condition itself at the top level,
// with what happened to it beside.
type Event struct {
Condition
// Event is the event's own name, so a body read without its subject still says what it is.
Event string `json:"event"`
At time.Time `json:"at"`
// Change is what happened: raised, reopened, severity, resolver, silenced, silence-ended, words, cleared.
Change string `json:"change"`
// Was is the value before, for a severity or resolver change.
Was string `json:"was,omitempty"`
// Why says why it cleared, or why it was silenced.
Why string `json:"why,omitempty"`
// Cleared is when it cleared, on a clearing.
Cleared *time.Time `json:"cleared,omitempty"`
// Show is the verb that shows more.
Show string `json:"show"`
}
// EventFor is the event a change is said under.
func EventFor(change string) string {
switch change {
case ChangeRaised, ChangeReopened:
return EventRaised
case ChangeCleared:
return EventCleared
}
return EventChanged
}
+197
View File
@@ -0,0 +1,197 @@
package health
import (
"encoding/json"
"os"
"reflect"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ADR 0564 clause 3 (issue 1952 S2): the bodies of condition-raised, condition-changed and condition-cleared read the
// events the live controller said unchanged. testdata/condition-events.json holds five transitions of the controller's
// condition history (mesh-controller_condition-history, whose entries are the events' bodies, events.go Event), captured
// on 2026-10-29 with `mesh-controller.conditions history=true days=1`: a raising, a reopening with gaps, a resolver and a
// severity change with tried and was, and a clearing with why. Never written by hand.
func TestTheControllersConditionEventsReadUnchanged(t *testing.T) {
raw, err := os.ReadFile("testdata/condition-events.json")
if err != nil {
t.Fatal(err)
}
var captured []json.RawMessage
if err := json.Unmarshal(raw, &captured); err != nil {
t.Fatal(err)
}
changes := map[string]bool{}
for _, body := range captured {
var e Event
if err := json.Unmarshal(body, &e); err != nil {
t.Fatalf("%s: %v", body, err)
}
changes[e.Change] = true
again, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
var want, got map[string]any
_ = json.Unmarshal(body, &want)
_ = json.Unmarshal(again, &got)
if !reflect.DeepEqual(want, got) {
t.Errorf("the controller's %s of %s reads back as\n%s\nnot\n%s", e.Change, e.Key, again, body)
}
if EventFor(e.Change) != e.Event || e.Key != Key(e.Subject.Scope, e.Subject.ID, e.Key[strings.LastIndex(e.Key, ".")+1:]) {
t.Errorf("%s: said as %s for a %s, or its key is not its subject's", e.Key, e.Event, e.Change)
}
}
for _, c := range []string{ChangeRaised, ChangeReopened, ChangeResolver, ChangeSeverity, ChangeCleared} {
if !changes[c] {
t.Errorf("no captured %s", c)
}
}
// The keeper's own body (Limits 9, 10): epoch 0, said and not left out, silenced null, and its show and silence
// naming the new seat.
c := Condition{Key: "machine.g14.silent", Actions: []Action{SilenceAction("machine.g14.silent")}}
b, _ := json.Marshal(Event{Condition: c, Event: EventRaised, Change: ChangeRaised, Show: ShowKeeper(c.Key)})
for _, says := range []string{`"epoch":0`, `"silenced":null`, `"show":"mesh-conditions.conditions key=machine.g14.silent"`,
`"verb":"mesh-conditions.silence"`} {
if !strings.Contains(string(b), says) {
t.Errorf("the keeper's body %s does not say %s", b, says)
}
}
}
// ADR 0564 clauses 2, 7, 8, 9 (issue 1952 S3, S4): the layouts between the observers, the healers and the keeper, and the
// take-over key the readers follow.
func TestTheHealthLayoutsHoldTheKeepersRules(t *testing.T) {
at := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
// ADR 0571 clause 3: a reading keeps its seven-day peak and is written only when it changes; a partial size is no peak.
var prev *Reading
for i, step := range []struct {
day, size int64
partial, wrote bool
peak int64
}{{20, 100, false, true, 100}, {20, 100, false, false, 100}, {21, 300, false, true, 300}, {22, 50, false, true, 300},
{23, 9000, true, true, 300}, {29, 10, false, true, 50}, {30, 10, false, true, 10}} {
now := time.Date(2026, 10, int(step.day), 1, 0, 0, 0, time.UTC)
size := step.size
m := Measured{SizeBytes: &size, MeasuredAt: &now, Precision: "exact"}
if step.partial {
m.Precision = "partial: stopped"
}
r, wrote := NextReading(prev, "ace", catalogue.PlacedDataItem{Module: "pg", ID: "data", Path: "/srv/pg"}, m, now)
raw, _ := json.Marshal(r)
back, err := DecodeReading(raw)
if wrote != step.wrote || r.Peak == nil || *r.Peak != step.peak || err != nil || back.ID != "data" {
t.Fatalf("reading %d: wrote %v, peak %v, read back %v", i, wrote, r.Peak, err)
}
prev = &r
}
// The first reading sets FirstSeen and every one after keeps it, as it keeps a retirement; an older reading reads as before.
if first := time.Date(2026, 10, 20, 1, 0, 0, 0, time.UTC); !prev.FirstSeen.Equal(first) {
t.Fatalf("first seen %s, not %s", prev.FirstSeen, first)
}
retired := *prev
retired.RetiredAt, retired.RetiredWhy = at, "its module left ace"
if r, _ := NextReading(&retired, "ace", retired.PlacedDataItem, retired.Measured, at.Add(time.Hour)); !r.RetiredAt.Equal(at) ||
r.RetiredWhy != "its module left ace" || !r.FirstSeen.Equal(prev.FirstSeen) {
t.Fatalf("a retired reading moved on as %+v", r)
}
if old, err := DecodeReading([]byte(`{"v":1,"node":"ace","module":"pg","item":"data"}`)); err != nil || !old.FirstSeen.IsZero() {
t.Fatalf("a reading of before read as %+v: %v", old, err)
}
if raw, _ := json.Marshal(Reading{V: ReadingVersion}); strings.Contains(string(raw), "first_seen") || strings.Contains(string(raw), "retired") {
t.Fatalf("zero fields were written: %s", raw)
}
if ReadingKey("ace", "pg", "data") != "ace.pg.data" || ReadingsBucket != "disk-load_readings" {
t.Fatal("the readings' key or bucket moved")
}
// And restic's last good night per item, keyed as a reading.
goodRow, _ := json.Marshal(GoodRow{V: GoodVersion, Node: "ace", Module: "pg", Item: "data", CoveredBy: "/srv/pg", LastGood: at})
if g, err := DecodeGoodRow(goodRow); err != nil || !g.LastGood.Equal(at) || GoodKey("ace", "pg", "data") != "ace.pg.data" ||
GoodBucket != "restic_good" || GoodRead != "restic.good" {
t.Fatalf("a last good night read back as %+v: %v", g, err)
}
if _, err := DecodeGoodRow([]byte(`{"v":2}`)); err == nil {
t.Fatal("a last good night of another version was read")
}
good := Finding{Key: "provider.keycloak.ace.mesh-mail.failing", Kind: "provider-failing", Token: "failing",
Subject: Subject{Scope: ScopeProvider, ID: "keycloak.ace.mesh-mail", Machine: "ace"},
Severity: Warning, Summary: "keycloak on ace fails mesh-mail", Source: "S8", Observer: WatchmanModule, At: at}
for _, c := range []struct {
name string
edit func(*Finding)
fails string
}{
{name: "a finding keyed by the controller's key rule"},
// The key rule is the controller's (condition.go Key): a character the bus refuses becomes `_`, so the finding
// and the condition it raises share one key.
{name: "an unsafe id", edit: func(f *Finding) {
f.Subject.ID, f.Key = "keycloak.ace.mesh mail*", "provider.keycloak.ace.mesh_mail_.failing"
}},
{name: "a key not its parts'", edit: func(f *Finding) { f.Key = "provider.keycloak.failing" }, fails: "not"},
{name: "an unknown scope", edit: func(f *Finding) { f.Subject.Scope = "thing" }, fails: "scope"},
{name: "no severity", edit: func(f *Finding) { f.Severity = "" }, fails: "urgent or a warning"},
{name: "no observer", edit: func(f *Finding) { f.Observer = "" }, fails: "observer"},
{name: "eleven observations", edit: func(f *Finding) { f.Evidence = make([]Evidence, KeptEvidence+1) }, fails: "at most"},
} {
f := good
if c.edit != nil {
c.edit(&f)
}
b, err := EncodeFinding(f)
if c.fails != "" {
if err == nil || !strings.Contains(err.Error(), c.fails) {
t.Errorf("%s: encoded (%v), want refused saying %q", c.name, err, c.fails)
}
continue
}
back, err := DecodeFinding(b)
if err != nil || back.Key != f.Key || back.V != FindingVersion || back.Evidence == nil {
t.Errorf("%s: read back as %+v, %v", c.name, back, err)
}
}
if FindingsBucket(SelfCheckModule) != "mesh-self-check_findings" || Bucket(HealersModule, AttemptsState) != "mesh-healers_attempts" ||
Bucket(KeeperModule, TakeoverState) != "mesh-conditions_takeover" {
t.Errorf("the buckets are named %s, %s", FindingsBucket(SelfCheckModule), Bucket(HealersModule, AttemptsState))
}
// An attempt reaches the condition's tried as the controller wrote it: by the healer, its outcome and what it said.
if tr := (Attempt{Healer: "H3", Act: "asserted the grants", Outcome: OutcomeEscalated, Said: "budget spent", Begun: at}).Tried(); tr !=
(Tried{At: at, What: "asserted the grants", Outcome: "escalated: budget spent", By: "healer H3"}) {
t.Errorf("an attempt is tried as %+v", tr)
}
// The take-over key (clause 9): absent follows the controller; the keeper's names who chose; anything else refused.
for _, c := range []struct {
raw string
found bool
seat, events string
fails bool
}{
{found: false, seat: "mesh-controller", events: "mesh-controller.doctor-heartbeat"},
{raw: `{"v":1,"follow":"controller","by":"the operator"}`, found: true, seat: "mesh-controller", events: "mesh-controller.doctor-heartbeat"},
{raw: `{"v":1,"follow":"keeper","by":"the operator","ask":"ask-1"}`, found: true, seat: "mesh-conditions", events: "mesh-self-check.checked"},
{raw: `{"v":1,"follow":"keeper"}`, found: true, fails: true},
{raw: `{"v":1,"follow":"both","by":"x"}`, found: true, fails: true},
{raw: `{"v":2,"follow":"keeper","by":"x"}`, found: true, fails: true},
} {
got, err := DecodeTakeover([]byte(c.raw), c.found)
if c.fails != (err != nil) {
t.Errorf("%q: read as %+v, %v", c.raw, got, err)
continue
}
if c.fails {
continue
}
seat, event := got.SelfCheckEvent()
if got.Seat() != c.seat || seat+"."+event != c.events {
t.Errorf("%q follows %s and %s.%s", c.raw, got.Seat(), seat, event)
}
if b, err := EncodeTakeover(got); err != nil || !strings.Contains(string(b), `"v":1`) {
t.Errorf("%q encodes as %s, %v", c.raw, b, err)
}
}
}
+341
View File
@@ -0,0 +1,341 @@
package health
// **What the observers write and the keeper reads** (novox/hq ADR 0564 clauses 2, 6, 7, 8 and 9; issue 1952): the
// layout of a finding, the self-check's `checked`, the healers' `healer-acted`, an act begun in `attempts` and one the
// healers would have taken, and the take-over key the readers follow. The keeper is the one writer of conditions; an
// observer writes findings in its own bucket and never another's (clause 2).
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// The modules holding the four health seats, and the states between them, as `<module>_<state>` buckets.
const (
// KeeperModule is the condition keeper, holder of mesh-conditions.
KeeperModule = "mesh-conditions"
// SelfCheckModule holds mesh-self-check; HealersModule mesh-healers; WatchmanModule mesh-watchdogs.
SelfCheckModule = "mesh-self-check"
HealersModule = "mesh-healers"
WatchmanModule = "mesh-delivery-watchman"
// FindingsState is every observer's own state of findings, one key per finding (clause 2).
FindingsState = "findings"
// ConditionsState is the keeper's open conditions, one key each, without their looks (clause 3).
ConditionsState = "conditions"
// TakeoverState is the keeper's take-over, one key TakeoverKey (clause 9).
TakeoverState = "takeover"
// TakeoverKey is the take-over state's one key.
TakeoverKey = "switch"
// AttemptsState is the healers' acts begun, each created before it is made (clause 7); WouldState the acts they
// would have taken while the lease names a controller (clause 8), which the keeper never reads.
AttemptsState = "attempts"
WouldState = "would"
)
// Bucket is a module's state as the bus names it, `<module>_<state>`.
func Bucket(module, state string) string { return module + "_" + state }
// FindingsBucket is an observer's bucket of findings.
func FindingsBucket(observer string) string { return Bucket(observer, FindingsState) }
// FindingVersion is the version of a finding's layout.
const FindingVersion = 1
// Finding is one thing an observer sees wrong now, kept as the key Key in its bucket `findings` until it no longer sees
// it (clause 2): written only when what it says changes (ADR 0289 §1, §4), deleted when it goes. A key written raises or
// changes the condition of the same key; a key deleted clears it. The keeper makes the condition from it: its words,
// severity, subject and source unchanged, its evidence kept newest first.
type Finding struct {
V int `json:"v"`
// Key is Key(Subject.Scope, Subject.ID, Token or Kind): the condition's key.
Key string `json:"key"`
Kind string `json:"kind"`
// Token is the key's last part when it is not Kind (`silent` for a machine).
Token string `json:"token,omitempty"`
Subject Subject `json:"subject"`
Severity Severity `json:"severity"`
// Summary is one line in the mesh's words; Headline, Explanation, Resolved and Needs the plain words of ADR 0253
// and Actions the answers a notification offers.
Summary string `json:"summary"`
Headline string `json:"headline"`
Explanation string `json:"explanation"`
Resolved string `json:"resolved"`
Needs string `json:"needs,omitempty"`
Actions []Action `json:"actions,omitempty"`
// Evidence is what the observer saw, newest first, at most KeptEvidence. Detail goes here, never in Summary.
Evidence []Evidence `json:"evidence"`
// Source is the row, probe or event that saw it: `S1`, `D6`, `apply-failing`.
Source string `json:"source"`
// Resolver is who resolves it, ResolverSelf when empty.
Resolver string `json:"resolver,omitempty"`
// Observer is the module that wrote it, and Run what of its own saw it last (a self-check run's id, empty for a
// watch): the condition's looks are its finding's and its observer's last run (clause 2).
Observer string `json:"observer"`
Run string `json:"run,omitempty"`
At time.Time `json:"at"`
}
// FindingKey is the key of a finding about scope and id of kind (the token): the condition's key rule.
func FindingKey(scope, id, token string) string { return Key(scope, id, token) }
func (f Finding) token() string {
if f.Token != "" {
return f.Token
}
return f.Kind
}
// Check refuses a finding the keeper could not make a condition of, naming why.
func (f Finding) Check() error {
known := false
for _, s := range Scopes {
known = known || s == f.Subject.Scope
}
switch {
case !known:
return fmt.Errorf("health: a finding's scope is one of %s, not %q", strings.Join(Scopes, ", "), f.Subject.Scope)
case strings.TrimSpace(f.Subject.ID) == "":
return fmt.Errorf("health: a %s finding names what it is about", f.Subject.Scope)
case strings.TrimSpace(f.Kind) == "":
return fmt.Errorf("health: the finding %s has no kind", f.Key)
case f.Key != Key(f.Subject.Scope, f.Subject.ID, f.token()):
return fmt.Errorf("health: the finding's key %q is not %q, its scope, id and kind", f.Key,
Key(f.Subject.Scope, f.Subject.ID, f.token()))
case f.Severity != Urgent && f.Severity != Warning:
return fmt.Errorf("health: the finding %s is urgent or a warning, not %q", f.Key, f.Severity)
case strings.TrimSpace(f.Summary) == "":
return fmt.Errorf("health: the finding %s says nothing", f.Key)
case strings.TrimSpace(f.Source) == "":
return fmt.Errorf("health: the finding %s does not say what saw it", f.Key)
case strings.TrimSpace(f.Observer) == "":
return fmt.Errorf("health: the finding %s does not name its observer", f.Key)
case len(f.Evidence) > KeptEvidence:
return fmt.Errorf("health: the finding %s keeps %d observations, at most %d", f.Key, len(f.Evidence), KeptEvidence)
}
return nil
}
// EncodeFinding is f as JSON at FindingVersion; it refuses what Check refuses.
func EncodeFinding(f Finding) ([]byte, error) {
f.V = FindingVersion
if f.Evidence == nil {
f.Evidence = []Evidence{}
}
if err := f.Check(); err != nil {
return nil, err
}
return json.Marshal(f)
}
// DecodeFinding reads a finding's value; it refuses another version and what Check refuses.
func DecodeFinding(data []byte) (Finding, error) {
var f Finding
if err := json.Unmarshal(data, &f); err != nil {
return Finding{}, fmt.Errorf("health: a finding is not readable: %w", err)
}
if f.V != FindingVersion {
return Finding{}, fmt.Errorf("health: a finding of version %d, and this reads %d", f.V, FindingVersion)
}
if err := f.Check(); err != nil {
return Finding{}, err
}
return f, nil
}
// EventChecked is the self-check's event, ending every run (clause 6), with the body of the controller's
// `doctor-heartbeat`, which mesh-watcher hears in its place.
const EventChecked = "checked"
// The verdicts of a probe in a run.
const (
VerdictPassed = "passed"
VerdictFailed = "failed"
VerdictFailedToRun = "failed-to-run"
VerdictDeferred = "deferred"
)
// ProbeVerdict is one probe's outcome in a run (mesh-controller doctor.go probeVerdict).
type ProbeVerdict struct {
ID string `json:"id"`
Verdict string `json:"verdict"`
Found []string `json:"found,omitempty"`
// Unconfirmed are findings one look can be wrong about, seen by this run and not the one before.
Unconfirmed []string `json:"unconfirmed,omitempty"`
Error string `json:"error,omitempty"`
Took string `json:"took,omitempty"`
// FoundCount and More are an overview's, never a checked event's.
FoundCount int `json:"found-count,omitempty"`
More string `json:"more,omitempty"`
}
// CheckedCounts are a run's verdicts, counted.
type CheckedCounts struct {
Passed int `json:"passed"`
Failed int `json:"failed"`
FailedToRun int `json:"failed-to-run"`
Deferred int `json:"deferred"`
}
// Checked is one run of the self-check and the body of `checked`, the controller's doctorRun (doctor.go:230) unchanged.
type Checked struct {
Run string `json:"run"`
At time.Time `json:"at"`
Started time.Time `json:"started"`
Took string `json:"took"`
IntervalSeconds int `json:"interval-seconds"`
Counts CheckedCounts `json:"counts"`
Probes []ProbeVerdict `json:"probes"`
// Controller is the machine that ran it (the name kept for mesh-watcher, which reads it), and Why what started it.
Controller string `json:"controller"`
Why string `json:"why"`
Unsaid int `json:"unsaid,omitempty"`
}
// EventHealerActed is the healers' event, one per act (clause 7).
const EventHealerActed = "healer-acted"
// The outcomes of an act.
const (
// OutcomeActing is an act begun and not yet finished: counted against the budget and the brake from its begin.
OutcomeActing = "acting"
// OutcomeActed is an act that did what it does; whether it repaired anything is the condition's clearing to say.
OutcomeActed = "acted"
// OutcomeFailed is an act that could not be done.
OutcomeFailed = "failed"
// OutcomeEscalated is a budget spent, said: the condition handed to the operator.
OutcomeEscalated = "escalated"
)
// HealerActed is the body of `healer-acted`, the controller's (healers.go:345) unchanged; mesh-healers leaves Epoch out
// (ADR 0564 Limits 10).
type HealerActed struct {
Event string `json:"event"`
At time.Time `json:"at"`
Healer string `json:"healer"`
By string `json:"by"`
Condition string `json:"condition"`
Kind string `json:"kind"`
Act string `json:"act"`
Outcome string `json:"outcome"`
Said string `json:"said"`
Budget string `json:"budget"`
Epoch uint64 `json:"epoch,omitempty"`
Show string `json:"show"`
}
// Attempt is one act of a healer as kept in the healers' state `attempts` (clause 7): created before it is made, so a
// holder that dies mid-act has spent it and a second instance cannot begin the same act, and finished after. The key is
// the healers' own; the keeper reads every value and writes the condition named its `tried`, resolver and escalation.
type Attempt struct {
Healer string `json:"healer"`
Condition string `json:"condition"`
Kind string `json:"kind"`
// BudgetKey is what the budget counts by: the condition's key unless the healer says another.
BudgetKey string `json:"budget-key"`
Act string `json:"act"`
Outcome string `json:"outcome"`
Said string `json:"said,omitempty"`
Begun time.Time `json:"begun"`
Finished *time.Time `json:"finished,omitempty"`
}
// Tried is the attempt as the condition carries it in `tried`.
func (a Attempt) Tried() Tried {
outcome := a.Outcome
if a.Said != "" {
outcome += ": " + a.Said
}
return Tried{At: a.Begun, What: a.Act, Outcome: outcome, By: "healer " + a.Healer}
}
// Would is an act mesh-healers would have taken while the controller's lease names a controller, kept in `would`: it
// counts for no budget, settle or brake and reaches no condition (clause 8).
type Would struct {
Healer string `json:"healer"`
Condition string `json:"condition"`
Kind string `json:"kind"`
Act string `json:"act"`
At time.Time `json:"at"`
// Why says why it was not taken: the lease names a controller, which heals.
Why string `json:"why"`
}
// Which source the readers of conditions follow (clause 9).
const (
FollowController = "controller"
FollowKeeper = "keeper"
)
// TakeoverVersion is the version of the take-over key's value.
const TakeoverVersion = 1
// Takeover is the value of the keeper's key `takeover.switch` (clause 9): which source the router, the grants part and
// mesh-watcher hear, the controller's condition events and `doctor-heartbeat`, or the keeper's events and the
// self-check's `checked`. Written by the keeper alone, on the operator's warrant for a take-over and by `hand-back`. An
// absent key is FollowController.
type Takeover struct {
V int `json:"v"`
Follow string `json:"follow"`
At time.Time `json:"at"`
// By is who chose (the operator, as the warrant proves), and Why why; Ask the approve ask it was chosen on.
By string `json:"by"`
Why string `json:"why,omitempty"`
Ask string `json:"ask,omitempty"`
}
// EncodeTakeover is t as JSON at TakeoverVersion; it refuses a source the readers could not follow.
func EncodeTakeover(t Takeover) ([]byte, error) {
t.V = TakeoverVersion
if err := t.check(); err != nil {
return nil, err
}
return json.Marshal(t)
}
// DecodeTakeover reads the key as a getter answered it: an absent key follows the controller.
func DecodeTakeover(data []byte, found bool) (Takeover, error) {
if !found || len(data) == 0 {
return Takeover{V: TakeoverVersion, Follow: FollowController}, nil
}
var t Takeover
if err := json.Unmarshal(data, &t); err != nil {
return Takeover{}, fmt.Errorf("health: the take-over key is not readable: %w", err)
}
if t.V != TakeoverVersion {
return Takeover{}, fmt.Errorf("health: the take-over key is of version %d, and this reads %d", t.V, TakeoverVersion)
}
if err := t.check(); err != nil {
return Takeover{}, err
}
return t, nil
}
func (t Takeover) check() error {
if t.Follow != FollowController && t.Follow != FollowKeeper {
return fmt.Errorf("health: the take-over follows %q, not %s or %s", t.Follow, FollowController, FollowKeeper)
}
if t.Follow == FollowKeeper && strings.TrimSpace(t.By) == "" {
return fmt.Errorf("health: a take-over by the keeper names who chose it")
}
return nil
}
// Seat is the seat a reader hears condition events on while it follows t: mesh-controller or mesh-conditions.
func (t Takeover) Seat() string {
if t.Follow == FollowKeeper {
return "mesh-conditions"
}
return "mesh-controller"
}
// SelfCheckEvent is the seat and event a reader of the self-check's run hears while it follows t: the controller's
// `doctor-heartbeat`, or mesh-self-check's `checked`.
func (t Takeover) SelfCheckEvent() (seat, event string) {
if t.Follow == FollowKeeper {
return "mesh-self-check", EventChecked
}
return "mesh-controller", "doctor-heartbeat"
}
+26 -29
View File
@@ -52,15 +52,6 @@ func TestAWaitIsReadWithWhatItNames(t *testing.T) {
}
}
func TestAnAnswerWithoutStateIsReadFromHealthy(t *testing.T) {
for raw, want := range map[string]string{`{"healthy":true,"why":"ok"}`: StateHealthy, `{"healthy":false,"why":"down"}`: StateUnhealthy} {
got, err := Read([]byte(raw))
if err != nil || got.State != want {
t.Fatalf("%s read as %+v, %v", raw, got, err)
}
}
}
func TestAWaitThatCannotBeReadIsUnhealthyAndSaysWhy(t *testing.T) {
for name, c := range map[string]struct {
w []Wait
@@ -88,13 +79,6 @@ func TestAWaitThatCannotBeReadIsUnhealthyAndSaysWhy(t *testing.T) {
}
}
func TestAnUnknownStateIsUnhealthy(t *testing.T) {
got, err := Read([]byte(`{"state":"fine","healthy":true}`))
if err != nil || got.State != StateUnhealthy || got.Healthy {
t.Fatalf("read as %+v, %v", got, err)
}
}
func TestWaitsBesideAFaultAreSaidAndExcuseNothing(t *testing.T) {
got := roundTrip(t, Unhealthy("the share is unreachable", password, Wait{Part: "p", What: "w"}))
if got.State != StateUnhealthy || len(got.Waits) != 1 {
@@ -111,12 +95,6 @@ func TestWaitingWithNothingIsUnhealthy(t *testing.T) {
}
}
func TestNotAnAnswerIsAnError(t *testing.T) {
if _, err := Read([]byte(`"yes"`)); err == nil {
t.Fatal("a string read as an answer")
}
}
func TestEveryFormOfPathOrAddressIsRefused(t *testing.T) {
for _, words := range []string{
"the source (/mnt/games)", `the source "/mnt/games"`, "the folder ~/games", "the folder ./games",
@@ -136,15 +114,34 @@ func TestEveryFormOfPathOrAddressIsRefused(t *testing.T) {
}
}
func TestHealthyStateWithHealthyFalseIsUnhealthy(t *testing.T) {
got, err := Read([]byte(`{"state":"healthy","healthy":false,"why":"x"}`))
if err != nil || got.State != StateUnhealthy || !strings.Contains(got.Why, "contradict") {
t.Fatalf("read as %+v, %v", got, err)
}
}
func TestWaitingChecksItsWaits(t *testing.T) {
if a := Waiting("it waits", Wait{Part: "p", What: "w"}); a.State != StateUnhealthy || !strings.Contains(a.Why, "neither") {
t.Fatalf("Waiting with a malformed wait is %+v", a)
}
}
// A raw answer is read by what it says: without state from healthy; an unknown state, or healthy with healthy false, as
// unhealthy; not an answer at all as an error.
func TestARawAnswerIsReadByWhatItSays(t *testing.T) {
for _, c := range []struct {
raw, state, says string
fails bool
}{
{raw: `{"healthy":true,"why":"ok"}`, state: StateHealthy},
{raw: `{"healthy":false,"why":"down"}`, state: StateUnhealthy},
{raw: `{"state":"fine","healthy":true}`, state: StateUnhealthy},
{raw: `{"state":"healthy","healthy":false,"why":"x"}`, state: StateUnhealthy, says: "contradict"},
{raw: `"yes"`, fails: true},
} {
got, err := Read([]byte(c.raw))
if c.fails {
if err == nil {
t.Errorf("%s read as an answer", c.raw)
}
continue
}
if err != nil || got.State != c.state || (c.state == StateUnhealthy && got.Healthy) || !strings.Contains(got.Why, c.says) {
t.Errorf("%s read as %+v, %v", c.raw, got, err)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
package health
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **Disk usage readings and peaks per data item** (novox/hq ADR 0571 clause 3, ADR 0233 §21, §27, §34): disk-load, on every
// node, measures each data item the modules of its node declare (catalogue.PlacedDataItem, from compose/frombus.DataItems)
// and keeps the newest reading and the item's largest size in seven days as its own state `readings`, one key
// `<node>.<module>.<item>`, written only when it changes. The node-backup holder, mounts, nfs-server and the self-check's
// probes read it there; the controller keeps none of it. Moved here from mesh-catalog modules/disk-load (b4c237d2).
const (
DiskLoadModule = "disk-load"
ReadingsState = "readings"
ReadingsBucket = DiskLoadModule + "_" + ReadingsState
ReadingsRead = DiskLoadModule + "." + ReadingsState
)
// ReadingVersion is the version of a reading's layout.
const ReadingVersion = 1
// PeakWindow is how far back the peak looks (ADR 0233 §34).
const PeakWindow = 7 * 24 * time.Hour
// Redundancy is the redundant storage an item is on, as read: zfs, md or btrfs, its pool, array or filesystem, and its
// health (nil when it could not be read).
type Redundancy struct {
Kind string `json:"kind"`
Where string `json:"where"`
Healthy *bool `json:"healthy"`
Said string `json:"said"`
}
// Measured is what one measurement found. Precision says what the size is: "exact", "dataset <name>: its whole size",
// "partial: ..." (a lower bound, never compared), or "no size: ...".
type Measured struct {
SizeBytes *int64 `json:"size_bytes,omitempty"`
LastWrite *time.Time `json:"last_write,omitempty"`
MeasuredAt *time.Time `json:"measured_at,omitempty"`
Precision string `json:"precision,omitempty"`
Error string `json:"error,omitempty"`
Redundancy *Redundancy `json:"redundancy,omitempty"`
}
// Partial says the size is only a lower bound, which a peak is never made of.
func (m Measured) Partial() bool { return strings.HasPrefix(m.Precision, "partial") }
// DaySize is the largest size measured on one UTC day.
type DaySize struct {
Day string `json:"day"`
Bytes int64 `json:"bytes"`
}
// Reading is the value of one key: the item as placed, what was measured, and the largest size in the seven days up to
// it (Peak, measured on PeakAt), read from the day maxima Days.
type Reading struct {
V int `json:"v"`
Node string `json:"node"`
catalogue.PlacedDataItem
Measured
Peak *int64 `json:"peak_bytes,omitempty"`
PeakAt string `json:"peak_day,omitempty"`
Days []DaySize `json:"days,omitempty"`
// FirstSeen is when the item was first measured on this node, kept by every reading after; RetiredAt and RetiredWhy
// say its module left the node, written by whoever retires it and carried on unchanged. Each is left out while zero,
// so a reading written before reads as it did.
FirstSeen time.Time `json:"first_seen,omitzero"`
RetiredAt time.Time `json:"retired_at,omitzero"`
RetiredWhy string `json:"retired_why,omitempty"`
}
// ReadingKey is the key of an item on a node: `<node>.<module>.<item>`.
func ReadingKey(node, module, item string) string { return node + "." + module + "." + item }
// DecodeReading reads a key's value; it refuses another version.
func DecodeReading(raw []byte) (Reading, error) {
var r Reading
if err := json.Unmarshal(raw, &r); err != nil {
return Reading{}, fmt.Errorf("health: a reading is not readable: %w", err)
}
if r.V != ReadingVersion {
return Reading{}, fmt.Errorf("health: a reading of version %d, and this reads %d", r.V, ReadingVersion)
}
return r, nil
}
// NextReading is the reading after a measurement, and whether it differs from the one before in anything but the time it
// was taken: a reading that did not change is not written. A measurement with no size (an error, a shallow item) carries
// the last size's days on, so the peak outlives one failed measurement.
func NextReading(prev *Reading, node string, it catalogue.PlacedDataItem, m Measured, now time.Time) (Reading, bool) {
r := Reading{V: ReadingVersion, Node: node, PlacedDataItem: it, Measured: m, FirstSeen: now.UTC()}
var days []DaySize
if prev != nil {
days = prev.Days
r.RetiredAt, r.RetiredWhy = prev.RetiredAt, prev.RetiredWhy
if !prev.FirstSeen.IsZero() {
r.FirstSeen = prev.FirstSeen
}
}
today := now.UTC().Format("2006-01-02")
cut := now.UTC().Add(-PeakWindow).Format("2006-01-02")
var kept []DaySize
todays := int64(-1)
for _, d := range days {
switch {
case d.Day == today:
todays = d.Bytes
case d.Day >= cut:
kept = append(kept, d)
}
}
if m.SizeBytes != nil && !m.Partial() && *m.SizeBytes > todays {
todays = *m.SizeBytes
}
if todays >= 0 {
kept = append(kept, DaySize{Day: today, Bytes: todays})
}
r.Days = kept
for _, d := range kept {
if r.Peak == nil || d.Bytes >= *r.Peak {
b := d.Bytes
r.Peak, r.PeakAt = &b, d.Day
}
}
if prev == nil {
return r, true
}
a, b := *prev, r
a.MeasuredAt, b.MeasuredAt = nil, nil
return r, !reflect.DeepEqual(a, b)
}
// **Each item's last good night** (novox/hq ADR 0571 clause 3, ADR 0564 §6; the lead, 2026-10-29): the node-backup holder
// restic keeps, from its tools bundle through the runtime on the night's file event (its night's process runs as the
// operator's account and holds no bus account, ADR 0436 §1-2), its own state `good`, one key `<node>.<module>.<item>` for every item a module
// declares that a backup path covers, written when it changes: the item, the path whose snapshot keeps it and the newest
// good night of its module (zero before the first). The self-check's probe of a stale backup reads it there. Moved here
// from mesh-catalog modules/restic (e5ef3bed).
const (
ResticModule = "restic"
GoodState = "good"
GoodBucket = ResticModule + "_" + GoodState
GoodRead = ResticModule + "." + GoodState
GoodVersion = 1
)
// GoodRow is one key of restic's `good`.
type GoodRow struct {
V int `json:"v"`
Node string `json:"node"`
Module string `json:"module"`
Item string `json:"item"`
CoveredBy string `json:"covered_by"`
LastGood time.Time `json:"last_good,omitzero"`
}
// GoodKey is a row's key: `<node>.<module>.<item>`, as a reading's.
func GoodKey(node, module, item string) string { return ReadingKey(node, module, item) }
// DecodeGoodRow reads a row; it refuses another version.
func DecodeGoodRow(raw []byte) (GoodRow, error) {
var g GoodRow
if err := json.Unmarshal(raw, &g); err != nil {
return GoodRow{}, fmt.Errorf("health: a last good night is not readable: %w", err)
}
if g.V != GoodVersion {
return GoodRow{}, fmt.Errorf("health: a last good night of version %d, and this reads %d", g.V, GoodVersion)
}
return g, nil
}
+222
View File
@@ -0,0 +1,222 @@
[
{
"actions": null,
"at": "2026-10-29T08:41:18.10610281Z",
"change": "raised",
"count": 1,
"epoch": 395230,
"event": "condition-raised",
"evidence": [
{
"at": "2026-10-29T08:41:18.10610281Z",
"said": "silent for 2m0s"
}
],
"explanation": "Nothing for you to do. The mesh has not heard from g14 for several minutes. A machine asleep or away is normal.",
"headline": "g14 is not answering",
"key": "machine.g14.silent",
"kind": "silent",
"last-observed": "2026-10-29T08:41:18.10610281Z",
"needs": "",
"observations": 1,
"raised": "2026-10-29T08:41:18.10610281Z",
"resolved": "g14 answers again",
"resolver": "self",
"severity": "warning",
"show": "mesh-controller.conditions key=machine.g14.silent",
"silenced": null,
"source": "S1",
"subject": {
"id": "g14",
"machine": "g14",
"scope": "machine"
},
"summary": "g14 has not been heard from since 2026-10-29 08:38 UTC (bound 3m0s)"
},
{
"actions": null,
"at": "2026-10-28T20:59:53.719121257Z",
"change": "reopened",
"count": 2,
"epoch": 387175,
"event": "condition-raised",
"evidence": [
{
"at": "2026-10-28T20:59:53.719121257Z",
"said": "70 pending ([70 on EVENTS]), last taken 2026-10-28T20:57:25Z"
}
],
"explanation": "Needs you: restart the controller if this stays, from the mesh MCP server; this notification cannot do it. The controller, which coordinates the mesh, has taken no messages for minutes while some wait. Changes and repairs do not happen until it recovers.",
"first": "2026-10-28T20:54:53.510073517Z",
"gaps": [
{
"cleared": "2026-10-28T20:56:52.499154879Z",
"reopened": "2026-10-28T20:59:53.719121257Z"
}
],
"headline": "The controller stopped listening",
"key": "core.controller.deaf",
"kind": "controller-deaf",
"last-observed": "2026-10-28T20:59:53.719121257Z",
"needs": "restart the controller if this stays, from the mesh MCP server; this notification cannot do it.",
"observations": 1,
"raised": "2026-10-28T20:59:53.719121257Z",
"resolved": "The controller listens again",
"resolver": "self",
"severity": "urgent",
"show": "mesh-controller.conditions key=core.controller.deaf",
"silenced": null,
"source": "S4",
"subject": {
"id": "controller",
"machine": "novox",
"scope": "core"
},
"summary": "the controller's event loop has taken nothing for 2m0s while its consumers hold 70 message(s): reports, builds and merges are not being acted on"
},
{
"actions": null,
"at": "2026-10-29T18:31:48.308721256Z",
"change": "resolver",
"count": 1,
"epoch": 401364,
"event": "condition-changed",
"evidence": [
{
"at": "2026-10-29T18:31:33.979579505Z",
"said": "no answer for node-files from g14 to the bus's discovery"
}
],
"explanation": "Nothing for you to do. Whatever serves node-files on g14 does not answer, so its tools do nothing there. A machine asleep is normal.",
"headline": "Node-files on g14 is not answering",
"key": "seat.node-files.g14.silent",
"kind": "holder-silent",
"last-observed": "2026-10-29T18:31:33.979579Z",
"needs": "",
"observations": 1,
"raised": "2026-10-29T18:31:33.979579505Z",
"resolved": "Node-files on g14 answers again",
"resolver": "healer:H3",
"severity": "warning",
"show": "mesh-controller.conditions key=seat.node-files.g14.silent",
"silenced": null,
"source": "D3",
"subject": {
"id": "node-files.g14",
"machine": "g14",
"scope": "seat"
},
"summary": "node-files's holder on g14 does not answer the bus: its verbs reach nothing there",
"tried": [
{
"at": "2026-10-29T18:31:48.308721256Z",
"by": "healer H3",
"outcome": "acted: every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there (act 1 of 1 within 1h0m0s)",
"what": "asserted the bus's streams, consumers and seat workers again"
}
],
"was": "self",
"why": "acted: every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there (act 1 of 1 within 1h0m0s)"
},
{
"actions": null,
"at": "2026-10-28T20:56:51.686413523Z",
"change": "severity",
"count": 1,
"epoch": 387175,
"event": "condition-changed",
"evidence": [
{
"at": "2026-10-28T20:55:13.087322533Z",
"said": "no answer for mesh-placement from ace to the bus's discovery"
},
{
"at": "2026-10-28T20:50:11.256396519Z",
"said": "no answer for mesh-placement from ace to the bus's discovery"
}
],
"explanation": "Needs you: the mesh tried to repair this and could not; read the details to decide what to do. Whatever serves mesh-placement on ace does not answer, so its tools do nothing there. A machine asleep is normal.",
"headline": "Mesh-placement on ace is not answering",
"key": "seat.mesh-placement.ace.silent",
"kind": "holder-silent",
"last-observed": "2026-10-28T20:55:13.087322Z",
"needs": "the mesh tried to repair this and could not; read the details to decide what to do.",
"observations": 2,
"raised": "2026-10-28T20:50:11.256396519Z",
"resolved": "Mesh-placement on ace answers again",
"resolver": "operator",
"severity": "urgent",
"show": "mesh-controller.conditions key=seat.mesh-placement.ace.silent",
"silenced": null,
"source": "D3",
"subject": {
"id": "mesh-placement.ace",
"machine": "ace",
"scope": "seat"
},
"summary": "mesh-placement's holder on ace does not answer the bus: its verbs reach nothing there",
"tried": [
{
"at": "2026-10-28T20:50:22.435049179Z",
"by": "healer H3",
"outcome": "acted: every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there (act 1 of 1 within 1h0m0s)",
"what": "asserted the bus's streams, consumers and seat workers again"
},
{
"at": "2026-10-28T20:56:51.686413523Z",
"by": "healer H3",
"outcome": "escalated: its budget of 1 act(s) within 1h0m0s is spent and seat.mesh-placement.ace.silent is still open: acted at 20:50 UTC (asserted the bus's streams, consumers and seat workers again \u2014 every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there)",
"what": "handed to the operator: nothing in the mesh will repair it now"
}
],
"was": "warning",
"why": "escalated: its budget of 1 act(s) within 1h0m0s is spent and seat.mesh-placement.ace.silent is still open: acted at 20:50 UTC (asserted the bus's streams, consumers and seat workers again \u2014 every object the mesh defines is asserted; the probe that raised it says on its next run whether it is there)"
},
{
"actions": null,
"at": "2026-10-28T21:18:48.630162912Z",
"change": "cleared",
"cleared": "2026-10-28T21:18:48.630162912Z",
"count": 2,
"epoch": 387463,
"event": "condition-cleared",
"evidence": [
{
"at": "2026-10-28T21:18:16.589232939Z",
"said": "82 pending ([82 on EVENTS]), last taken 2026-10-28T21:15:22Z"
},
{
"at": "2026-10-28T21:17:46.778840937Z",
"said": "82 pending ([82 on EVENTS]), last taken 2026-10-28T21:15:22Z"
}
],
"explanation": "Needs you: restart the controller if this stays, from the mesh MCP server; this notification cannot do it. The controller, which coordinates the mesh, has taken no messages for minutes while some wait. Changes and repairs do not happen until it recovers.",
"first": "2026-10-28T21:13:16.626696927Z",
"gaps": [
{
"cleared": "2026-10-28T21:13:47.735189608Z",
"reopened": "2026-10-28T21:17:46.778840937Z"
}
],
"headline": "The controller stopped listening",
"key": "core.controller.deaf",
"kind": "controller-deaf",
"last-observed": "2026-10-28T21:18:16.589232Z",
"needs": "restart the controller if this stays, from the mesh MCP server; this notification cannot do it.",
"observations": 2,
"raised": "2026-10-28T21:17:46.778840937Z",
"resolved": "The controller listens again",
"resolver": "self",
"severity": "urgent",
"show": "mesh-controller.conditions key=core.controller.deaf",
"silenced": null,
"source": "S4",
"subject": {
"id": "controller",
"machine": "novox",
"scope": "core"
},
"summary": "the controller's event loop has taken nothing for 3m0s while its consumers hold 82 message(s): reports, builds and merges are not being acted on",
"why": "S4 no longer observes it"
}
]
+103 -3
View File
@@ -9,8 +9,9 @@ import (
// Log — a module's record of operations on the bus, kept as long as the log is (novox/hq ADR 0297). Not the
// current value: that is state, and a log's state is its fold. Each entry is one operation on one key, its
// sequence in the log is its identity, and the order of the log is the order of the operations. A module
// declares each log it owns in its manifest (`logs`) and names it here as it named it there; a log is its
// owner's alone, and the runtime refuses, with the reason, one it did not issue.
// declares each log it owns in its manifest (`logs`) and names it here as it named it there; only its owner appends.
// Another module's log is read by naming it `<module>.<log>` in the manifest's `reads` and here (novox/hq ADR 0571
// clause 1): read, newest per key, from a time, and followed. The runtime refuses, with the reason, one it did not issue.
// LogEntry is one operation on one key, as the log holds it.
type LogEntry struct {
@@ -40,7 +41,7 @@ type LogInfo struct {
// KeptLog is one log this module owns.
type KeptLog struct{ name string }
// Log is a log this module owns, by its local name.
// Log is a log this module owns, by its local name, or reads, `<module>.<log>`.
func Log(name string) KeptLog { return KeptLog{name: name} }
const logTimeout = 30 * time.Second
@@ -121,3 +122,102 @@ func (l KeptLog) Info() (*LogInfo, error) {
var i LogInfo
return &i, json.Unmarshal(raw, &i)
}
// Newest is the newest entry of every key keys names (a key, `*` for one name, `**` for the rest; empty is every key),
// in the log's order: runtime verb `mesh/log.newest` {log, keys}, answered as a list of entries.
func (l KeptLog) Newest(keys string) ([]LogEntry, error) {
params := map[string]any{}
if keys != "" {
params["keys"] = keys
}
raw, err := l.ask("newest", params)
if err != nil {
return nil, err
}
var out []LogEntry
return out, json.Unmarshal(raw, &out)
}
// Since is one page of the entries from a time on, as Read pages them: runtime verb `mesh/log.read` with `since` (RFC
// 3339) in place of `from`.
func (l KeptLog) Since(at time.Time, keys string, limit int) (*LogPage, error) {
if limit < 0 {
return nil, fmt.Errorf("a read of %s asks for at least no entries: limit %d", l.name, limit)
}
params := map[string]any{"since": at.UTC().Format(time.RFC3339Nano)}
if keys != "" {
params["keys"] = keys
}
if limit > 0 {
params["limit"] = limit
}
raw, err := l.ask("read", params)
if err != nil {
return nil, err
}
var p LogPage
return &p, json.Unmarshal(raw, &p)
}
// LogFrom is where a log's watch starts: LogFromNewest (the newest entry of each key, then every new one), LogFromAll
// (every entry the log keeps), LogFromSince (every entry from a time) or LogFromNow (only new entries).
type LogFrom struct {
Kind string
Since time.Time
}
// The kinds of LogFrom.
const (
LogFromNewestKind = "newest"
LogFromAllKind = "all"
LogFromSinceKind = "since"
LogFromNowKind = "now"
)
var (
LogFromNewest = LogFrom{Kind: LogFromNewestKind}
LogFromAll = LogFrom{Kind: LogFromAllKind}
LogFromNow = LogFrom{Kind: LogFromNowKind}
)
// LogFromSince starts a watch at a time.
func LogFromSince(at time.Time) LogFrom { return LogFrom{Kind: LogFromSinceKind, Since: at} }
func (f LogFrom) params() (map[string]any, error) {
p := map[string]any{"from": f.Kind}
switch f.Kind {
case LogFromNewestKind, LogFromAllKind, LogFromNowKind:
case LogFromSinceKind:
p["since"] = f.Since.UTC().Format(time.RFC3339Nano)
default:
return nil, fmt.Errorf("a log's watch starts from newest, all, since or now, not %q", f.Kind)
}
return p, nil
}
// Watch hands handle each entry of keys from where from says, in the log's order, and then every new one as it is
// appended, and returns once the entries there at its start have been handled: runtime verb `mesh/log.watch` {log,
// keys, from, since}, each entry then sent as `mesh/log` {watch, entry}. Event-driven: nothing reads the log again on
// a timer. The watch lasts as long as the process.
func (l KeptLog) Watch(keys string, from LogFrom, handle func(LogEntry) error) error {
params, err := from.params()
if err != nil {
return err
}
c, err := live()
if err != nil {
return err
}
id := c.newID()
c.mu.Lock()
c.logWatches[id] = handle
c.mu.Unlock()
params["log"], params["keys"] = l.name, keys
if _, err := c.askAs(id, "mesh/log.watch", params, logTimeout); err != nil {
c.mu.Lock()
delete(c.logWatches, id)
c.mu.Unlock()
return err
}
return nil
}
+51 -5
View File
@@ -58,12 +58,13 @@ func (r *logRuntime) answer(m message) map[string]any {
From uint64 `json:"from"`
Keys string `json:"keys"`
Limit int `json:"limit"`
Since time.Time `json:"since"`
}
_ = json.Unmarshal(m.Params, &p)
r.mu.Lock()
defer r.mu.Unlock()
r.asked = append(r.asked, m)
if p.Log != "changes" {
if p.Log != "changes" && p.Log != "builder.build-log" {
return map[string]any{"error": map[string]any{"code": -32000, "message": "issues was issued no log " + p.Log}}
}
switch strings.TrimPrefix(m.Method, "mesh/log.") {
@@ -83,7 +84,7 @@ func (r *logRuntime) answer(m message) map[string]any {
page := map[string]any{"entries": []LogEntry{}, "next": 0, "last": len(r.entries)}
var got []LogEntry
for _, e := range r.entries {
if e.Sequence < p.From || !matches(p.Keys, e.Key) {
if e.Sequence < p.From || !matches(p.Keys, e.Key) || e.At.Before(p.Since) {
continue
}
if len(got) == limit {
@@ -96,6 +97,16 @@ func (r *logRuntime) answer(m message) map[string]any {
page["entries"] = got
}
return map[string]any{"result": page}
case "newest":
var out []LogEntry
for _, e := range r.entries {
if matches(p.Keys, e.Key) && r.last(e.Key) == e.Sequence {
out = append(out, e)
}
}
return map[string]any{"result": out}
case "watch":
return map[string]any{"result": map[string]any{}}
case "last":
seq := r.last(p.Key)
if seq == 0 {
@@ -148,6 +159,13 @@ func logging(t *testing.T, r *logRuntime, run func() (any, error)) map[string]an
a := r.answer(m)
a["id"] = m.ID
send(a)
// A watch's entries follow its answer, each named by the watch's id (ADR 0571 clause 1).
if m.Method == "mesh/log.watch" {
r.mu.Lock()
e := r.entries[len(r.entries)-1]
r.mu.Unlock()
send(map[string]any{"id": "entry-1", "method": "mesh/log", "params": map[string]any{"watch": m.ID, "entry": e}})
}
}(m)
continue
}
@@ -193,7 +211,9 @@ func TestABundleKeepsALogThroughTheRuntime(t *testing.T) {
pages []*LogPage
last, none *LogEntry
info *LogInfo
narrowed *LogPage
narrowed, since *LogPage
newest []LogEntry
watched *LogEntry
)
got := logging(t, r, func() (any, error) {
changes := Log("changes")
@@ -232,8 +252,27 @@ func TestABundleKeepsALogThroughTheRuntime(t *testing.T) {
if none, err = changes.Last("999"); err != nil {
return nil, err
}
info, err = changes.Info()
return "done", err
if info, err = changes.Info(); err != nil {
return nil, err
}
// ADR 0571 clause 1: another module's log, newest per key, from a time, and followed.
if newest, err = changes.Newest(""); err != nil {
return nil, err
}
if since, err = changes.Since(time.Date(2026, 10, 11, 18, 0, 5, 0, time.UTC), "", 0); err != nil {
return nil, err
}
heard := make(chan LogEntry, 1)
if err = Log("builder.build-log").Watch("letta", LogFromSince(time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC)),
func(e LogEntry) error { heard <- e; return nil }); err != nil {
return nil, err
}
select {
case e := <-heard:
watched = &e
case <-time.After(5 * time.Second):
}
return "done", nil
})
if out, isError := text(t, got); isError || out != `"done"` {
t.Fatalf("the tool answered %s", out)
@@ -296,6 +335,13 @@ func TestABundleKeepsALogThroughTheRuntime(t *testing.T) {
if none != nil {
t.Fatalf("a key with no entry has a last: %+v", none)
}
if len(newest) != 5 || newest[0].Key != "402" || newest[1].Sequence != 3 || len(since.Entries) != 2 || since.Entries[0].Sequence != 5 {
t.Fatalf("the newest per key are %+v, since a time %+v", newest, since)
}
if method, p = r.params(t, 15); method != "mesh/log.watch" || p["log"] != "builder.build-log" || p["keys"] != "letta" ||
p["from"] != "since" || p["since"] != "2026-10-11T18:00:00Z" || watched == nil || watched.Sequence != 6 {
t.Fatalf("a watch asked %s %v and heard %+v", method, p, watched)
}
if info == nil || info.Entries != 6 || info.MaxBytes != 1<<30 || info.First != 1 || info.Last != 6 || info.Bytes == 0 {
t.Fatalf("the log's info is %+v", info)
}
+8
View File
@@ -43,6 +43,14 @@ var sendGrants = []string{
"mesh.seat.operator-channel.call.tell.novox~mesh-mail~send",
"mesh.seat.operator-channel.tool.tell",
"mesh.seat.operator-channel.tool.tell.*",
// And the builder's log, as buscompose's readLogGrants grants a declared read of it (novox/hq ADR 0571 clause 1).
"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_build-log",
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log",
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.>",
"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.>",
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.*",
"$JS.API.CONSUMER.INFO.LOG_mesh-delivery-builder_build-log.*",
"$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.*",
}
// grantedSendProcess is a test bus with JetStream, with two users: novox.mesh-mail.send, permissioned to
+152
View File
@@ -0,0 +1,152 @@
package process
import (
"context"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// **Another module's log, read on this process's own credential** (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): a
// log the module's manifest names in `reads`, `<module>.<log>`, read through an ordered consumer this connection creates
// and lets go of, never a durable one. Newest is the newest entry of each key, Since every entry from a time, and Watch
// those or every entry, then each new one as it is appended: event-driven, nothing reads the log again on a timer. The
// grants are buscompose's readLogGrants, given for a declared read of a log alone.
// LogReader is one module's log this connection reads.
type LogReader struct {
Module, Name string
js func() (jetstream.JetStream, error)
closed func() bool
}
// ReadLog is module's log name, read on this connection.
func (c *Conn) ReadLog(module, name string) LogReader {
return LogReader{Module: module, Name: name, closed: func() bool { return c.nc.IsClosed() },
js: func() (jetstream.JetStream, error) { return jetstream.New(c.nc) }}
}
func (l LogReader) stream() string { return "LOG_" + l.Module + "_" + l.Name }
func (l LogReader) subject() string { return "mesh.log." + l.Module + "." + l.Name }
// filter is keys as a subject filter: a key, `*` for one name, `**` for the rest; empty is every key.
func (l LogReader) filter(keys string) string {
if keys == "" {
return l.subject() + ".>"
}
return l.subject() + "." + watchPattern(keys)
}
func (l LogReader) config(keys string, from stdio.LogFrom) (jetstream.OrderedConsumerConfig, error) {
cfg := jetstream.OrderedConsumerConfig{FilterSubjects: []string{l.filter(keys)}}
switch from.Kind {
case stdio.LogFromNewestKind:
cfg.DeliverPolicy = jetstream.DeliverLastPerSubjectPolicy
case stdio.LogFromAllKind:
cfg.DeliverPolicy = jetstream.DeliverAllPolicy
case stdio.LogFromSinceKind:
at := from.Since
cfg.DeliverPolicy, cfg.OptStartTime = jetstream.DeliverByStartTimePolicy, &at
case stdio.LogFromNowKind:
cfg.DeliverPolicy = jetstream.DeliverNewPolicy
default:
return cfg, fmt.Errorf("%s's log %s: a read starts from newest, all, since or now, not %q", l.Module, l.Name, from.Kind)
}
return cfg, nil
}
func (l LogReader) entry(m jetstream.Msg) (stdio.LogEntry, error) {
meta, err := m.Metadata()
if err != nil {
return stdio.LogEntry{}, err
}
return stdio.LogEntry{Sequence: meta.Sequence.Stream, Key: strings.TrimPrefix(m.Subject(), l.subject()+"."),
Value: append([]byte(nil), m.Data()...), At: meta.Timestamp}, nil
}
// Newest is the newest entry of each key keys names, in the log's order.
func (l LogReader) Newest(keys string) ([]stdio.LogEntry, error) {
return l.collect(keys, stdio.LogFromNewest)
}
// Since is every entry of keys from at on, in the log's order.
func (l LogReader) Since(at time.Time, keys string) ([]stdio.LogEntry, error) {
return l.collect(keys, stdio.LogFromSince(at))
}
func (l LogReader) collect(keys string, from stdio.LogFrom) ([]stdio.LogEntry, error) {
var out []stdio.LogEntry
stop, err := l.watch(keys, from, func(e stdio.LogEntry) error { out = append(out, e); return nil }, false)
if stop != nil {
stop()
}
return out, err
}
// Watch hands handle each entry from where from says and then every new one, in the log's order, and returns once the
// entries there at its start have been handled. The watch lasts as long as the connection; an error from handle ends
// it, as the connection closing does. Before the entries at its start are handled, either is returned as the error.
func (l LogReader) Watch(keys string, from stdio.LogFrom, handle func(stdio.LogEntry) error) error {
_, err := l.watch(keys, from, handle, true)
return err
}
func (l LogReader) watch(keys string, from stdio.LogFrom, handle func(stdio.LogEntry) error, follow bool) (func(), error) {
cfg, err := l.config(keys, from)
if err != nil {
return nil, err
}
js, err := l.js()
if err != nil {
return nil, err
}
bind, stopBind := context.WithTimeout(context.Background(), stateTimeout)
defer stopBind()
cons, err := js.OrderedConsumer(bind, l.stream(), cfg)
if err != nil {
return nil, fmt.Errorf("%s's log %s (stream %s): %w", l.Module, l.Name, l.stream(), err)
}
info, err := cons.Info(bind)
if err != nil {
return nil, fmt.Errorf("%s's log %s (stream %s): %w", l.Module, l.Name, l.stream(), err)
}
pending := info.NumPending
it, err := cons.Messages()
if err != nil {
return nil, fmt.Errorf("%s's log %s (stream %s): %w", l.Module, l.Name, l.stream(), err)
}
for ; pending > 0; pending-- {
m, err := it.Next()
if err == nil {
var e stdio.LogEntry
if e, err = l.entry(m); err == nil {
err = handle(e)
}
}
if err != nil {
it.Stop()
return nil, fmt.Errorf("%s's log %s: %w", l.Module, l.Name, err)
}
}
if !follow {
return it.Stop, nil
}
go func() {
defer it.Stop()
for {
m, err := it.Next()
if err != nil || (l.closed != nil && l.closed()) {
return
}
e, err := l.entry(m)
if err != nil || handle(e) != nil {
return
}
}
}()
return it.Stop, nil
}
+45
View File
@@ -113,6 +113,51 @@ func (s State) Create(key string, value any) (uint64, error) {
return rev, nil
}
// MinTTL is the shortest time to live a key may be given: the bus counts a key's age in whole seconds.
const MinTTL = time.Second
// CreateTTL writes a key only when it has none, with its own time to live (novox/hq ADR 0564 clauses 4 and 7, issue
// 1952 S6): the bus removes it when ttl passes and its watchers hear the removal, so an expiry is an event and no timer
// runs. A key that has a value is refused as stdio.ErrChanged: an act begun once cannot be begun again. The bucket must
// take keys with their own time to live (a state declared `key-ttl`).
func (s State) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
if ttl < MinTTL {
return 0, fmt.Errorf("%s's state %s, key %s: a time to live of %s is shorter than the bus keeps (%s)", s.Module, s.Name, key, ttl, MinTTL)
}
ctx, cancel := context.WithTimeout(context.Background(), stateTimeout)
defer cancel()
kv, err := s.store(ctx)
if err != nil {
return 0, err
}
data, err := json.Marshal(value)
if err != nil {
return 0, err
}
rev, err := kv.Create(ctx, key, data, jetstream.KeyTTL(ttl))
if errors.Is(err, jetstream.ErrKeyExists) {
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
}
if err != nil {
return 0, fmt.Errorf("%s's state %s, key %s: %w", s.Module, s.Name, key, err)
}
return rev, nil
}
// RenewTTL writes a key whole with a fresh time to live, whatever it held: a pulse its signal renews (ADR 0564 clause
// 4). A time to live is set only when a key is created, so a live key is deleted first; its watchers hear the delete and
// then the new value.
func (s State) RenewTTL(key string, value any, ttl time.Duration) (uint64, error) {
rev, err := s.CreateTTL(key, value, ttl)
if !errors.Is(err, stdio.ErrChanged) {
return rev, err
}
if err := s.Delete(key); err != nil {
return 0, err
}
return s.CreateTTL(key, value, ttl)
}
// Update writes a key only when it is still at revision, and returns the new one; a key changed meanwhile
// is refused as stdio.ErrChanged.
func (s State) Update(key string, value any, revision uint64) (uint64, error) {
+55 -1
View File
@@ -44,7 +44,7 @@ func TestStateCreateGetUpdateOnAGrantedBucket(t *testing.T) {
if _, err := awaited.Put(ctxFor(t), "hearing", []byte(`{"heard":true}`)); !errors.Is(err, ErrNotYet) {
t.Fatalf("a second write before the bucket exists: %v, want ErrNotYet", err)
}
if _, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_sends"}); err != nil {
if _, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_sends", LimitMarkerTTL: time.Second}); err != nil {
t.Fatalf("the admin could not make the bucket: %v", err)
}
select {
@@ -94,6 +94,22 @@ func TestStateCreateGetUpdateOnAGrantedBucket(t *testing.T) {
if _, err := s.Create("key", map[string]any{"v": 4}); !errors.Is(err, stdio.ErrChanged) {
t.Fatalf("Create of an existing key: %v, want stdio.ErrChanged", err)
}
// 1952 S6 (ADR 0564 clause 4): a key created with its own time to live is gone once it passes, on the bus itself.
if _, err := s.CreateTTL("pulse", map[string]any{"v": 5}, time.Second); err != nil {
t.Fatalf("CreateTTL on a bucket that takes keys with a time to live: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for {
e, err := s.Get("pulse")
if err == nil && e == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf("the key created with a second to live still reads %+v, %v after 5s", e, err)
}
time.Sleep(100 * time.Millisecond)
}
}
// 1188/AC4: given an account not granted a bucket, when it writes there, then the bus's refusal comes
@@ -258,6 +274,44 @@ func TestStateWatchOnAGrantedBucket(t *testing.T) {
t.Fatalf("narrowed watch got %+v for a key it did not name", c)
case <-time.After(500 * time.Millisecond):
}
// ADR 0571 clause 1: another module's log, read on the process's own credential with exactly the read grants: the
// newest per key, every entry from a time, and a watch from the newest that then hears an appended entry.
if _, err := adminJetStream.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_mesh-delivery-builder_build-log",
Subjects: []string{"mesh.log.mesh-delivery-builder.build-log.>"}, AllowDirect: true}); err != nil {
t.Fatal(err)
}
for _, k := range []string{"letta", "gitea", "letta"} {
if _, err := adminJetStream.Publish(ctx, "mesh.log.mesh-delivery-builder.build-log."+k, []byte(`{"module":"`+k+`"}`)); err != nil {
t.Fatal(err)
}
time.Sleep(5 * time.Millisecond)
}
builds := conn.ReadLog("mesh-delivery-builder", "build-log")
newest, err := builds.Newest("")
if err != nil || len(newest) != 2 || newest[0].Key != "gitea" || newest[1].Sequence != 3 {
t.Fatalf("the newest per key are %+v: %v", newest, err)
}
since, err := builds.Since(newest[0].At, "letta")
if err != nil || len(since) != 1 || since[0].Sequence != 3 {
t.Fatalf("letta since gitea's build is %+v: %v", since, err)
}
heard := make(chan stdio.LogEntry, 4)
if err := builds.Watch("letta", stdio.LogFromNewest, func(e stdio.LogEntry) error { heard <- e; return nil }); err != nil || len(heard) != 1 {
t.Fatalf("a watch from the newest handed %d at its start: %v", len(heard), err)
}
<-heard
if _, err := adminJetStream.Publish(ctx, "mesh.log.mesh-delivery-builder.build-log.letta", []byte(`{}`)); err != nil {
t.Fatal(err)
}
select {
case e := <-heard:
if e.Sequence != 4 || e.Key != "letta" {
t.Fatalf("the watch heard %+v", e)
}
case <-time.After(10 * time.Second):
t.Fatal("the watch did not hear the appended entry")
}
}
// 1840/AC8: given an account not granted a bucket, when it watches it, then the refusal names the bucket.
+29 -1
View File
@@ -38,6 +38,8 @@ type fakeKV struct {
// initial is what a Watch hands first, before its nil marker; watcher is the last one made.
initial []jetstream.KeyValueEntry
watcher *fakeWatcher
// withTTL says the last create of a key carried an option: its time to live.
withTTL map[string]bool
}
func newFakeKV() *fakeKV { return &fakeKV{values: map[string][]byte{}, revs: map[string]uint64{}} }
@@ -52,9 +54,13 @@ func (f *fakeKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry, er
return fakeEntry{key: key, value: v, rev: f.revs[key]}, nil
}
func (f *fakeKV) Create(_ context.Context, key string, value []byte, _ ...jetstream.KVCreateOpt) (uint64, error) {
func (f *fakeKV) Create(_ context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.withTTL == nil {
f.withTTL = map[string]bool{}
}
f.withTTL[key] = len(opts) > 0
if _, ok := f.values[key]; ok {
return 0, jetstream.ErrKeyExists
}
@@ -160,6 +166,28 @@ func TestStateSatisfiesAskerStore(t *testing.T) {
if rev2 == rev {
t.Fatal("Update did not advance the revision")
}
// 1952 S6 (ADR 0564 clauses 4, 7): a key created with its own time to live is created once, a second create
// refused as an act begun already; a renewal writes it whole with a fresh time to live; a time to live the bus
// cannot keep is refused.
kv := newFakeKV()
s = stateOn(kv)
if _, err := s.CreateTTL("settle.H3", map[string]any{"a": 1}, time.Minute); err != nil || !kv.withTTL["settle.H3"] {
t.Fatalf("CreateTTL: %v, with a time to live %v", err, kv.withTTL["settle.H3"])
}
if _, err := s.CreateTTL("settle.H3", map[string]any{"a": 2}, time.Minute); !errors.Is(err, stdio.ErrChanged) {
t.Fatalf("CreateTTL of a live key: %v, want stdio.ErrChanged", err)
}
kv.withTTL["settle.H3"] = false
if _, err := s.RenewTTL("settle.H3", map[string]any{"a": 3}, time.Minute); err != nil || !kv.withTTL["settle.H3"] {
t.Fatalf("RenewTTL: %v, with a time to live %v", err, kv.withTTL["settle.H3"])
}
if e, _ := s.Get("settle.H3"); e == nil || string(e.Value) != `{"a":3}` {
t.Fatalf("after RenewTTL the key reads %+v", e)
}
if _, err := s.CreateTTL("pulse.S1", 1, 10*time.Millisecond); err == nil || !strings.Contains(err.Error(), "shorter") {
t.Fatalf("a time to live under a second: %v", err)
}
}
// 1840/S1: given a state, when a key is put twice, then each Put returns a new revision and the key reads
+1 -2
View File
@@ -39,8 +39,7 @@ func TestTheConsumersFileAnswersEachConsumersCredential(t *testing.T) {
// Wired as the helper's Vault, the file is what makes each consumer's login.
bus := newStandIn()
bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte(`{"node":"ace","module":"letta",
"provisioning":{"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"}]}}`))
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
var made []string
p := New(Options{Node: "novox", Module: "postgres", Bus: bus, Vault: v,
Offers: map[string]Offer{"postgres-database": {Serves: map[string]any{"port": 5432}}},
-42
View File
@@ -1,51 +1,9 @@
package provider
import (
"context"
"encoding/json"
"slices"
"testing"
)
// Issue 1098: an assignment with `ended` set, or with a `state`, is not a standing assignment: the helper reads it
// as a removed key, so the entry of its consumer is removed and the adapter hears it is gone. The fields the
// controller adds change nothing.
func TestAnEndedAssignmentIsReadAsARemovedKey(t *testing.T) {
bus, m := newStandIn(), &maker{}
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}}
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
p := postgres(bus, v, m)
if _, err := p.Pass(context.Background()); err != nil {
t.Fatal(err)
}
if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database"}) {
t.Fatalf("entries %v", got)
}
for _, mark := range []map[string]any{{"ended": "2026-10-18T01:00:00Z"}, {"state": "pending"}} {
raw := bus.get(AssignmentsBucket, "ace.letta")
var a map[string]any
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatal(err)
}
for k, v := range mark {
a[k] = v
}
raw, _ = json.Marshal(a)
_ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", raw)
out, err := p.Pass(context.Background())
if err != nil {
t.Fatal(err)
}
if got := bus.keys(BucketOf("postgres")); len(got) != 0 || len(out) != 1 || out[0].Status != Removed {
t.Fatalf("%v: entries %v, outcomes %+v", mark, got, out)
}
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
if _, err := p.Pass(context.Background()); err != nil {
t.Fatal(err)
}
}
}
func TestStandingIsFalseOnlyForAnEndedOrPendingValue(t *testing.T) {
for raw, want := range map[string]bool{
`{"node":"a","module":"b"}`: true,
+4 -3
View File
@@ -105,7 +105,8 @@ func TestAnUnreadableGrantFileRetiresNothing(t *testing.T) {
}
}
// A login the grant file stops giving is retired by the usual rules: stable, then retired, never dropped.
// A login the grant file stops giving is not retired by the provider itself (novox/hq ADR 0570 clause 5): every
// retirement waits for the operator's yes on a provision's key.
func TestALoginTheGrantFileStopsGivingIsRetired(t *testing.T) {
bound, _ := bindings(1)
w := newMovedWorld(t, bound)
@@ -117,7 +118,7 @@ func TestALoginTheGrantFileStopsGivingIsRetired(t *testing.T) {
w.pass()
w.granting(map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}})
w.settled()
if !reflect.DeepEqual(w.a.removed, []string{"mesh_ace_letta"}) || len(w.a.deleted) != 0 {
t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted)
if len(w.a.removed) != 0 || len(w.a.deleted) != 0 {
t.Fatalf("retired %v, deleted %v, by itself", w.a.removed, w.a.deleted)
}
}
+33 -60
View File
@@ -19,13 +19,13 @@ package harness
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/provider"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// MovedSpec is what differs per provider on the moved path.
@@ -57,17 +57,17 @@ type Moved struct {
// FirstRetry is the first wait before a watch asked too early is asked again (issue 943); a second when zero.
FirstRetry time.Duration
mu sync.Mutex // guards assignments and loaded
assignments map[string][]byte
loaded bool
verified map[string]time.Time
mu sync.Mutex // guards plans and loaded
plans map[string][]byte
loaded bool
verified map[string]time.Time
}
func newMoved(h *Harness, spec MovedSpec) *Moved {
h.mu.Lock()
h.init()
h.mu.Unlock()
return &Moved{h: h, spec: spec, assignments: map[string][]byte{}, verified: map[string]time.Time{}}
return &Moved{h: h, spec: spec, plans: map[string][]byte{}, verified: map[string]time.Time{}}
}
// Make brings one consumer's resource into being under its login and the vault's credential, as the harness does:
@@ -122,77 +122,50 @@ func (m *Moved) Make(ctx context.Context, c provider.Consumer) error {
return nil
}
// Gone is the helper asking whether it may forget a consumer that no longer binds. **It never retires**: retiring is
// the harness's rule, the same unasked set over StablePasses passes and StableFor, at most RetireAtOnce at a time
// (retirement.go, ADR 0230), run by retire below. So a consumer missing from one read of the plans loses nothing:
// the helper is told no, and asks again at its next pass, until the login is retired, or asked for again.
func (m *Moved) Gone(_ context.Context, e provider.Entry) error {
// Gone is the helper letting a consumer go, which it does only on a retiring provision the operator said yes to (novox/hq
// ADR 0570 clause 5): its login is retired at once, through the adapter, its resource and data kept and marked to
// delete, deleted only by a person's `cleanup delete`. A consumer another plan still binds under the same login loses
// nothing.
func (m *Moved) Gone(ctx context.Context, e provider.Entry) error {
h := m.h
as := m.spec.Login(e)
if as == "" {
return nil // refused: nothing was made for it
}
// The plans and the grant file as they are now, not as the last retirement pass read them: with either not
// read, nothing is let go.
want, read := m.asked(h.Node)
want, _ := m.asked(h.Node)
h.mu.Lock()
defer h.mu.Unlock()
asked := want[as]
if m.spec.WithGrants {
_, granted, ok := m.granted(want)
read = read && ok
asked = asked || granted[as]
}
_, held := h.applied[as]
_, retired := h.r.retired[as]
switch {
case !read:
case asked:
if want[as] {
return nil // another holder of the same login still binds: nothing to retire
case retired, !held && h.r.seeded:
}
if _, held := h.applied[as]; !held {
return nil
}
return fmt.Errorf("%s is kept until its retirement is stable (%d passes and %s) and within the bound, or a "+
"person approves it", as, h.StablePasses, h.StableFor)
if done := h.retire(ctx, []string{as}, "the operator approved its retirement ("+e.Key()+")", nil); len(done) != 1 {
return fmt.Errorf("%s was not retired; it is tried again at the next pass", as)
}
return nil
}
// plan is as much of an assignment as is read to learn who binds the provider.
type plan struct {
Node string `json:"node"`
Module string `json:"module"`
Provisioning *struct {
Provisions []struct {
Provision string `json:"provision"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
// As is who the consumer is at its provider's end, its slug first (issue 788).
As string `json:"as"`
} `json:"provisions"`
} `json:"provisioning"`
}
// asked is the logins the plans bind the provider for, and whether they could all be read: a plan that is not the
// controller's, or no read yet, is not an answer, so nothing is counted unasked from it (novox/hq issue 241).
// asked is the logins the seat's plans bind the provider for (novox/hq ADR 0570 clause 2), and whether they could all be
// read: a plan that cannot be read, or no read yet, is not an answer (novox/hq issue 241).
func (m *Moved) asked(node string) (map[string]bool, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.loaded || len(m.assignments) == 0 {
if !m.loaded || len(m.plans) == 0 {
return nil, false
}
want := map[string]bool{}
for key, raw := range m.assignments {
var a plan
if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key {
for key, raw := range m.plans {
pl, err := provisions.DecodePlan(raw)
if err != nil || pl.Node+"."+pl.Module != key {
return nil, false
}
if a.Provisioning == nil {
continue
}
for _, b := range a.Provisioning.Provisions {
for _, b := range pl.Plan.Provisions {
if b.Provider == node && b.ProviderModule == m.spec.Module && b.Provision == m.spec.Provision {
as := b.As
if as == "" {
as = provider.ConsumerIdentity(a.Node, a.Module) // a plan that names none
as = provider.ConsumerIdentity(pl.Node, pl.Module) // a plan that names none
}
want[as] = true
}
@@ -223,8 +196,8 @@ func (m *Moved) granted(planned map[string]bool) ([]contribution, map[string]boo
return own, logins, true
}
// retire is one pass of the harness's retirement over what the plans bind and the grant file gives now; the logins
// only the grant file gives are made and checked first, as the grant path does.
// retire is one pass over what the plans bind and the grant file gives now: the logins only the grant file gives are
// made and checked, as the grant path does. It retires nothing (ADR 0570 clause 5).
func (m *Moved) retire(ctx context.Context) {
h := m.h
want, ok := m.asked(h.Node)
@@ -245,7 +218,7 @@ func (m *Moved) retire(ctx context.Context) {
}
h.seed(ctx, want)
h.apply(ctx, given)
h.retireUnasked(ctx, want)
// No login is retired for going unasked (novox/hq ADR 0570 clause 5): only Gone, on the operator's yes.
h.r.lastWant = want
for as := range h.failing {
if !want[as] {
@@ -261,13 +234,13 @@ func (m *Moved) retire(ctx context.Context) {
// watch keeps the plans as the bus holds them, from the first read on.
func (m *Moved) watch(ctx context.Context, bus provider.Bus) error {
err := bus.Watch(ctx, provider.AssignmentsBucket, func(key string, value []byte) {
err := bus.Watch(ctx, provisions.PlansBucket, func(key string, value []byte) {
m.mu.Lock()
defer m.mu.Unlock()
if value == nil {
delete(m.assignments, key)
delete(m.plans, key)
} else {
m.assignments[key] = append([]byte(nil), value...)
m.plans[key] = append([]byte(nil), value...)
}
})
if err == nil {
+60 -76
View File
@@ -11,7 +11,9 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/provider"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
const (
@@ -57,6 +59,43 @@ func (b *bucketBus) Delete(_ context.Context, bucket, key string) error {
return nil
}
// putPlan is a consumer's plan on the seat mesh-provisioner, of these plan entries, each provision's key provided (novox/hq
// ADR 0570 clauses 1, 2): what the helper binds by.
func (b *bucketBus) putPlan(node, module string, entries ...map[string]any) {
raw, _ := json.Marshal(entries)
var ps []catalogue.PlanProvision
if err := json.Unmarshal(raw, &ps); err != nil {
panic(err)
}
for _, pp := range ps {
pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node},
Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided}
key, _ := pr.Key()
value, err := provisions.Encode(pr)
if err != nil {
panic(err)
}
_ = b.Put(context.Background(), provisions.ProvisionsBucket, key, value)
}
plan, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}})
if err != nil {
panic(err)
}
_ = b.Put(context.Background(), provisions.PlansBucket, node+"."+module, plan)
}
// retireOn is a consumer's provision retiring with the operator's yes (ADR 0570 clause 5), its plan gone: the one way
// a provider lets it go.
func (b *bucketBus) retireOn(node, module, provides string) {
pr := provisions.Provision{Provision: ProvisionName, Consumer: provisions.End{Module: module, Node: node},
Provider: provisions.End{Module: provides, Node: "novox"}, State: provisions.Retiring, Why: module + " left",
Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}}
key, _ := pr.Key()
value, _ := provisions.Encode(pr)
_ = b.Delete(context.Background(), provisions.PlansBucket, node+"."+module)
_ = b.Put(context.Background(), provisions.ProvisionsBucket, key, value)
}
// bind is one consumer's assignment, whose plan names this machine's postgres as the provider and carries what the
// consumer contributes (issue 776).
func (b *bucketBus) bind(node, module string, contributes map[string]any) {
@@ -65,12 +104,8 @@ func (b *bucketBus) bind(node, module string, contributes map[string]any) {
// bindNamed is bind to a provider module, with the names the controller composed for the consumer (issue 792).
func (b *bucketBus) bindNamed(provides, node, module string, contributes map[string]any, names map[string]string) {
plan, _ := json.Marshal(map[string]any{"node": node, "module": module, "provisioning": map[string]any{
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": provides,
"contributes": contributes, "names": names}}}})
if err := b.Put(context.Background(), provider.AssignmentsBucket, node+"."+module, plan); err != nil {
panic(err)
}
b.putPlan(node, module, map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": provides,
"contributes": contributes, "names": names})
}
// consumersFile writes the file the machine's client fills: one credential per consumer.
@@ -124,9 +159,9 @@ func newMovedWorldOf(t *testing.T, bound map[[2]string]string, spec MovedSpec) *
func (w *movedWorld) pass() []provider.Outcome {
w.now = w.now.Add(5 * time.Second)
// the plans as the bus holds them now, as the watch would hand them
w.run.assignments = map[string][]byte{}
for k, v := range w.bus.buckets[provider.AssignmentsBucket] {
w.run.assignments[k] = v
w.run.plans = map[string][]byte{}
for k, v := range w.bus.buckets[provisions.PlansBucket] {
w.run.plans[k] = v
}
// the helper reads its buckets from the in-memory bus
w.p = provider.New(provider.Options{Node: "novox", Module: w.spec.Module, Bus: w.bus,
@@ -205,46 +240,27 @@ func TestTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) {
}
}
// One read without a consumer retires nothing: it takes the harness's stable passes and time, and then the entry
// goes and the login is retired, never dropped.
// A consumer whose plan goes retires nothing by itself, however long (novox/hq ADR 0570 clause 5): its login is retired,
// never dropped, and its entry removed only once its provision is retiring with the operator's yes.
func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) {
bound, logins := bindings(2)
w := newMovedWorld(t, bound)
w.bus.bind("ace", "app1", nil)
w.bus.bind("ace", "app2", nil)
w.pass()
// One read without app2.
saved := w.bus.buckets[provider.AssignmentsBucket]["ace.app2"]
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2")
w.pass()
if len(w.a.removed) != 0 {
t.Fatalf("retired %v on one read", w.a.removed)
}
if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept {
t.Fatal("the entry went on one read")
}
// It is back: nothing was lost, and the entry never left the bucket.
w.bus.Put(ctx, provider.AssignmentsBucket, "ace.app2", saved)
_ = w.bus.Delete(ctx, provisions.PlansBucket, "ace.app2")
for i := 0; i < 8; i++ {
w.pass()
}
w.now = w.now.Add(15 * time.Minute)
w.now = w.now.Add(30 * time.Minute)
w.pass()
if len(w.a.removed) != 0 || len(w.bus.buckets[provider.BucketOf("postgres")]) != 2 {
t.Fatalf("retired %v, entries %d", w.a.removed, len(w.bus.buckets[provider.BucketOf("postgres")]))
if len(w.a.removed) != 0 || w.h.r.waiting != nil {
t.Fatalf("retired %v, waiting %v, with no warrant", w.a.removed, w.h.r.waiting)
}
// Gone for good: not retired before StablePasses and StableFor, then retired and its entry removed.
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2")
for i := 0; i < 6; i++ {
w.pass()
if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept {
t.Fatal("the entry went with no warrant")
}
if len(w.a.removed) != 0 {
t.Fatalf("retired %v before the time was out", w.a.removed)
}
w.now = w.now.Add(11 * time.Minute)
w.pass()
w.bus.retireOn("ace", "app2", "postgres")
w.pass()
if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 {
t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted)
@@ -254,35 +270,13 @@ func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) {
}
}
// More than three at once waits for a person, as the harness's bound says.
func TestMoreThanTheBoundWaitsForAPerson(t *testing.T) {
bound, _ := bindings(5)
w := newMovedWorld(t, bound)
for i := 1; i <= 5; i++ {
w.bus.bind("ace", fmt.Sprintf("app%d", i), nil)
}
w.pass()
for i := 1; i <= 4; i++ {
w.bus.Delete(ctx, provider.AssignmentsBucket, fmt.Sprintf("ace.app%d", i))
}
for i := 0; i < 6; i++ {
w.pass()
}
w.now = w.now.Add(11 * time.Minute)
w.pass()
w.pass()
if len(w.a.removed) != 0 || w.h.r.waiting == nil {
t.Fatalf("retired %v, waiting %v", w.a.removed, w.h.r.waiting)
}
}
// A plan list that was never read is not nobody asking.
func TestNoPlansReadRetiresNothing(t *testing.T) {
bound, _ := bindings(1)
w := newMovedWorld(t, bound)
w.bus.bind("ace", "app1", nil)
w.pass()
w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app1")
w.bus.Delete(ctx, provisions.PlansBucket, "ace.app1")
for i := 0; i < 8; i++ {
w.pass()
}
@@ -310,12 +304,8 @@ func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) {
func TestASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) {
agent := provider.ConsumerIdentity("ace", "agent")
w := newMovedWorld(t, map[[2]string]string{{"ace", "claude-code"}: "pw-agent"})
plan, _ := json.Marshal(map[string]any{"node": "ace", "module": "claude-code", "provisioning": map[string]any{
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox",
"provider-module": "postgres", "as": agent}}}})
if err := w.bus.Put(ctx, provider.AssignmentsBucket, "ace.claude-code", plan); err != nil {
t.Fatal(err)
}
w.bus.putPlan("ace", "claude-code", map[string]any{"provision": ProvisionName, "provider": "novox",
"provider-module": "postgres", "as": agent})
for _, o := range w.pass() {
if o.Status != provider.Answered {
t.Fatalf("%+v", o)
@@ -353,10 +343,8 @@ func TestASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile(t *testing
if err != nil {
t.Fatal(err)
}
plan, _ := json.Marshal(map[string]any{"node": "ace", "module": "wiki", "provisioning": map[string]any{
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox",
"provider-module": "keycloak", "as": "wiki-client", "contributes": map[string]any{"label": "Wiki"}}}}})
_ = bus.Put(ctx, provider.AssignmentsBucket, "ace.wiki", plan)
bus.putPlan("ace", "wiki", map[string]any{"provision": ProvisionName, "provider": "novox",
"provider-module": "keycloak", "as": "wiki-client", "contributes": map[string]any{"label": "Wiki"}})
if err := run.watch(ctx, bus); err != nil {
t.Fatal(err)
}
@@ -454,12 +442,8 @@ func TestTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape(t *testin
func TestAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) {
agent := provider.ConsumerIdentity("home", "dashboard")
w := newMovedWorldOf(t, map[[2]string]string{{"home", "grafana-2"}: "pw"}, keycloakSpec)
plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana-2", "provisioning": map[string]any{
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak",
"as": agent, "contributes": grafanaContributes, "names": grafanaNames}}}})
if err := w.bus.Put(ctx, provider.AssignmentsBucket, "home.grafana-2", plan); err != nil {
t.Fatal(err)
}
w.bus.putPlan("home", "grafana-2", map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak",
"as": agent, "contributes": grafanaContributes, "names": grafanaNames})
for _, o := range w.pass() {
if o.Status != provider.Answered {
t.Fatalf("%+v", o)
+173 -105
View File
@@ -47,6 +47,7 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// AssignmentsBucket is the controller's assignments mirror, `<node>.<module>`, whose value carries the plan of a
@@ -124,6 +125,11 @@ type Entry struct {
VaultName string `json:"vault-name,omitempty"`
// Refused, when set, is why the consumer is not served, naming both ends.
Refused string `json:"refused,omitempty"`
// State is the provider's outcome for the consumer, which the holder of mesh-provisioner reads to set the
// provision's key (novox/hq ADR 0570 clause 3): provided, failed with Why, or waits with what is awaited. Empty from
// a provider before it.
State string `json:"state,omitempty"`
Why string `json:"why,omitempty"`
// Contributes is what the consumer's module tells its provider about this provision (a database's name, its
// extensions), from its plan's entry (issue 776). Handed to Make and Gone; never written to the bucket.
Contributes map[string]any `json:"-"`
@@ -185,11 +191,13 @@ type Options struct {
type Provider struct {
opts Options
mu sync.Mutex
assignments map[string][]byte
entries map[string][]byte
said map[string]string
poke chan struct{}
mu sync.Mutex
entries map[string][]byte
// plans and provisions are the seat mesh-provisioner's states (ADR 0570 clauses 1, 2), by key.
plans map[string][]byte
provisions map[string][]byte
said map[string]string
poke chan struct{}
}
// New is a provider answering its consumers as opts say.
@@ -203,10 +211,15 @@ func New(opts Options) *Provider {
if opts.Logf == nil {
opts.Logf = func(f string, a ...any) { fmt.Printf("[provider] "+f+"\n", a...) }
}
return &Provider{opts: opts, assignments: map[string][]byte{}, entries: map[string][]byte{},
said: map[string]string{}, poke: make(chan struct{}, 1)}
return &Provider{opts: opts, entries: map[string][]byte{},
plans: map[string][]byte{}, provisions: map[string][]byte{}, said: map[string]string{}, poke: make(chan struct{}, 1)}
}
// ErrNotReady is what a Make returns, wrapped or alone, when the provider cannot make a consumer's provision yet but
// will without anyone's hand (its keys not read since a restart): the entry waits and keeps serving what it served.
// Any other error is a failure, and its entry serves nothing.
var ErrNotReady = errors.New("not ready yet")
// What a pass did for one bind.
const (
Answered = "answered"
@@ -232,16 +245,22 @@ func (p *Provider) Run(ctx context.Context) error {
if err := p.watch(ctx, BucketOf(p.opts.Module), p.entries); err != nil {
return fmt.Errorf("watching %s: %w", BucketOf(p.opts.Module), err)
}
if err := p.watch(ctx, AssignmentsBucket, p.assignments); err != nil {
return fmt.Errorf("watching %s: %w", AssignmentsBucket, err)
for _, bucket := range []string{provisions.PlansBucket, provisions.ProvisionsBucket} {
into := p.plans
if bucket == provisions.ProvisionsBucket {
into = p.provisions
}
if err := p.watch(ctx, bucket, into); err != nil {
return fmt.Errorf("watching %s: %w", bucket, err)
}
}
tick := time.NewTicker(p.opts.Every)
defer tick.Stop()
for {
p.mu.Lock()
assignments, entries := clone(p.assignments), clone(p.entries)
in := Inputs{Entries: clone(p.entries), Plans: clone(p.plans), Provisions: clone(p.provisions)}
p.mu.Unlock()
if _, err := p.pass(ctx, assignments, entries); err != nil {
if _, err := p.passIn(ctx, in); err != nil {
p.say("pass", err.Error())
}
select {
@@ -262,7 +281,7 @@ func (p *Provider) watch(ctx context.Context, bucket string, into map[string][]b
into[key] = append([]byte(nil), value...)
}
p.mu.Unlock()
if bucket == AssignmentsBucket {
if bucket != BucketOf(p.opts.Module) {
select {
case p.poke <- struct{}{}:
default:
@@ -302,110 +321,82 @@ func (p *Provider) Pass(ctx context.Context) ([]Outcome, error) {
if err != nil {
return nil, fmt.Errorf("reading %s: %w", BucketOf(p.opts.Module), err)
}
assignments, err := read(AssignmentsBucket)
plans, err := read(provisions.PlansBucket)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", AssignmentsBucket, err)
return nil, fmt.Errorf("reading %s: %w", provisions.PlansBucket, err)
}
return p.pass(ctx, assignments, entries)
provs, err := read(provisions.ProvisionsBucket)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", provisions.ProvisionsBucket, err)
}
return p.passIn(ctx, Inputs{Entries: entries, Plans: plans, Provisions: provs})
}
// planned is as much of an assignment as the helper reads: lenient, since the plan is the client's to act on.
type planned struct {
Node string `json:"node"`
Module string `json:"module"`
Provisioning *struct {
Provisions []struct {
Provision string `json:"provision"`
Local string `json:"local"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
Contributes map[string]any `json:"contributes"`
At string `json:"at"`
From string `json:"from"`
As string `json:"as"`
Names map[string]string `json:"names"`
CredentialForm string `json:"credential-form"`
Name string `json:"name"`
} `json:"provisions"`
} `json:"provisioning"`
// Inputs are what one pass reads: the provider's own entries, and the seat mesh-provisioner's plans and provisions
// (novox/hq ADR 0570 clauses 1, 2), each by key. The controller's assignments are read no more (clause 2).
type Inputs struct {
Entries, Plans, Provisions map[string][]byte
}
func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]byte) ([]Outcome, error) {
func (p *Provider) passIn(ctx context.Context, in Inputs) ([]Outcome, error) {
entries := in.Entries
var binds []Entry
var out []Outcome
unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged
for _, key := range sortedKeys(assignments) {
if !Standing(assignments[key]) {
continue // ended or pending: read as a removed key (issue 1098)
}
var a planned
if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key {
o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the assignment %s is not the controller's, so "+
"who it binds is not known; it is skipped, and its entries are left as they are", key)}
// Every consumer is bound by its plan and its provision keys (ADR 0570 clauses 2, 3), and an entry no plan names is
// never let go by the provider itself (clause 5): only a retiring key with the operator's yes retires it.
seat := map[string]bool{}
gated := map[string]provisions.Provision{}
for _, key := range sortedKeys(in.Plans) {
plan, err := provisions.DecodePlan(in.Plans[key])
if err != nil || plan.Node+"."+plan.Module != key {
o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the plan %s is not readable, so who it binds is not "+
"known; it is skipped, and its entries are left as they are", key)}
out = append(out, o)
p.say(key, o.Status+" "+o.Why)
unknown[key] = true
unknown[key], seat[key] = true, true
continue
}
if a.Provisioning == nil {
continue
}
for _, b := range a.Provisioning.Provisions {
seat[key] = true
for _, b := range plan.Plan.Provisions {
if b.Provider != p.opts.Node || b.ProviderModule != p.opts.Module {
continue
}
binds = append(binds, Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module,
Provision: b.Provision, Consumer: a.Node, Module: a.Module, Local: b.Local,
Contributes: b.Contributes, At: b.At, From: b.From, As: b.As, Names: b.Names,
CredentialForm: b.CredentialForm, Name: b.Name})
e := Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, Provision: b.Provision, Consumer: plan.Node,
Module: plan.Module, Local: b.Local, Contributes: b.Contributes, At: b.At, From: b.From, As: b.As,
Names: b.Names, CredentialForm: b.CredentialForm, Name: b.Name}
binds = append(binds, e)
}
}
for _, key := range sortedKeys(in.Provisions) {
if pr, err := provisions.Decode(in.Provisions[key]); err == nil && pr.Provider.Node == p.opts.Node &&
pr.Provider.Module == p.opts.Module {
gated[key] = pr
}
}
want := map[string]bool{}
for _, b := range binds {
key := b.Key()
want[key] = true
o := p.answer(ctx, b, entries[key])
out = append(out, o)
p.say(key, o.Status+" "+o.Why)
}
for _, key := range sortedKeys(entries) {
if want[key] || unknown[consumerOf(key)] {
continue
}
var e Entry
if err := json.Unmarshal(entries[key], &e); err != nil || e.Provider != p.opts.Node ||
e.ProviderModule != p.opts.Module {
continue // another machine's instance of this provider wrote it, or nothing of this helper's
}
if p.opts.Gone != nil {
if err := p.opts.Gone(ctx, e); err != nil {
o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " +
err.Error()}
out = append(out, o)
p.say(key, o.Status+" "+o.Why)
continue
}
}
if err := p.remove(ctx, key, entries[key]); err != nil {
if errors.Is(err, stdio.ErrChanged) {
o := Outcome{Key: key, Status: Superseded, Why: "another instance of the provider wrote its entry " +
"since this pass read it, so it stands"}
out = append(out, o)
p.say(key, o.Status+" "+o.Why)
continue
}
o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " +
err.Error()}
if why, ok := p.ready(key, gated, in.Provisions); !ok {
o := Outcome{Key: key, Status: Waiting, Why: why}
out = append(out, o)
p.say(key, o.Status+" "+o.Why)
continue
}
out = append(out, Outcome{Key: key, Status: Removed})
p.say(key, Removed)
p.mu.Lock()
delete(p.said, key)
p.mu.Unlock()
o := p.answer(ctx, b, entries[key])
out = append(out, o)
p.say(key, o.Status+" "+o.Why)
}
// ADR 0570 clause 5: a retiring key the operator said yes to is let go; one asked and not answered, or declined,
// keeps its entry.
for _, key := range sortedKeys(gated) {
if pr := gated[key]; pr.State == provisions.Retiring && pr.Warrant != nil && pr.Warrant.Option == "yes" {
want[key] = false
if _, held := entries[key]; held {
out = append(out, p.letGo(ctx, key, entries[key])...)
}
}
}
return out, nil
}
@@ -443,6 +434,7 @@ func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome {
strings.Join(derivedKeys(offer.Serves), ", "))
}
if e.Refused != "" {
e.State, e.Why = provisions.Failed, e.Refused
return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused})
}
@@ -455,6 +447,7 @@ func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome {
e.Refused = fmt.Sprintf("%s on %s serving %s to %s on %s: %v", e.ProviderModule, e.Provider, e.Provision,
e.Module, e.Consumer, err)
e.As = ""
e.State, e.Why = provisions.Failed, e.Refused
return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused})
}
if e.At != "" || e.From != "" {
@@ -505,12 +498,31 @@ func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome {
}
}
if p.opts.Make != nil {
if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); err != nil {
why := err.Error()
why = redact(why, credential)
return Outcome{Key: key, Status: Failed, Why: "the provider could not make it: " + why}
if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); errors.Is(err, ErrNotReady) {
// **Not ready is no failure** (the regression of 2026-10-30: gitea after a restart, its keys not read yet,
// stopped serving every consumer): the entry says waits with why, and keeps what the last entry served, so a
// consumer goes on with it until the provider can make it again.
why := "the provider is not ready to make it yet: " + redact(err.Error(), credential)
waiting := e
var prev Entry
if len(have) > 0 && json.Unmarshal(have, &prev) == nil && prev.State == provisions.Provided {
waiting.Values, waiting.VaultName, waiting.User = prev.Values, prev.VaultName, prev.User
} else {
waiting.Values, waiting.VaultName, waiting.User = nil, "", ""
}
waiting.State, waiting.Why = provisions.Waits, why
p.write(ctx, waiting, have, Outcome{Key: key, Status: Waiting, Why: why})
return Outcome{Key: key, Status: Waiting, Why: why}
} else if err != nil {
why := "the provider could not make it: " + redact(err.Error(), credential)
// Said in its entry, which the holder of mesh-provisioner reads (ADR 0570 clause 3), with nothing served.
failed := e
failed.Values, failed.VaultName, failed.User, failed.State, failed.Why = nil, "", "", provisions.Failed, why
p.write(ctx, failed, have, Outcome{Key: key, Status: Failed, Why: why})
return Outcome{Key: key, Status: Failed, Why: why}
}
}
e.State = provisions.Provided
return p.write(ctx, e, have, Outcome{Key: key, Status: Answered})
}
@@ -557,20 +569,20 @@ func (p *Provider) say(key, what string) {
}
// SharedName is the vault's one value per provider and provision (ADR 0158, issue 677).
func SharedName(provider, provision string) string { return "provision/" + provider + "/" + provision }
func SharedName(provider, provision string) string {
return provisions.VaultName(provisions.FormShared, provisions.Provision{Provision: provision, Provider: provisions.End{Node: provider}})
}
// HolderName is the vault's value of one holder's own credential, issued or given for it (mesh-vault perholder.go).
func HolderName(provider, provision, node, module string) string {
return "provision/" + provider + "/" + provision + "/" + node + "/" + module
return provisions.VaultName(provisions.FormIssued, provisions.Provision{Provision: provision,
Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}})
}
// PairName is the vault's value of one consumer's own login (ADR 0048, ADR 0158 §11, issue 677's note 12).
func PairName(provider, provision, node, module, local string) string {
n := "pair/" + provider + "/" + provision + "/" + node + "/" + module
if local != "" {
n += "/" + local
}
return n
return provisions.VaultName(provisions.FormPair, provisions.Provision{Provision: provision, Local: local,
Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}})
}
// identityUnusable is every character a consumer's identity does not hold (mesh-controller internal/catalogue
@@ -672,8 +684,9 @@ func sortedKeys[V any](m map[string]V) []string {
}
// StateBus is the Bus of a provider that runs as a module bundle over the runtime's stdio: the provider's own bucket,
// BucketOf(module), is the module's state `bound`, and AssignmentsBucket is the state it reads,
// `mesh-controller.assignments` (both declared in the module's manifest). Any other bucket is refused.
// BucketOf(module), is the module's state `bound`, and provisions.PlansBucket and provisions.ProvisionsBucket are the
// states it reads, `mesh-provisioner.plans` and `mesh-provisioner.provisions` (each declared in the module's manifest).
// Any other bucket is refused.
//
// A stdio state watch lasts as long as the process and cannot be ended (stdio.KeptState.Watch), so Watch honours the
// Bus contract by silencing instead: once ctx has ended the handler is never called again, though the runtime keeps
@@ -687,8 +700,10 @@ func (b stateBus) state(bucket string) (stdio.KeptState, error) {
switch bucket {
case BucketOf(b.module):
return stdio.State("bound"), nil
case AssignmentsBucket:
return stdio.State("mesh-controller.assignments"), nil
case provisions.PlansBucket:
return stdio.State(provisions.PlansRead), nil
case provisions.ProvisionsBucket:
return stdio.State(provisions.ProvisionsRead), nil
}
return stdio.KeptState{}, fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket)
}
@@ -767,3 +782,56 @@ func fieldSet(f json.RawMessage) bool {
s := strings.TrimSpace(string(f))
return s != "" && s != "null" && s != `""`
}
// letGo lets a consumer's entry go: the provider hears it gone (Gone, which retires what it kept for the consumer), and
// the entry is removed while it is still as read.
func (p *Provider) letGo(ctx context.Context, key string, held []byte) []Outcome {
var e Entry
if err := json.Unmarshal(held, &e); err != nil || e.Provider != p.opts.Node || e.ProviderModule != p.opts.Module {
return nil // another machine's instance of this provider wrote it, or nothing of this helper's
}
said := func(o Outcome) []Outcome {
p.say(key, o.Status+" "+o.Why)
return []Outcome{o}
}
if p.opts.Gone != nil {
if err := p.opts.Gone(ctx, e); err != nil {
return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " + err.Error()})
}
}
if err := p.remove(ctx, key, held); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return said(Outcome{Key: key, Status: Superseded, Why: "another instance of the provider wrote its entry " +
"since this pass read it, so it stands"})
}
return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " + err.Error()})
}
p.say(key, Removed)
p.mu.Lock()
delete(p.said, key)
p.mu.Unlock()
return []Outcome{{Key: key, Status: Removed}}
}
// ready says whether the provision under key may be made now (novox/hq ADR 0570 clause 3): its key is on the seat, it is
// asked or provided, and every provision it depends on is provided. Otherwise why it waits, naming the key or the
// dependency.
func (p *Provider) ready(key string, mine map[string]provisions.Provision, all map[string][]byte) (string, bool) {
pr, ok := mine[key]
if !ok {
return "mesh-provisioner holds no key " + key + " naming this provider yet", false
}
if pr.State != provisions.Asked && pr.State != provisions.Provided {
return "its key is " + pr.State + ": " + pr.Why, false
}
for _, d := range pr.DependsOn {
dep, err := provisions.Decode(all[d])
if err != nil {
return "it depends on " + d + ", which mesh-provisioner holds no readable key of", false
}
if dep.State != provisions.Provided {
return "it depends on " + d + ", which is " + dep.State, false
}
}
return "", true
}
+200 -46
View File
@@ -15,6 +15,8 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// standIn is a stand-in bus: buckets of keys and values, and watches that hand the current values first and then
@@ -165,22 +167,63 @@ func (m *maker) Gone(_ context.Context, e Entry) error {
return nil
}
// assign puts a consumer's assignment with its plan's provisions into the stand-in mirror.
func assign(t *testing.T, bus *standIn, node, module string, provisions ...map[string]string) {
// assign puts a consumer's plan with its provisions on the seat mesh-provisioner, each provision's key provided, as its
// holder writes them (novox/hq ADR 0570 clauses 1, 2): what a provider binds by.
func assign(t *testing.T, bus *standIn, node, module string, binds ...map[string]string) {
t.Helper()
ps := []map[string]string{}
ps = append(ps, provisions...)
raw, err := json.Marshal(map[string]any{"node": node, "module": module, "assigned": "2026-10-15T00:00:00Z",
"provisioning": map[string]any{"templates": map[string]string{"source": "x", "digest": "y"},
"provisions": ps}})
var ps []catalogue.PlanProvision
for _, b := range binds {
raw, _ := json.Marshal(b)
var pp catalogue.PlanProvision
if err := json.Unmarshal(raw, &pp); err != nil {
t.Fatal(err)
}
ps = append(ps, pp)
}
planned(t, bus, node, module, ps...)
}
// planned puts a consumer's plan of these provisions on the seat, each provision's key provided.
func planned(t *testing.T, bus *standIn, node, module string, ps ...catalogue.PlanProvision) {
t.Helper()
for _, pp := range ps {
pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node},
Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided}
key, err := pr.Key()
if err != nil {
t.Fatal(err)
}
value, err := provisions.Encode(pr)
if err != nil {
t.Fatal(err)
}
_ = bus.Put(context.Background(), provisions.ProvisionsBucket, key, value)
}
raw, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}})
if err != nil {
t.Fatal(err)
}
if err := bus.Put(context.Background(), AssignmentsBucket, node+"."+module, raw); err != nil {
if err := bus.Put(context.Background(), provisions.PlansBucket, node+"."+module, raw); err != nil {
t.Fatal(err)
}
}
// retire marks a consumer's provision retiring, with the operator's yes (novox/hq ADR 0570 clause 5): the one way a
// provider lets a consumer go.
func retire(t *testing.T, bus *standIn, node, module, provision, provider, providerModule string) {
t.Helper()
pr := provisions.Provision{Provision: provision, Consumer: provisions.End{Module: module, Node: node},
Provider: provisions.End{Module: providerModule, Node: provider}, State: provisions.Retiring, Why: module + " left " + node,
Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}}
key, _ := pr.Key()
raw, err := provisions.Encode(pr)
if err != nil {
t.Fatal(err)
}
_ = bus.Delete(context.Background(), provisions.PlansBucket, node+"."+module)
_ = bus.Put(context.Background(), provisions.ProvisionsBucket, key, raw)
}
func bind(provision, local, provider, providerModule string) map[string]string {
b := map[string]string{"provision": provision, "provider": provider, "provider-module": providerModule}
if local != "" {
@@ -352,7 +395,7 @@ func TestAnUnbindRemovesTheEntryAndLeavesAnotherProvidersAlone(t *testing.T) {
if _, err := p.Pass(context.Background()); err != nil {
t.Fatal(err)
}
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres")
out, err := p.Pass(context.Background())
if err != nil {
t.Fatal(err)
@@ -418,11 +461,11 @@ func TestOnlyBindsNamingThisProviderAreAnswered(t *testing.T) {
}
}
// An assignment that cannot be read is skipped and said, by its key and why; the rest of the pass goes on (issue 825,
// A plan that cannot be read is skipped and said, by its key and why; the rest of the pass goes on (issue 825,
// finding 3). Its consumer's own entry is left alone, since a bind missed would read as an unbind.
func TestAnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn(t *testing.T) {
for name, bad := range map[string]string{"not json": "{not json",
"another key": `{"node":"g14","module":"umami"}`} {
"another key": `{"v":1,"node":"g14","module":"umami","plan":{"templates":{"source":"","digest":""},"files":null}}`} {
bus, m := newStandIn(), &maker{}
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s",
"pair/novox/postgres-database/g14/umami": "u"}}
@@ -431,7 +474,7 @@ func TestAnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn(t *testing.T) {
if _, err := p.Pass(context.Background()); err != nil {
t.Fatal(err)
}
_ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte(bad))
_ = bus.Put(context.Background(), provisions.PlansBucket, "ace.letta", []byte(bad))
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
var logged []string
p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
@@ -457,7 +500,8 @@ func TestAnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn(t *testing.T) {
}
}
// An adapter that fails is said with the credential taken out of its words, and the entry is not written.
// An adapter that fails is said with the credential taken out of its words, and its entry says it failed, serving
// nothing (novox/hq ADR 0570 clause 3: the provider's outcome is its entry, which the holder of mesh-provisioner reads).
func TestAFailedMakeIsSaidWithoutTheCredential(t *testing.T) {
bus, m := newStandIn(), &maker{fail: errors.New(`password "hunter2-secret" rejected`)}
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "hunter2-secret"}}
@@ -475,8 +519,38 @@ func TestAFailedMakeIsSaidWithoutTheCredential(t *testing.T) {
if strings.Contains(strings.Join(logged, "\n"), "hunter2") || len(logged) == 0 {
t.Fatalf("logged %q", logged)
}
if bus.keys(BucketOf("postgres")) != nil {
t.Fatal("an entry was written for a consumer the adapter could not make")
var e Entry
raw := bus.get(BucketOf("postgres"), "ace.letta.postgres-database")
if err := json.Unmarshal(raw, &e); err != nil || e.State != "failed" || e.Values != nil || e.VaultName != "" ||
strings.Contains(string(raw), "hunter2") {
t.Fatalf("the failed entry is %s", raw)
}
// A provider not ready yet (gitea after a restart, its keys not read; the regression of 2026-10-30) is no failure:
// a first entry waits serving nothing, and once made, a later not ready keeps serving what it served.
m.fail = fmt.Errorf("keys not read yet: %w", ErrNotReady)
_ = bus.Delete(context.Background(), BucketOf("postgres"), "ace.letta.postgres-database")
if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting {
t.Fatalf("not ready at first: %+v", out)
}
raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database")
if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || !strings.Contains(e.Why, "keys not read yet") || e.Values != nil {
t.Fatalf("the first entry not ready is %s", raw)
}
m.fail = nil
if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Answered {
t.Fatalf("made: %+v", out)
}
var made Entry
_ = json.Unmarshal(bus.get(BucketOf("postgres"), "ace.letta.postgres-database"), &made)
m.fail = ErrNotReady
if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting {
t.Fatalf("not ready after a restart: %+v", out)
}
raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database")
if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || e.VaultName != made.VaultName || e.User != made.User ||
fmt.Sprint(e.Values) != fmt.Sprint(made.Values) || len(made.Values) == 0 {
t.Fatalf("not ready after it served, the entry is %s, it served %+v", raw, made)
}
}
@@ -492,7 +566,7 @@ func TestRunAnswersBindsAndUnbindsAsTheyAreWatched(t *testing.T) {
go func() { done <- p.Run(ctx) }()
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") != nil })
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres")
waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil })
cancel()
if err := <-done; err != nil && !errors.Is(err, context.Canceled) {
@@ -532,12 +606,12 @@ func TestAConsumersContributionIsHandedToTheProviderPerConsumer(t *testing.T) {
bus, m := newStandIn(), &maker{}
v := &vault{values: map[string]string{
"pair/novox/postgres-database/ace/nextcloud": "a", "pair/novox/postgres-database/g14/umami": "b"}}
raw := `{"node":"ace","module":"nextcloud","provisioning":{"templates":{"source":"x","digest":"y"},` +
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
`"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}]}}`
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.nextcloud", []byte(raw)); err != nil {
var pp catalogue.PlanProvision
if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+
`"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}`), &pp); err != nil {
t.Fatal(err)
}
planned(t, bus, "ace", "nextcloud", pp)
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
t.Fatal(err)
@@ -561,12 +635,12 @@ func TestAConsumersEntryCarriesAtFromAndTheAsOfItsPlan(t *testing.T) {
bus, m := newStandIn(), &maker{}
v := &vault{values: map[string]string{
"pair/novox/postgres-database/ace/claude-code": "a", "pair/novox/postgres-database/g14/umami": "b"}}
raw := `{"node":"ace","module":"claude-code","provisioning":{"templates":{"source":"x","digest":"y"},` +
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
`"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}]}}`
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.claude-code", []byte(raw)); err != nil {
var pp catalogue.PlanProvision
if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+
`"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}`), &pp); err != nil {
t.Fatal(err)
}
planned(t, bus, "ace", "claude-code", pp)
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
t.Fatal(err)
@@ -596,12 +670,12 @@ func TestAConsumersEntryCarriesTheNamesOfItsPlan(t *testing.T) {
bus, m := newStandIn(), &maker{}
v := &vault{values: map[string]string{
"pair/novox/postgres-database/ace/gitea": "a", "pair/novox/postgres-database/g14/umami": "b"}}
raw := `{"node":"ace","module":"gitea","provisioning":{"templates":{"source":"x","digest":"y"},` +
`"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
`"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}]}}`
if err := bus.Put(context.Background(), AssignmentsBucket, "ace.gitea", []byte(raw)); err != nil {
var pp catalogue.PlanProvision
if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+
`"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}`), &pp); err != nil {
t.Fatal(err)
}
planned(t, bus, "ace", "gitea", pp)
assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres"))
if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil {
t.Fatal(err)
@@ -719,6 +793,26 @@ func TestThePlansCredentialFormWinsOverTheOffers(t *testing.T) {
if e := run(Shared, nil); e.VaultName != "provision/novox/postgres-database" {
t.Fatalf("the offer's form is the default: %+v", e)
}
{
// An offer that declares no credential is answered with none whatever form the plan says (the controller writes
// "pair" for such an offer): no vault name, and the Vault is never asked, so a provider with no Vault works (issue 825,
// finding 4; issue 923).
bus := newStandIn()
b := bind("route", "", "novox", "route-proxy")
b["credential-form"] = "pair"
assign(t, bus, "ace", "letta", b)
p := New(Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: func(string, ...any) {},
Offers: map[string]Offer{"route": {Credential: NoCredential}}})
out, err := p.Pass(context.Background())
if err != nil || len(out) != 1 || out[0].Status != Answered {
t.Fatalf("%v %+v", err, out)
}
var e Entry
if err := json.Unmarshal(bus.get(BucketOf("route-proxy"), "ace.letta.route"), &e); err != nil || e.VaultName != "" ||
e.User != "" {
t.Fatalf("%v %+v", err, e)
}
}
}
// An instance deletes only an entry it wrote, and only while the entry is as it read it: one that another instance of
@@ -733,7 +827,7 @@ func TestAnEntryAnotherInstanceWroteAfterTheMoveStands(t *testing.T) {
}
// The consumer moves to the instance on shanks: A's pass reads its own entry and the unbind, and while it lets
// the consumer go, B writes the consumer's entry.
_ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta")
retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres")
theirs, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database",
Consumer: "ace", Module: "letta", VaultName: "pair/shanks/postgres-database/ace/letta"})
a.opts.Gone = func(ctx context.Context, e Entry) error {
@@ -781,23 +875,83 @@ func TestAFailedMakeIsSaidWithoutTheCredentialInAnyOfItsForms(t *testing.T) {
}
}
// An offer that declares no credential is answered with none whatever form the plan says (the controller writes
// "pair" for such an offer): no vault name, and the Vault is never asked, so a provider with no Vault works (issue 825,
// finding 4; issue 923).
func TestAnOfferWithNoCredentialIgnoresThePlansForm(t *testing.T) {
bus := newStandIn()
b := bind("route", "", "novox", "route-proxy")
b["credential-form"] = "pair"
assign(t, bus, "ace", "letta", b)
p := New(Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: func(string, ...any) {},
Offers: map[string]Offer{"route": {Credential: NoCredential}}})
out, err := p.Pass(context.Background())
if err != nil || len(out) != 1 || out[0].Status != Answered {
t.Fatalf("%v %+v", err, out)
// novox/hq ADR 0570 clauses 3 and 5 (issue 1954 L6-P3, P4): a consumer the seat mesh-provisioner plans is bound by its
// plan and its provision key, never by its assignment. Its entry is made only once its key is asked and every provision
// it depends on is provided, and says provided; a plan gone retires nothing by itself; a retiring key is let go only on
// the operator's yes.
func TestTheSeatsKeysSayWhenAProviderActsAndWhenItLetsGo(t *testing.T) {
ctx := context.Background()
bus, m := newStandIn(), &maker{}
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}}
// The same consumer's assignment names another provider: the seat's plan wins.
assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres"))
plan, _ := provisions.EncodePlan(provisions.Plan{Node: "ace", Module: "letta", Plan: catalogue.Plan{
Provisions: []catalogue.PlanProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}}})
_ = bus.Put(ctx, provisions.PlansBucket, "ace.letta", plan)
key := "ace.letta.postgres-database"
put := func(k string, state string, depends []string, warrant *provisions.Warrant) {
pr := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "letta", Node: "ace"},
Provider: provisions.End{Module: "postgres", Node: "novox"}, DependsOn: depends, State: state, Warrant: warrant}
if k != key {
_, _, pr.Provision, _, _ = provisions.ParseKey(k)
pr.Provider = provisions.End{Module: "mesh-vault", Node: "novox"}
}
if state != provisions.Asked && state != provisions.Provided {
pr.Why = "said"
}
raw, err := provisions.Encode(pr)
if err != nil {
t.Fatal(err)
}
_ = bus.Put(ctx, provisions.ProvisionsBucket, k, raw)
}
var e Entry
if err := json.Unmarshal(bus.get(BucketOf("route-proxy"), "ace.letta.route"), &e); err != nil || e.VaultName != "" ||
e.User != "" {
t.Fatalf("%v %+v", err, e)
p := postgres(bus, v, m)
steps := []struct {
name string
before func()
status string
says string
entry bool
}{
{name: "no key yet", status: Waiting, says: "holds no key"},
{name: "a dependency asked", before: func() {
put("ace.letta.secret", provisions.Asked, nil, nil)
put(key, provisions.Asked, []string{"ace.letta.secret"}, nil)
}, status: Waiting, says: "depends on ace.letta.secret, which is asked"},
{name: "the dependency provided", before: func() { put("ace.letta.secret", provisions.Provided, nil, nil) },
status: Answered, entry: true},
{name: "its plan gone: nothing let go by itself", before: func() { _ = bus.Delete(ctx, provisions.PlansBucket, "ace.letta") },
entry: true},
{name: "retiring, not yet answered", before: func() { put(key, provisions.Retiring, nil, nil) }, entry: true},
{name: "retiring, the operator's yes", before: func() {
put(key, provisions.Retiring, nil, &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"})
}, status: Removed},
}
for _, s := range steps {
if s.before != nil {
s.before()
}
out, err := p.Pass(ctx)
if err != nil {
t.Fatalf("%s: %v", s.name, err)
}
var mine []Outcome
for _, o := range out {
if o.Key == key {
mine = append(mine, o)
}
}
if s.status != "" && (len(mine) != 1 || mine[0].Status != s.status || !strings.Contains(mine[0].Why, s.says)) {
t.Errorf("%s: %+v", s.name, mine)
}
if held := bus.get(BucketOf("postgres"), key) != nil; held != s.entry {
t.Errorf("%s: an entry held %v", s.name, held)
}
if s.entry && entryOf(t, bus, key).State != provisions.Provided {
t.Errorf("%s: the entry does not say provided", s.name)
}
}
if !slices.Equal(m.gone, []string{key}) || m.made[key] != "s1" {
t.Errorf("gone %v, made %v: let go once, on the warrant, and made from the vault's value", m.gone, m.made)
}
}
+7 -5
View File
@@ -10,6 +10,7 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// runtime drives a bundle over pipes as the runtime does: it reads what the bundle says and sends what the runtime would.
@@ -55,8 +56,9 @@ func serve(t *testing.T, tools []stdio.Tool) *runtime {
return &runtime{t: t, lines: bufio.NewScanner(outR), in: inW}
}
// The bus of a moved provider maps its own bucket to the state `bound` and the assignments to the state
// `mesh-controller.assignments`, writes only the own bucket's state, and refuses any other bucket.
// The bus of a moved provider maps its own bucket to the state `bound` and the seat's plans to the state
// `mesh-provisioner.plans` (novox/hq ADR 0570 clause 2), writes only the own bucket's state, and refuses any other
// bucket, the controller's assignments among them.
func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) {
bus := StateBus("postgres")
rt := serve(t, []stdio.Tool{
@@ -72,7 +74,7 @@ func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) {
})
for i, c := range []struct{ bucket, state string }{
{BucketOf("postgres"), "bound"},
{AssignmentsBucket, "mesh-controller.assignments"},
{provisions.PlansBucket, "mesh-provisioner.plans"},
} {
rt.call(i+1, "watch", map[string]any{"bucket": c.bucket})
w := rt.next()
@@ -97,7 +99,7 @@ func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) {
rt.send(map[string]any{"jsonrpc": "2.0", "id": d["id"], "result": map[string]any{}})
rt.next()
for _, bucket := range []string{"other_bound", "elsewhere"} {
for _, bucket := range []string{"other_bound", "elsewhere", AssignmentsBucket} {
if err := bus.Watch(context.Background(), bucket, func(string, []byte) {}); err == nil || !strings.Contains(err.Error(), "not a bucket") {
t.Fatalf("a watch of %s gave %v", bucket, err)
}
@@ -118,7 +120,7 @@ func TestStateBusWatchCallsItsHandlerNoMoreOnceItsContextEnded(t *testing.T) {
handled := make(chan string, 8)
rt := serve(t, []stdio.Tool{
{Name: "start", Run: func(map[string]any) (any, error) {
return "started", bus.Watch(ctx, AssignmentsBucket, func(key string, value []byte) {
return "started", bus.Watch(ctx, provisions.PlansBucket, func(key string, value []byte) {
handled <- key + "=" + string(value)
})
}},
+305
View File
@@ -0,0 +1,305 @@
// Package provisions is the contract of the seat mesh-provisioner's records (novox/hq ADR 0570 clauses 1, 2 and 5; issue
// 1954): every provision one key of the state `provisions`, holding a reference and never a value, and every handed
// module's provisioning plan one key of the state `plans`. The module mesh-provisioner alone writes both; the vault,
// each node's provisioner client and every provider read them through `reads`.
package provisions
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The seat, its holder and its two states, and the names a reader declares in its `reads`.
const (
Seat = "mesh-provisioner"
Module = "mesh-provisioner"
ProvisionsState = "provisions"
PlansState = "plans"
ProvisionsRead = Module + "." + ProvisionsState
PlansRead = Module + "." + PlansState
// ProvisionsBucket and PlansBucket are the states' buckets.
ProvisionsBucket = Module + "_" + ProvisionsState
PlansBucket = Module + "_" + PlansState
)
// The states of a provision (clause 1).
const (
// Asked is a provision its consumer needs that its provider has not answered yet, or whose dependencies are not
// all provided (clause 3).
Asked = "asked"
// Provided is a provision the provider said it made: set only from the provider's outcome (clause 3).
Provided = "provided"
// Waits is a provision only a person can give, naming what is awaited (clause 7).
Waits = "waits"
// Failed is a provision the provider could not make, with why.
Failed = "failed"
// Retiring is a provision no want needs, asked of the operator before its credential goes (clause 5).
Retiring = "retiring"
)
// States is every state a provision may be in.
var States = []string{Asked, Provided, Waits, Failed, Retiring}
// Version is the version of the records' layout.
const Version = 1
// token is one part of a key: what a bucket's key takes, no dot.
var token = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Key is a provision's key: `<node>.<consumer>.<provision>`, and `.<local>` where the consumer keeps several values of
// one provision (ADR 0094). The same key the provider's own entry for that consumer has (provider.Entry.Key).
func Key(node, consumer, provision, local string) (string, error) {
parts := []string{node, consumer, provision}
if local != "" {
parts = append(parts, local)
}
for _, p := range parts {
if !token.MatchString(p) {
return "", fmt.Errorf("provisions: %q cannot be part of a provision's key", p)
}
}
return strings.Join(parts, "."), nil
}
// ParseKey is the inverse of Key.
func ParseKey(key string) (node, consumer, provision, local string, err error) {
parts := strings.Split(key, ".")
if len(parts) < 3 || len(parts) > 4 {
return "", "", "", "", fmt.Errorf("provisions: %q is not <node>.<consumer>.<provision>[.<local>]", key)
}
for _, p := range parts {
if !token.MatchString(p) {
return "", "", "", "", fmt.Errorf("provisions: %q is not <node>.<consumer>.<provision>[.<local>]", key)
}
}
if len(parts) == 4 {
local = parts[3]
}
return parts[0], parts[1], parts[2], local, nil
}
// PlanKey is a plan's key in `plans`: `<node>.<module>` (clause 2).
func PlanKey(node, module string) (string, error) {
if !token.MatchString(node) || !token.MatchString(module) {
return "", fmt.Errorf("provisions: %s.%s is not a plan's key, <node>.<module>", node, module)
}
return node + "." + module, nil
}
// The forms of a provision's credential, as a plan's entry names them (catalogue.PlanProvision.CredentialForm).
const (
FormPair = "pair"
FormShared = "shared"
FormIssued = "issued"
FormGiven = "given"
// FormNone is an offer whose consumers present no credential (catalogue.CredentialFormNone): the vault mints nothing,
// and the provision depends on no credential.
FormNone = "none"
)
// VaultName is the vault's name of a provision's credential in form, as its verb `own` names it and the provider asks
// it: `pair/<provider node>/<provision>/<consumer node>/<module>[/<local>]` for a login of the consumer's own (ADR 0158
// §11), `provision/<provider node>/<provision>` for the provider's one shared value, and `provision/<provider
// node>/<provision>/<consumer node>/<module>` for a holder's own issued or given value. Empty for a form with no
// credential.
func VaultName(form string, p Provision) string {
switch form {
case FormPair:
n := "pair/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module
if p.Local != "" {
n += "/" + p.Local
}
return n
case FormShared:
return "provision/" + p.Provider.Node + "/" + p.Provision
case FormIssued, FormGiven:
return "provision/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module
}
return ""
}
// VaultModule is the provider of every provision's credential (novox/hq ADR 0570 clauses 1, 6).
const VaultModule = "mesh-vault"
// CredentialKey is the key of a provision's credential, itself a provision whose provider is the vault (clause 6; the
// key form the lead settled on 2026-10-29): `<consumer node>.<consumer>.secret-<provision>[.<local>]` for a value of the
// consumer's own (pair, issued, given), and `<provider node>.<provider>.secret-<provision>` for the provider's one
// shared value. False for a form with no vault value, and for a provision the vault itself provides.
func CredentialKey(form string, consumer, provider End, provision, local string) (string, bool) {
if provider.Module == VaultModule {
return "", false // the vault's own offer is the value: it has no credential beside it (the lead, 2026-10-29)
}
name := catalogue.CredentialProvisionPrefix + provision
switch form {
case FormPair, FormIssued, FormGiven:
k, err := Key(consumer.Node, consumer.Module, name, local)
return k, err == nil
case FormShared:
k, err := Key(provider.Node, provider.Module, name, "")
return k, err == nil
}
return "", false
}
// DependsOn is what a provision waits for before its provider makes it (clause 3): its credential's key, where its form
// has a vault value, and the key of every provision its provider module itself requires on its own node, read from
// the provider's plan there (nil when the provider is not handed and has none). Sorted, none twice.
func DependsOn(consumer End, entry catalogue.PlanProvision, providerPlan *catalogue.Plan) []string {
seen := map[string]bool{}
var out []string
add := func(k string) {
if k != "" && !seen[k] {
seen[k] = true
out = append(out, k)
}
}
provider := End{Module: entry.ProviderModule, Node: entry.Provider}
if k, ok := CredentialKey(entry.CredentialForm, consumer, provider, entry.Provision, entry.Local); ok {
add(k)
}
if providerPlan != nil {
for _, own := range providerPlan.Provisions {
if k, err := Key(provider.Node, provider.Module, own.Provision, own.Local); err == nil {
add(k)
}
}
}
sort.Strings(out)
return out
}
// End is one end of a provision: a module on a node.
type End struct {
Module string `json:"module"`
Node string `json:"node"`
}
// Reference is a value the vault keeps, by its id and the generation of the value: never the value, sealed or not
// (ADR 0349 §7). The holder writes the generation the vault says in its own entry (clause 6). ID is the vault's name of
// the credential exactly as its verb `own` names it (VaultName): the consuming node's engine checks both the name and
// the generation of what it is handed.
type Reference struct {
ID string `json:"id"`
Generation int `json:"generation"`
}
// Warrant is the operator's answer to a retiring provision's ask, as the holder writes it into the key (clause 5).
type Warrant struct {
Ask string `json:"ask"`
Option string `json:"option"`
By string `json:"by"`
At time.Time `json:"at"`
}
// Provision is one key of `provisions`.
type Provision struct {
V int `json:"v"`
Provision string `json:"provision"`
Local string `json:"local,omitempty"`
Consumer End `json:"consumer"`
Provider End `json:"provider"`
// DependsOn are the keys of the provisions this one waits for (clause 3): a database user whose password is the
// vault's.
DependsOn []string `json:"depends-on,omitempty"`
State string `json:"state"`
// Why is the reason for waits, failed and retiring, and for asked when a dependency holds it.
Why string `json:"why,omitempty"`
Reference *Reference `json:"reference,omitempty"`
// Ask is a retiring provision's ask of the operator; Warrant the answer, once given.
Ask string `json:"ask,omitempty"`
Warrant *Warrant `json:"warrant,omitempty"`
At time.Time `json:"at"`
}
// Key is the provision's key.
func (p Provision) Key() (string, error) {
return Key(p.Consumer.Node, p.Consumer.Module, p.Provision, p.Local)
}
func (p Provision) check() error {
if _, err := p.Key(); err != nil {
return err
}
if p.Provider.Module == "" || p.Provider.Node == "" {
return fmt.Errorf("provisions: %s names no provider", p.Provision)
}
known := false
for _, s := range States {
known = known || s == p.State
}
switch {
case !known:
return fmt.Errorf("provisions: a provision's state is one of %s, not %q", strings.Join(States, ", "), p.State)
case (p.State == Waits || p.State == Failed || p.State == Retiring) && strings.TrimSpace(p.Why) == "":
return fmt.Errorf("provisions: a %s provision says why", p.State)
case p.Warrant != nil && p.State != Retiring:
return fmt.Errorf("provisions: only a retiring provision carries the operator's warrant")
}
for _, d := range p.DependsOn {
if _, _, _, _, err := ParseKey(d); err != nil {
return err
}
}
return nil
}
// Encode is p as JSON at Version; it refuses what a holder may not write.
func Encode(p Provision) ([]byte, error) {
p.V = Version
if err := p.check(); err != nil {
return nil, err
}
return json.Marshal(p)
}
// Decode reads a key's value strictly: a field the layout does not name is refused, so no value, sealed or not, rides in
// a provision (clause 1, ADR 0201 §13).
func Decode(data []byte) (Provision, error) {
var p Provision
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return Provision{}, fmt.Errorf("provisions: a provision is not readable: %w", err)
}
if p.V != Version {
return Provision{}, fmt.Errorf("provisions: a provision of version %d, and this reads %d", p.V, Version)
}
return p, p.check()
}
// Plan is one key of `plans`: a module's provisioning plan on a node, never a value (clause 2).
type Plan struct {
V int `json:"v"`
Node string `json:"node"`
Module string `json:"module"`
Plan catalogue.Plan `json:"plan"`
}
// EncodePlan is p as JSON at Version.
func EncodePlan(p Plan) ([]byte, error) {
p.V = Version
if _, err := PlanKey(p.Node, p.Module); err != nil {
return nil, err
}
return json.Marshal(p)
}
// DecodePlan reads a plan's key.
func DecodePlan(data []byte) (Plan, error) {
var p Plan
if err := json.Unmarshal(data, &p); err != nil {
return Plan{}, fmt.Errorf("provisions: a plan is not readable: %w", err)
}
if p.V != Version {
return Plan{}, fmt.Errorf("provisions: a plan of version %d, and this reads %d", p.V, Version)
}
_, err := PlanKey(p.Node, p.Module)
return p, err
}
+119
View File
@@ -0,0 +1,119 @@
package provisions
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ADR 0570 clauses 1, 2 and 5 (issue 1954 L6-P2): a provision's key is its consumer's node and module, the provision and
// its local name, the provider's entry's key; its value round-trips, names its state and why, and refuses any field the
// layout does not name, so no value rides in it; a plan's key is its node and module.
func TestAProvisionIsAReferenceUnderItsConsumersKey(t *testing.T) {
at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
db := Provision{Provision: "postgres-database", Consumer: End{Module: "keycloak", Node: "novox"},
Provider: End{Module: "postgres", Node: "novox"}, DependsOn: []string{"novox.keycloak.secret.db"}, State: Asked,
Reference: &Reference{ID: "provision/postgres/postgres-database", Generation: 2}, At: at}
for _, c := range []struct {
name string
edit func(*Provision)
key string
refused string
}{
{name: "asked, depending on the vault's", key: "novox.keycloak.postgres-database"},
{name: "under a local name", edit: func(p *Provision) { p.Local = "replica" }, key: "novox.keycloak.postgres-database.replica"},
{name: "retiring with the operator's warrant", edit: func(p *Provision) {
p.State, p.Why, p.Ask = Retiring, "keycloak left novox", "a-1"
p.Warrant = &Warrant{Ask: "a-1", Option: "yes", By: "the operator", At: at}
}, key: "novox.keycloak.postgres-database"},
{name: "an unknown state", edit: func(p *Provision) { p.State = "done" }, refused: "one of"},
{name: "failed without why", edit: func(p *Provision) { p.State = Failed }, refused: "says why"},
{name: "a warrant on a provided one", edit: func(p *Provision) { p.State, p.Warrant = Provided, &Warrant{Ask: "a"} }, refused: "only a retiring"},
{name: "no provider", edit: func(p *Provision) { p.Provider = End{} }, refused: "no provider"},
{name: "a dotted consumer", edit: func(p *Provision) { p.Consumer.Module = "a.b" }, refused: "cannot be part"},
{name: "a dependency that is no key", edit: func(p *Provision) { p.DependsOn = []string{"x"} }, refused: "is not"},
} {
p := db
if c.edit != nil {
c.edit(&p)
}
raw, err := Encode(p)
if c.refused != "" {
if err == nil || !strings.Contains(err.Error(), c.refused) {
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
}
continue
}
back, err := Decode(raw)
key, kerr := back.Key()
if err != nil || kerr != nil || key != c.key || back.V != Version || back.Reference.Generation != 2 {
t.Errorf("%s: read back as %+v under %q, %v %v", c.name, back, key, err, kerr)
}
if node, consumer, provision, local, err := ParseKey(key); err != nil || node != "novox" || consumer != "keycloak" ||
provision != "postgres-database" || local != p.Local {
t.Errorf("%s: %s parses as %s %s %s %s, %v", c.name, key, node, consumer, provision, local, err)
}
}
// A value never rides in a provision: a field the layout does not name is refused.
if _, err := Decode([]byte(`{"v":1,"provision":"p","consumer":{"module":"c","node":"n"},"provider":{"module":"p","node":"n"},` +
`"state":"provided","at":"2026-10-29T22:00:00Z","password":"hunter2"}`)); err == nil {
t.Error("a provision carrying a password was read")
}
plan := Plan{Node: "novox", Module: "restic", Plan: catalogue.Plan{}}
raw, err := EncodePlan(plan)
if err != nil {
t.Fatal(err)
}
if back, err := DecodePlan(raw); err != nil || back.Node != "novox" || back.Module != "restic" {
t.Errorf("a plan read back as %+v, %v", back, err)
}
if k, err := PlanKey("novox", "restic"); k != "novox.restic" || err != nil {
t.Errorf("a plan's key is %q, %v", k, err)
}
// A reference's id is the vault's name as its verb own names it, in each form (the host agent, 2026-10-29).
for form, want := range map[string]string{FormPair: "pair/novox/postgres-database/novox/keycloak", FormShared: "provision/novox/postgres-database",
FormIssued: "provision/novox/postgres-database/novox/keycloak", FormNone: ""} {
if got := VaultName(form, db); got != want {
t.Errorf("the %s name is %q", form, got)
}
}
if got := VaultName(FormPair, Provision{Provision: "p", Local: "r", Provider: End{Node: "a"}, Consumer: End{Node: "b", Module: "m"}}); got != "pair/a/p/b/m/r" {
t.Errorf("a local name's pair is %q", got)
}
// A provision waits for its credential, the vault's provision `secret-<provision>`, and for what its provider itself
// requires on its node (clause 3; the key form the lead settled on 2026-10-29).
keycloak, postgres := End{Module: "keycloak", Node: "novox"}, End{Module: "postgres", Node: "ace"}
providerPlan := &catalogue.Plan{Provisions: []catalogue.PlanProvision{{Provision: "backup-target", Provider: "ace", ProviderModule: "restic"}}}
for _, c := range []struct {
form string
plan *catalogue.Plan
want string
}{
{FormPair, nil, "novox.keycloak.secret-postgres-database"},
{FormShared, nil, "ace.postgres.secret-postgres-database"},
{FormPair, providerPlan, "ace.postgres.backup-target,novox.keycloak.secret-postgres-database"},
{"", nil, ""},
// An offer with no credential (the lead, 2026-10-29): nothing minted, nothing to wait for.
{FormNone, nil, ""},
} {
entry := catalogue.PlanProvision{Provision: "postgres-database", Provider: postgres.Node, ProviderModule: postgres.Module,
CredentialForm: c.form}
if got := strings.Join(DependsOn(keycloak, entry, c.plan), ","); got != c.want {
t.Errorf("%q depends on %q, want %q", c.form, got, c.want)
}
}
// The vault's own offer is the value: no secret-secret beside it.
vaultEntry := catalogue.PlanProvision{Provision: "secret", Provider: "novox", ProviderModule: VaultModule, CredentialForm: FormPair}
if got := DependsOn(keycloak, vaultEntry, nil); len(got) != 0 {
t.Errorf("the vault's secret depends on %v", got)
}
if _, err := catalogue.ParseManifest([]byte(`{"module":"x","version":"1","provides":[{"name":"secret-db","scope":"mesh"}]}`)); err == nil ||
!strings.Contains(err.Error(), "secret-") {
t.Errorf("a provision named secret-*: %v", err)
}
if ProvisionsBucket != "mesh-provisioner_provisions" || PlansRead != "mesh-provisioner.plans" {
t.Errorf("the states are %s and %s", ProvisionsBucket, PlansRead)
}
}
+6
View File
@@ -177,6 +177,12 @@ func (s KeptState) Create(key string, value any) (uint64, error) {
return s.revisionOf(s.ask("create", map[string]any{"key": key, "value": value}))
}
// CreateTTL is Create with the key's own time to live (PutTTL): refused as ErrChanged when the key has a value, so an act
// begun once is not begun again; when ttl passes a watch hands the key over as OpExpire.
func (s KeptState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
return s.revisionOf(s.ask("create", map[string]any{"key": key, "value": value, "ttl_seconds": ttlSeconds(ttl)}))
}
// Update writes a key only when it is still at revision, and returns the new one; a key changed
// meanwhile is refused, so of two writers that read one value only the first writes.
func (s KeptState) Update(key string, value any, revision uint64) (uint64, error) {
+33 -1
View File
@@ -20,7 +20,18 @@ type StateEntry struct {
Revision uint64 `json:"revision"`
}
// StateChange is one change a watch hands over: a key put or deleted.
// The operations a watch hands over (StateChange.Op).
const (
// OpPut is a key written.
OpPut = "put"
// OpDelete is a key deleted or purged.
OpDelete = "delete"
// OpExpire is a key whose own time to live ended (CreateTTL, PutTTL): the runtime says the bus's marker of it as
// "expire", so a watcher tells an expiry, which is the event a pulse or a bound waits for, from a delete.
OpExpire = "expire"
)
// StateChange is one change a watch hands over: a key put (OpPut), deleted (OpDelete) or expired (OpExpire).
type StateChange struct {
State string `json:"state"`
Key string `json:"key"`
@@ -98,6 +109,27 @@ func (s KeptState) Put(key string, value any) (uint64, error) {
return put.Revision, json.Unmarshal(raw, &put)
}
// PutTTL writes a key whole with its own time to live — the owner's instances only — and returns the revision written:
// the bus removes it when ttl passes and a watch hands that over as OpExpire. The state is declared `key-ttl`.
func (s KeptState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) {
raw, err := s.ask("put", map[string]any{"key": key, "value": value, "ttl_seconds": ttlSeconds(ttl)})
if err != nil {
return 0, err
}
var put struct {
Revision uint64 `json:"revision"`
}
return put.Revision, json.Unmarshal(raw, &put)
}
// ttlSeconds is a time to live as the runtime takes it, whole seconds, at least one.
func ttlSeconds(ttl time.Duration) int64 {
if s := int64(ttl / time.Second); s > 1 {
return s
}
return 1
}
// Delete removes a key — the owner's instances only. A key that was not there is not an error.
func (s KeptState) Delete(key string) error {
_, err := s.ask("delete", map[string]any{"key": key})
+43
View File
@@ -3,6 +3,8 @@ package stdio
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
@@ -33,6 +35,12 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
return State("servers").Put(a["key"].(string), a["value"])
}},
{Name: "recall", Run: func(a map[string]any) (any, error) { return State("servers").Get(a["key"].(string)) }},
{Name: "pulse", Run: func(a map[string]any) (any, error) {
if a["create"] == true {
return State("servers").CreateTTL(a["key"].(string), 1, 90*time.Second)
}
return State("servers").PutTTL(a["key"].(string), 1, 90*time.Second)
}},
}, inR, outW)
}()
lines := bufio.NewScanner(outR)
@@ -87,6 +95,15 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
if err := <-watched; err != nil {
t.Fatal(err)
}
// 1952 (the runtime's per-key time to live, mesh-tools 1583fa85): an expiry is handed over as its own operation.
send(map[string]any{"jsonrpc": "2.0", "id": 49, "method": "mesh/state", "params": map[string]any{"watch": watch["id"],
"change": map[string]any{"state": "servers", "key": "all.gone", "op": OpExpire}}})
if r := next(); r["id"] != float64(49) || r["error"] != nil {
t.Fatalf("an expiry was answered %v", r)
}
if s := <-seen; s != "expire all.gone " {
t.Fatalf("handled %q", s)
}
// A change for a watch this bundle never asked for is refused.
send(map[string]any{"jsonrpc": "2.0", "id": 51, "method": "mesh/state", "params": map[string]any{"watch": "bundle-999",
"change": map[string]any{"key": "x", "op": "put"}}})
@@ -105,6 +122,32 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
if r := next(); r["id"] != float64(2) || r["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"] != "9" {
t.Fatalf("the put answered %v", r)
}
// PutTTL and CreateTTL ask the runtime's put and create with the key's time to live in whole seconds; a create the
// runtime refuses as changed (-32010) is ErrChanged.
for i, create := range []bool{false, true} {
send(map[string]any{"jsonrpc": "2.0", "id": 20 + i, "method": "tools/call", "params": map[string]any{"name": "pulse",
"arguments": map[string]any{"key": "pulse.S1", "create": create}}})
asked := next()
p := asked["params"].(map[string]any)
want := map[bool]string{false: "mesh/state.put", true: "mesh/state.create"}[create]
if asked["method"] != want || p["ttl_seconds"] != float64(90) || p["key"] != "pulse.S1" {
t.Fatalf("asked %v", asked)
}
if create {
send(map[string]any{"jsonrpc": "2.0", "id": asked["id"], "error": map[string]any{"code": CodeChanged, "message": "exists"}})
if r := next(); r["id"] != float64(21) || !strings.Contains(fmt.Sprint(r), "exists") {
t.Fatalf("a refused create answered %v", r)
}
continue
}
send(map[string]any{"jsonrpc": "2.0", "id": asked["id"], "result": map[string]any{"revision": 4}})
if r := next(); r["id"] != float64(20) {
t.Fatalf("the put answered %v", r)
}
}
if !errors.Is(&refused{code: CodeChanged, message: "x"}, ErrChanged) {
t.Fatal("a refusal coded -32010 is not ErrChanged")
}
// Get of a key with no value is nil.
send(map[string]any{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": map[string]any{"name": "recall",
"arguments": map[string]any{"key": "nothing"}}})
+26 -2
View File
@@ -72,6 +72,8 @@ type channel struct {
watches map[string]func(StateChange) error
// provisioned are this bundle's watches of its values, by the id each was asked with (provision.go).
provisioned map[string]func(ProvisionChange) error
// logWatches are this bundle's watches of a log, by the id each was asked with (log.go).
logWatches map[string]func(LogEntry) error
// takers and provers are this bundle's seat work and proof handlers, by worker and subject (ADR 0259).
takers map[string]func(Work) error
provers map[string]func(subject string, body json.RawMessage) (any, error)
@@ -182,8 +184,8 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
byName[t.Name] = t
}
c := &channel{enc: json.NewEncoder(out), waiting: map[string]chan message{}, watches: map[string]func(StateChange) error{},
provisioned: map[string]func(ProvisionChange) error{},
takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}}
provisioned: map[string]func(ProvisionChange) error{}, logWatches: map[string]func(LogEntry) error{},
takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}}
currentMu.Lock()
current = c
currentMu.Unlock()
@@ -315,6 +317,28 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
}
c.say(map[string]any{"id": id, "result": map[string]any{}})
}(id, *p.Change)
case "mesh/log":
// One entry of a log this bundle watches (log.go), named by the watch's id, answered once its handler has
// taken it, in order: the runtime sends the next only after this answer.
var p struct {
Watch string `json:"watch"`
Entry *LogEntry `json:"entry"`
}
_ = json.Unmarshal(m.Params, &p)
c.mu.Lock()
handle := c.logWatches[p.Watch]
c.mu.Unlock()
if handle == nil || p.Entry == nil {
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/log names no watch of this bundle"}})
continue
}
go func(id any, e LogEntry) {
if err := handle(e); err != nil {
c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
return
}
c.say(map[string]any{"id": id, "result": map[string]any{}})
}(id, *p.Entry)
case "mesh/provision":
// One change to a value of this module's that this bundle watches (provision.go), named by the
// watch's id, answered once its handler has taken it. Beside the loop, like a tool call.