Compare commits

..
21 Commits
Author SHA1 Message Date
mesh-tools b0d489a86c Merge pull request 'A batch that shares nothing with a running rollout starts beside it (issue 973, step 1)' (#366) from feat/merge-window-on-the-bus into main 2026-10-16 23:11:17 +00:00
jschoubben 6355176fda The ssh-client test offers the forge from another node, as the catalogue's ssh-client now requires forge-account (issue 985)
mesh/merge-gate head 6355176fdab0 merged into trunk 6a62a7e552ea — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 6355176fdab0 merged into trunk 6a62a7e552ea — pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-17 01:00:40 +02:00
jschoubben 300d3c1bf4 Cut a closed batch beside the open walks it shares nothing with, queue a related one with the reason, fold only related waiting walks (issue 973)
mesh/merge-gate head 300d3c1bf466 merged into trunk 6a62a7e552ea — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 300d3c1bf466 merged into trunk 6a62a7e552ea — fail: its merge-check.sh failed: --- FAIL: TestSSHClientOwnsTheOperatorsSSHConfig (0.01s)
mesh/delivery superseded: a newer head of the same pull request
2026-10-17 00:49:53 +02:00
jschoubben baf1501d81 A walk, a retry and a let-go wait only for a started walk they are related to (issue 973) 2026-10-17 00:46:27 +02:00
jschoubben a5ba47e23c Name the open walk a change is related to, own path related to all (issue 973) 2026-10-17 00:45:00 +02:00
mesh-tools 6a62a7e552 Merge pull request 'The merge gate composes each machine with its engine's contract, so moved modules are handed there as on the mesh (issue 969)' (#365) from fix/969-gate-composes-handing into main 2026-10-16 21:53:07 +00:00
fix-969 5890eb104d List the gate's store test so it cannot skip unseen (issue 969)
mesh/merge-gate head 5890eb104da0 merged into trunk 1cdf12df00b5 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 5890eb104da0 merged into trunk 1cdf12df00b5 — pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-16 23:18:14 +02:00
fix-969 262b39ae09 Test the gate's handing end to end: snapshot, store, and the composition either side (issue 969) 2026-10-16 23:16:38 +02:00
fix-969 2f2a8bb57e Compose each machine in the merge gate with its engine's contract, so moved modules are handed there as on the mesh (issue 969) 2026-10-16 23:12:16 +02:00
mesh-tools 1cdf12df00 Merge pull request 'Test the refusal of a jail whose pattern names the host twice' (#364) from fix/959-jail-pattern-test into main 2026-10-16 20:19:49 +00:00
jschoubben f614eeb445 Test the refusal of a jail whose pattern names the host twice
mesh/merge-gate head f614eeb445a1 merged into trunk 53a3c91f53ce — pass: the change touches no module of the mesh's graph
mesh/repo-check head f614eeb445a1 merged into trunk 53a3c91f53ce — pass: its merge-check.sh passed
mesh/delivery delivered
Main refuses such a jail (fail2ban would refuse its whole configuration and
keep no bans), but no test held it: the only one sat untracked in an old
worktree. Issue 959.
2026-10-16 22:04:36 +02:00
mesh-tools 53a3c91f53 Merge pull request 'Feature providers-after-switch-on, step 1: plans keep their route names and name the credential tool (issues 923, 928)' (#363) from feat/providers-after-switch-on into main 2026-10-16 19:12:52 +00:00
fix-ctl-pas df5f01bfa1 Name the credential tool of a software-issued offer in the plan (issue 928)
mesh/merge-gate head df5f01bfa1ee merged into trunk c7cb9f0a2cd3 beside mesh-catalog#337@d2992f81 — pass: builds build-agent, mesh-controller → ace, g14, n…
mesh/repo-check head df5f01bfa1ee merged into trunk c7cb9f0a2cd3 beside mesh-catalog#337@d2992f81 — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/providers-after-switch-on delivered: every member is delivered
2026-10-16 19:35:56 +02:00
fix-ctl-pas 32014ac6de Keep a provision's contributes, at, from, as and names in the sent plans (issue 923) 2026-10-16 19:31:58 +02:00
mesh-tools c7cb9f0a2c Merge pull request 'Give a channel's verified sender back when the root-free judgement passes again (issue 930)' (#362) from fix/930-root-free-recomposes-verified-sender into main 2026-10-16 15:53:20 +00:00
jschoubben 87410d79ed Issue a verified sender again only over the membership that was read, so a newer push is not overwritten (issue 930)
mesh/merge-gate head 87410d79edd6 merged into trunk e231185572ba — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 87410d79edd6 merged into trunk e231185572ba — pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-16 17:46:18 +02:00
jschoubben 1c3c3edc7d Prove on a real bus a lost verified sender is issued again once the judgement passes (issue 930) 2026-10-16 17:43:25 +02:00
jschoubben b2e2bf0638 Give a channel's verified sender back when the root-free judgement passes again, and say when it is dropped (issue 930)
A push composed during a failed judgement issued the router a membership without the verified sender and nothing composed it again, so every approval was refused. The self-check now issues it again within one run; a composition says which kind lost it and why.
2026-10-16 17:42:23 +02:00
mesh-tools e231185572 Merge pull request 'Grant the controller mesh-config's config verb in its own name (issue 925)' (#361) from fix/925-controller-asks-mesh-config into main 2026-10-16 14:46:14 +00:00
mesh-tools b12755c163 Prove on a real bus the controller asks mesh-config in its own name (issue 925)
mesh/merge-gate head b12755c16313 merged into trunk ead062569ed3 — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head b12755c16313 merged into trunk ead062569ed3 — pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-16 14:39:53 +00:00
mesh-tools abf37c7212 Grant the controller mesh-config's config verb in its own name (issue 925)
mesh/merge-gate error: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; the check could not run: killed by hand
mesh/repo-check error: the check could not run: killed by hand
mesh/delivery superseded: a newer head of the same pull request
move-config asks mesh-config.config on the subject that names the
controller as caller; no grant named it, so the bus refused the move.
2026-10-16 14:36:19 +00:00
35 changed files with 1383 additions and 120 deletions
+122 -43
View File
@@ -27,9 +27,10 @@ import (
// commit in it, with at most one commit per repository, the latest of its branch, which contains the earlier
// ones. The walk names every merge it answers.
//
// - One walk at a time. Merges that come while a walk runs join the next batch. A walk that started is never
// superseded; one still waiting for its delivery's word is folded into the next batch's walk, which
// answers every merge it answered — the only supersession left.
// - One walk at a time beside a related one (ADR 0407 decision 4, issue 973). Merges that come while a walk
// runs join the next batch; a closed batch that shares nothing with an open walk is cut beside it. A walk
// that started is never superseded; one still waiting for its delivery's word is folded into the walk of a
// batch related to it, which answers every merge it answered — the only supersession left.
// - A merge heard after a later merge of its branch was put in a walk is answered by that walk: a batch
// still assembling, an open walk that builds everything it moves, or a walk done that did, which then
// names it at once. Otherwise it joins the next batch.
@@ -39,8 +40,9 @@ import (
//
// **A merge waits only for a batch it shares something with** (novox/hq ADR 0304 decision 1, ADR 0306 decisions
// 2 and 3, issue 525): it joins a batch it is related to, and one that is related to none and whose delivery
// group has no member still to merge is a batch whose window is closed at once (batchToJoin). Walks stay one at
// a time: such a batch is cut when no walk is open, and queued behind the one that is otherwise.
// group has no member still to merge is a batch whose window is closed at once (batchToJoin). Such a batch is cut
// at once, beside any open walk it shares nothing with, and queued behind the first one it is related to
// (cutUnrelated).
//
// A merge on the controller's own path (a module whose walk waits for nobody's word) never shares a batch with
// one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds
@@ -340,8 +342,8 @@ func ownPath(touches []inventory.Entry) bool {
// names why): a new batch, which waits for the window, beside the ones it shares nothing with; its words say
// why.
// - none, and no member of its group is still to merge: a new batch whose window is closed at once. Cut at
// once means only that: walks stay one at a time (ADR 0276 decision 4), so with a walk open the batch is
// queued behind it as any closed batch is.
// once means only that: with a walk open that it is related to the batch is queued behind it as any closed
// batch is (ADR 0276 decision 4, ADR 0407 decision 4); beside one it shares nothing with it is cut.
func batchToJoin(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, now time.Time, own bool, waits string) (inventory.Plan, string, error) {
edges, err := inv.Dependencies(ctx)
@@ -406,7 +408,9 @@ func relatedBatches(ctx context.Context, inv *inventory.Inventory, merges []inve
}
carry := newFootprint()
if !own {
// An own-path batch folds no waiting walk (cutBatchesHeld); a catalogue batch may fold every one.
// An own-path batch folds no waiting walk (cutBatchesHeld); a catalogue batch folds those it is related to
// (cutUnrelated). Every one is counted here, which only makes a merge join a batch more readily: it waits
// for the window, never walks beside what it shares something with (issue 973).
plans, err := inv.OpenPlans(ctx)
if err != nil {
return nil, nil, nil, err
@@ -544,6 +548,11 @@ func newBatch(ctx context.Context, inv *inventory.Inventory, first inventory.Bat
// keepBatch writes a batch as its merges and the clock say it now: the commits it holds, the merges they
// answer, its window, and assembling or queued behind a walk. Saved only when that changed.
func keepBatch(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan, now time.Time, behind string) error {
return keepBatchBecause(ctx, inv, b, now, behind, "")
}
// keepBatchBecause is keepBatch for a batch queued behind a walk it is related to, which its words say (issue 973).
func keepBatchBecause(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan, now time.Time, behind, why string) error {
merges, err := inv.MergesOf(ctx, b.ID)
if err != nil {
return err
@@ -574,13 +583,13 @@ func keepBatch(ctx context.Context, inv *inventory.Inventory, b *inventory.Plan,
cutAtOnce = was.CutAtOnce // the merge's, until another joins it (ADR 0304 decision 1)
waits = was.Waits // its group's, as said when it was made (ADR 0306 decision 2)
}
b.Delivery.Batch = &inventory.PlanBatch{ClosesAt: closes, AtMost: latest, Behind: behind, Own: b.OwnPath(),
b.Delivery.Batch = &inventory.PlanBatch{ClosesAt: closes, AtMost: latest, Behind: behind, BehindWhy: why, Own: b.OwnPath(),
Closed: closed, Withheld: withheld, CutAtOnce: cutAtOnce, Waits: waits}
b.State = inventory.PlanAssembling
if behind != "" && windowClosed(*b, now) {
b.State = inventory.PlanQueued
} else {
b.Delivery.Batch.Behind = ""
b.Delivery.Batch.Behind, b.Delivery.Batch.BehindWhy = "", ""
}
b.Note = batchWords(*b, now)
if unNoted(*b) == before {
@@ -761,9 +770,9 @@ func batchCutter(ctx context.Context, open *stores) {
}
// cutBatchesHeld takes the step that is due, for a caller holding the plans: after a failed walk, the search
// for the merge that brought the failure; with no walk open, the next merge of that search walked alone, or a
// closed batch cut into one walk with the waiting walks folded in; with a walk open, a closed batch said
// queued behind it.
// for the merge that brought the failure; with no walk open, the next merge of that search walked alone; else
// each closed batch cut into a walk with the waiting walks it is related to folded in, unless it is related to a
// walk open, behind which it is said queued (cutUnrelated). While the search runs everything waits.
func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
inv := open.inventory
if err := searchAfterFailures(ctx, inv); err != nil {
@@ -780,8 +789,7 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
if err != nil {
return err
}
var started *inventory.Plan
var waiting []inventory.Plan
var started, waiting []inventory.Plan
for i := range plans {
p := plans[i]
if p.Release != nil {
@@ -789,8 +797,8 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
}
if p.Waiting() {
waiting = append(waiting, p)
} else if started == nil {
started = &plans[i]
} else {
started = append(started, p)
}
}
batches, err := inv.Batches(ctx)
@@ -805,46 +813,113 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
}
return nil
}
if started != nil {
// **One walk at a time** (ADR 0276 decision 4): every batch waits behind it, whatever its window says.
return keepAll(started.ID)
}
if len(alone) > 0 {
// A failed walk's search walks its merges alone, one at a time: everything waits for it (kept as it was,
// issue 973).
if len(started) > 0 {
return keepAll(started[0].ID)
}
if len(waiting) > 0 {
// The waiting walk is the one open; the search walks after it, and the batches wait behind both.
return keepAll(waiting[0].ID)
}
return walkAlone(ctx, open, alone[0], now)
}
if err := keepAll(""); err != nil {
return cutUnrelated(ctx, open, batches, started, waiting, now)
}
// cutUnrelated cuts the closed batches that share nothing with an open walk, each into a walk beside the open
// ones, and says the others queued behind the first walk they are related to (novox/hq ADR 0407 decision 4,
// issue 973). A batch cut in this look counts as open for the batches after it.
//
// - An own-path batch goes first and folds no waiting walk; a catalogue batch folds the waiting walks it is
// related to, and those it shares nothing with keep waiting.
// - **The controller's own path is related to everything** (decided for issue 973): a walk or batch that
// replaces the controller, the node-engine, the tool runner, the bus or mesh-delivery never runs beside
// another, and nothing runs beside it.
// - A batch whose window is still open is assembling; one related to an open walk is queued behind it, and
// ADR 0276 decision 4 stands for it: nothing supersedes a walk that started.
func cutUnrelated(ctx context.Context, open *stores, batches, started, waiting []inventory.Plan, now time.Time) error {
inv := open.inventory
sort.SliceStable(batches, func(i, j int) bool { return batches[i].OwnPath() && !batches[j].OwnPath() })
var entries []inventory.Entry
var read map[string][]inventory.ReadRepository
var edges []inventory.Edge
judged := false
openWalks, err := openWalksOf(ctx, inv, started)
if err != nil {
return err
}
// The oldest batch whose window closed is cut, an own-path one before a catalogue one (it goes first, and
// the catalogue batch queues behind it rather than being cut and overtaken). Several of a kind may be open
// since a merge that shares nothing is cut at once (ADR 0304 decision 1): walks stay one at a time, so one
// is cut per look, and once its walk started the others are queued behind it.
sort.SliceStable(batches, func(i, j int) bool { return batches[i].OwnPath() && !batches[j].OwnPath() })
for i := range batches {
b := &batches[i]
if !windowClosed(*b, now) {
continue
}
if b.OwnPath() {
// A walk on the controller's own path folds no walk waiting for its word (decided during the build,
// 2026-10-10): that walk keeps waiting beside it, and starts once this one ended (advanceOnce).
waiting = nil
}
if err := cutBatch(ctx, open, b, waiting, now); err != nil {
return err
}
// The batches left wait behind the walk just cut when it started; the next look does the same.
if b.Open() && !b.Waiting() {
if batches, err = inv.Batches(ctx); err != nil {
if err := keepBatch(ctx, inv, b, now, ""); err != nil {
return err
}
return keepAll(b.ID)
continue
}
if !judged {
var err error
if entries, err = inv.Catalogued(ctx); err != nil {
return err
}
if read, err = readForPlanning(ctx, inv); err != nil {
return err
}
if edges, err = inv.Dependencies(ctx); err != nil {
return err
}
judged = true
}
held, err := inv.MergesOf(ctx, b.ID)
if err != nil {
return err
}
fp := footprintAsCut(held, entries, read, edges)
var fold, keep []inventory.Plan
if !b.OwnPath() {
for _, w := range waiting {
ww, err := openWalkOf(ctx, inv, w)
if err != nil {
return err
}
if ok, _ := related(fp, ww.fp, edges); ok {
fold = append(fold, w)
fp = unionFootprint(fp, ww.fp)
} else {
keep = append(keep, w)
}
}
}
if behind, why := relatedOpen(fp, b.OwnPath(), openWalks, edges); behind != "" {
if err := keepBatchBecause(ctx, inv, b, now, behind, why); err != nil {
return err
}
continue
}
if err := keepBatch(ctx, inv, b, now, ""); err != nil {
return err
}
if err := cutBatch(ctx, open, b, fold, now); err != nil {
return err
}
waiting = keep
cut, err := inv.PlanByID(ctx, b.ID)
if err != nil {
return err
}
if !cut.Open() {
continue
}
w, err := openWalkOf(ctx, inv, cut)
if err != nil {
return err
}
if cut.Waiting() {
waiting = append(waiting, cut)
} else {
openWalks = append(openWalks, w)
}
return nil
}
return nil
}
@@ -1396,7 +1471,11 @@ func batchWords(b inventory.Plan, now time.Time) string {
grouped += " (on the controller's own path)"
}
if b.State == inventory.PlanQueued {
return fmt.Sprintf("queued behind %s; grouped: %s; plan not yet calculated", w.Behind, grouped)
because := ""
if w.BehindWhy != "" {
because = " (" + w.BehindWhy + ")"
}
return fmt.Sprintf("queued behind %s%s; grouped: %s; plan not yet calculated", w.Behind, because, grouped)
}
if w.CutAtOnce {
return fmt.Sprintf("cut at once, sharing nothing with a batch assembling; grouped: %s; plan not yet calculated",
+9 -9
View File
@@ -334,8 +334,8 @@ func captured(t *testing.T, run func() error) string {
return b.String()
}
// One walk at a time: a merge heard while a walk runs joins the next batch, which is cut when the walk ends;
// the walk that started is never superseded.
// A merge heard during a walk it is related to (the same repository, issue 973) joins the next batch, which is
// cut when the walk ends; the walk that started is never superseded (ADR 0276 decision 4, ADR 0407 decision 4).
func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
@@ -346,7 +346,7 @@ func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) {
t.Fatalf("the first batch was not cut: %+v", ws)
}
first := ws[0]
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
hear(t, open, repoMerge("one", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
cutAt(t, open, t0.Add(5*time.Minute))
ws, bs := walks(t, open)
if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID {
@@ -391,7 +391,7 @@ func TestAWaitingWalkIsFoldedIntoTheNextBatch(t *testing.T) {
t.Fatalf("the walk does not wait for its word: %+v", ws)
}
waiting := ws[0]
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
hear(t, open, repoMerge("one", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
cutAt(t, open, t0.Add(2*time.Minute+90*time.Second))
ws, bs := walks(t, open)
if len(bs) != 0 || len(ws) != 2 {
@@ -405,7 +405,7 @@ func TestAWaitingWalkIsFoldedIntoTheNextBatch(t *testing.T) {
if folded.State != inventory.PlanSuperseded || folded.Delivery.TakenOverBy != newer.ID {
t.Fatalf("the waiting walk is %s, taken over by %q", folded.State, folded.Delivery.TakenOverBy)
}
if len(newer.Delivery.Merges) != 2 || newer.CommitOf("novox/one") != "c1" || newer.CommitOf("novox/two") != "c2" {
if len(newer.Delivery.Merges) != 2 || newer.CommitOf("novox/one") != "c2" {
t.Fatalf("the newer walk answers %+v", newer.Delivery.Merges)
}
if _, in := newer.Modules["one"]; !in {
@@ -662,8 +662,8 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
}
}
// One walk at a time holds against the delivery's word too: a waiting walk let go beside a started one takes the
// word and starts once that one ended, asking nothing before.
// A related walk waits for the delivery's word too: a waiting walk let go beside a started one it is related to
// (the same repository, issue 973) takes the word and starts once that one ended, asking nothing before.
func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
open := windowed(t)
asked := asksWithPaths(t)
@@ -671,8 +671,8 @@ func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(2*time.Minute))
started, _ := walks(t, open)
waiting := inventory.Plan{ID: "plan-waiting", Repository: "novox/two", Branch: "main", Commit: "d1", Created: t0,
State: inventory.PlanBuilding, Tiers: [][]string{{"two"}}, Modules: map[string]*inventory.PlanModule{"two": {}},
waiting := inventory.Plan{ID: "plan-waiting", Repository: "novox/one", Branch: "main", Commit: "d1", Created: t0,
State: inventory.PlanBuilding, Tiers: [][]string{{"one"}}, Modules: map[string]*inventory.PlanModule{"one": {}},
Delivery: &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}}
if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
t.Fatal(err)
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// An unrelated closed batch is cut into a walk beside an open one, and that walk asks its first tier at once
// (issue 973, novox/hq ADR 0407 decision 4): a hotfix is never held by an unrelated delivery.
func TestAnUnrelatedBatchWalksBesideAnOpenWalk(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
ctx := t.Context()
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(90*time.Second))
if ws, _ := walks(t, open); len(ws) != 1 || !ws[0].Open() {
t.Fatalf("the first walk is not open: %+v", ws)
}
heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("two", "c2", heard)), heard)
advanceHeld(ctx, open)
ws, bs := walks(t, open)
if len(ws) != 2 || len(bs) != 0 {
t.Fatalf("%d walk(s) and %d batch(es); want the unrelated batch cut beside the open walk", len(ws), len(bs))
}
for _, w := range ws {
if !w.Open() || w.Waiting() || deferred(w) || !askedAny(w) {
t.Errorf("%s (%s) is not walking: open %v, deferred %v, asked %v, note %q", w.ID, w.Named(), w.Open(),
deferred(w), askedAny(w), w.Note)
}
}
}
// A closed batch related to an open walk is queued behind it with the reason, while one that shares nothing is
// cut beside it; the walk that started is never superseded (ADR 0276 decision 4, issue 973).
func TestARelatedBatchWaitsBehindTheOpenWalk(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
ctx := t.Context()
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(90*time.Second))
first := func() inventory.Plan { ws, _ := walks(t, open); return ws[len(ws)-1] }()
heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("one", "c2", heard)), heard)
hear(t, open, lone(repoMerge("three", "c3", heard.Add(time.Second))), heard.Add(time.Second))
ws, bs := walks(t, open)
if len(ws) != 2 || len(bs) != 1 {
t.Fatalf("%d walk(s) and %d batch(es); want the unrelated merge walking and the related one queued", len(ws), len(bs))
}
b := bs[0]
words := batchWords(b, heard)
if b.State != inventory.PlanQueued || b.Delivery.Batch.Behind != first.ID || b.CommitOf("novox/one") != "c2" ||
!strings.Contains(words, "queued behind "+first.ID) || !strings.Contains(words, "both change novox/one") {
t.Fatalf("the related batch is %s, behind %q, reading %q", b.State, b.Delivery.Batch.Behind, words)
}
if got, _ := open.inventory.PlanByID(ctx, first.ID); !got.Open() {
t.Fatalf("the started walk was %s by the related merge", got.State)
}
first.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &first); err != nil {
t.Fatal(err)
}
cutAt(t, open, heard.Add(2*time.Second))
if _, bs := walks(t, open); len(bs) != 0 {
t.Fatalf("the queued batch was not cut once the walk it waited for ended: %+v", bs)
}
}
// A batch on the controller's own path never walks beside another walk, and nothing walks beside it, whatever
// the two share (issue 973): it replaces the controller, so it goes alone.
func TestAnOwnPathBatchNeverWalksBeside(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
ctx := t.Context()
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(90*time.Second))
ws, _ := walks(t, open)
first := ws[0]
heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("mesh-controller", "k1", heard)), heard)
ws, bs := walks(t, open)
if len(ws) != 1 || len(bs) != 1 || !bs[0].OwnPath() || bs[0].State != inventory.PlanQueued ||
bs[0].Delivery.Batch.Behind != first.ID || !strings.Contains(batchWords(bs[0], heard), "own path") {
t.Fatalf("an own-path batch beside an open walk: %d walk(s), batches %+v", len(ws), bs)
}
first.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &first); err != nil {
t.Fatal(err)
}
cutAt(t, open, heard.Add(time.Second))
ws, _ = walks(t, open)
if len(ws) != 2 || !ws[0].Open() || ws[0].CommitOf("novox/mesh-controller") != "k1" {
t.Fatalf("the own-path batch was not cut once the walk ended: %+v", ws)
}
own := ws[0]
// Unrelated to the own-path walk by every footprint, a merge still waits for it.
later := heard.Add(time.Minute)
hear(t, open, lone(repoMerge("two", "c2", later)), later)
ws, bs = walks(t, open)
if len(ws) != 2 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != own.ID {
t.Fatalf("a merge beside the own-path walk: %d walk(s), batches %+v", len(ws), bs)
}
}
+3 -2
View File
@@ -136,8 +136,9 @@ func letGo(ctx context.Context, inv *inventory.Inventory, id, by, why string) (i
return p, fmt.Errorf("%s was let go by %s at %s already", p.ID, p.Delivery.By,
p.Delivery.Go.Local().Format("15:04:05"))
}
// **One walk at a time** (novox/hq ADR 0276): the word is taken, and the walk starts once no other walk is
// started (advanceOnce), so the delivery's owner says it once and is not refused.
// **One walk at a time beside a related one** (novox/hq ADR 0276, ADR 0407 decision 4, issue 973): the word is
// taken, and the walk starts once no started walk it is related to is open (advanceOnce), so the delivery's
// owner says it once and is not refused.
now := time.Now().UTC()
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
p.Note = "let go by " + by + "; its first tier is asked next"
+6
View File
@@ -287,6 +287,12 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
return snapshot.Facts{}, err
}
m.Architecture, m.Kernel = reported.Architecture, reported.Kernel
// Whether its node-engine takes handed files is its contract (issue 969).
if stated, had, err := inv.HealthOf(ctx, n.Name); err != nil {
return snapshot.Facts{}, err
} else if had {
m.Contract = stated.Contract
}
outward, err := inv.OutwardLinksOf(ctx, n.Name)
if err != nil {
return snapshot.Facts{}, err
+6 -4
View File
@@ -227,8 +227,9 @@ func TestAMergeRelatedToTwoBatchesFoldsThem(t *testing.T) {
}
}
// With a walk open, a batch cut at once still waits for it, said queued behind it (walks stay one at a time, ADR
// 0276 decision 4); it is cut once that walk ended, with no window to wait for.
// With a walk open that it is related to (the same repository, issue 973), a batch cut at once still waits for
// it, said queued behind it (ADR 0276 decision 4, ADR 0407 decision 4); it is cut once that walk ended, with no
// window to wait for.
func TestACutAtOnceBatchQueuesBehindAnOpenWalk(t *testing.T) {
open := windowed(t)
groupsWaiting(t, "pr/c1")
@@ -241,7 +242,7 @@ func TestACutAtOnceBatchQueuesBehindAnOpenWalk(t *testing.T) {
}
first := ws[0]
heard := t0.Add(100 * time.Second)
hear(t, open, lone(repoMerge("two", "c2", heard)), heard)
hear(t, open, lone(repoMerge("one", "c2", heard)), heard)
ws, bs := walks(t, open)
if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID ||
!bs[0].Delivery.Batch.CutAtOnce {
@@ -280,7 +281,8 @@ func TestAStopWalksTheOthersAgainInABatchOfTheirOwnWhenTheyShareNothing(t *testi
cutAt(t, open, t0.Add(2*time.Minute))
ws, _ := walks(t, open)
walk := ws[0]
hear(t, open, repoMerge("two", "two1cccccccc", t0.Add(3*time.Minute)), t0.Add(3*time.Minute))
// Related to the walk by its repository (queued behind it), and to nothing the stop walks again.
hear(t, open, catalogueMerge("cat2cccccccc", "notes", t0.Add(3*time.Minute)), t0.Add(3*time.Minute))
_, bs := walks(t, open)
if len(bs) != 1 {
t.Fatalf("the merge heard while the walk ran is in %d batches", len(bs))
+16
View File
@@ -1017,6 +1017,13 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
"kernel": m.Kernel, "capabilities": caps}); err != nil {
return notes, err
}
// What its node-engine takes, as the mesh composes it today (issue 969): without the statement the gate
// composes a moved module the old way on a machine the mesh hands it to.
if stated, ok := standInHealth(m, f.Taken); ok {
if _, err := inv.RecordHealth(ctx, stated); err != nil {
return notes, err
}
}
for _, record := range []func(context.Context, string, string) error{inv.RecordSealingKey, inv.RecordOverlayKey} {
key, err := standInKey()
if err != nil {
@@ -1355,3 +1362,12 @@ func firstOr(items, otherwise []string) string {
}
return "it says nothing more"
}
// standInHealth is the statement the gate gives a machine of the snapshot so that it composes as the mesh does:
// only its contract, which says whether it takes handed files (issue 969). False for a snapshot that carries none.
func standInHealth(m snapshot.Machine, taken time.Time) (inventory.NodeHealth, bool) {
if m.Contract == 0 {
return inventory.NodeHealth{}, false
}
return inventory.NodeHealth{Node: m.Name, Contract: m.Contract, SaidAt: taken, HeardAt: taken}, true
}
@@ -0,0 +1,85 @@
package main
import (
"os"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
snapshot "github.com/novox/mesh-controller/internal/facts"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// Issue 969: the gate composes a machine as the mesh does, so a moved module is handed where the machine takes
// handed files and composed the old way where it does not.
func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing.T) {
taken := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
stated, ok := standInHealth(snapshot.Machine{Name: "f35", Contract: link.HandedContract}, taken)
if !ok || !takesHanded(stated, ok) {
t.Fatalf("a machine whose engine takes handed files must be given that in the gate: %+v, %v", stated, ok)
}
old, ok := standInHealth(snapshot.Machine{Name: "e12", Contract: link.HandedContract - 1}, taken)
if !ok || takesHanded(old, ok) {
t.Fatalf("a machine whose engine is older must not take handed files: %+v, %v", old, ok)
}
none, ok := standInHealth(snapshot.Machine{Name: "d01"}, taken)
if ok || takesHanded(none, ok) {
t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok)
}
}
// Issue 969, through the store: a machine whose engine stated the handed contract is in the snapshot with it, the
// gate raises it as taking handed files, and one whose statement is below it is raised as not taking them. Run in
// the merge check with the other tests that need a store; the composition itself, handed here and refused there, is
// the catalogue's TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded.
func TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
said := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
for node, contract := range map[string]int{"anchor": link.HandedContract, "laptop": link.HandedContract - 1} {
if _, err := open.inventory.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: contract,
SaidAt: said, HeardAt: said}); err != nil {
t.Fatal(err)
}
}
f, err := gatherFacts(ctx, open, "2.11.17")
if err != nil {
t.Fatal(err)
}
if len(f.Machines) != 2 {
t.Fatalf("the snapshot holds %d machines", len(f.Machines))
}
want := map[string]bool{}
for _, m := range f.Machines {
if m.Contract == 0 {
t.Fatalf("%s: the snapshot carries no contract", m.Name)
}
want[m.Name] = m.Contract >= link.HandedContract
}
handing := 0
for _, hands := range want {
if hands {
handing++
}
}
if handing != 1 {
t.Fatalf("one machine takes handed files in the snapshot, got %v", want)
}
raised, drop, err := throwawayStores(ctx, os.Getenv("MESH_TEST_POSTGRES"))
if err != nil {
t.Fatal(err)
}
defer drop()
if _, err := raiseFromFacts(ctx, raised, f, map[string]catalogue.Manifest{}, map[string]inventory.Source{}); err != nil {
t.Fatal(err)
}
for name, hands := range want {
got, err := engineTakesHanded(ctx, raised.inventory, name)
if err != nil || got != hands {
t.Errorf("%s: the gate raises it as taking handed files = %v (%v), the mesh says %v", name, got, err, hands)
}
}
}
+7 -1
View File
@@ -1126,7 +1126,13 @@ func engineTakesHanded(ctx context.Context, inv *inventory.Inventory, node strin
if err != nil {
return false, err
}
return had && stated.Contract >= link.HandedContract, nil
return takesHanded(stated, had), nil
}
// takesHanded is whether a machine's newest statement (had: it ever stated one) is of an engine that writes a
// filled file on the provisioner client's word alone.
func takesHanded(stated inventory.NodeHealth, had bool) bool {
return had && stated.Contract >= link.HandedContract
}
// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 §4): the user
+15 -4
View File
@@ -197,12 +197,23 @@ func retryRefusal(p inventory.Plan, plans []inventory.Plan) error {
return oneWalkAtATime(p, plans)
}
// oneWalkAtATime refuses a retry while another walk is open, started or waiting for its word (novox/hq ADR
// 0276): a walk retried beside it would be two walks at once.
// oneWalkAtATime refuses a retry while an open walk, started or waiting for its word, is related to it (novox/hq
// ADR 0276, ADR 0407 decision 4, issue 973): a walk retried beside a related one would be two walks on the same
// things at once. A walk related to none may be retried beside them. The retried walk is judged by the modules it
// plans, which already hold their dependents, and the repositories it carries.
func oneWalkAtATime(p inventory.Plan, plans []inventory.Plan) error {
mine := footprintOfWalk(p, nil)
for _, q := range plans {
if q.ID != p.ID && q.Open() && q.Release == nil {
return fmt.Errorf("%s is open (%s): one walk at a time — retry %s once it ended", q.ID, q.Named(), p.ID)
if q.ID == p.ID || !q.Open() || q.Release != nil {
continue
}
if ownWalk(p) || ownWalk(q) {
return fmt.Errorf("%s is open (%s) and one of them is on the controller's own path: one walk at a time — "+
"retry %s once it ended", q.ID, q.Named(), p.ID)
}
if ok, why := related(mine, footprintOfWalk(q, nil), nil); ok {
return fmt.Errorf("%s is open (%s) and %s: one walk at a time beside a related one — retry %s once it ended",
q.ID, q.Named(), why, p.ID)
}
}
return nil
+5 -12
View File
@@ -320,7 +320,11 @@ func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, e
quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now)
return err == nil && quiet
}
return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil
obs := rootObservations(ctx, r, trustedMachines(r.entries), time.Now())
// A membership that lost its verified sender to a judgement that failed at a push gets it back here, once
// the machines pass (issue 930); the bound is one self-check.
giveVerifiedSendersBack(ctx, d)
return obs, nil
}
// rootObservations judges the machines and says each that fails.
@@ -365,14 +369,3 @@ func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, e
}
return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil
}
// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass.
func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool {
free := map[string]bool{}
for _, v := range judgeRootFree(ctx, r, machines, now) {
if v.Free {
free[v.Machine] = true
}
}
return free
}
+7 -1
View File
@@ -1333,8 +1333,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
}
// Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the
// router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement.
records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now())
var whys map[string]string
records.RootFree, whys = rootVerdictsOf(judgeRootFree(ctx, newRootReader(ctx, open.inventory, bus.Conn),
records.Nodes, time.Now()))
where := broker.PlacementsOf(records, records.Interchangeable)
// What the judgement kept off is said, with its reason (novox/hq issue 930); the self-check gives it back.
for _, line := range droppedVerifiedSenders(records, where, whys) {
fmt.Println(line)
}
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
// raise at start asserts them too, but a module registered and assigned since would otherwise have
// its bucket only after the control plane next restarts — found the first time a module declared
@@ -0,0 +1,224 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/link"
)
// A channel's verified sender is composed into a membership only while the machines are judged root-free at
// that moment (novox/hq ADR 0259 §8), and the judgement fails closed when a read fails (novox/hq issue 930):
// a push composed during a failed read issued the router a membership without it, and nothing composed it
// again, so every approval was refused until somebody pushed. Two things follow.
//
// What a composition drops it says (droppedVerifiedSenders), with the reason the judgement gave.
//
// And the self-check issues it again (reissueVerifiedSenders): each time it judges the machines, a membership
// that lacks a verified sender the composition would now give it is issued again with only its kinds changed.
// **Bound: one self-check, doctorEvery (five minutes), after the judgement passes.** It only ever adds: a
// machine that stops passing keeps what it was issued, as at a push, because the router asks the root-free
// verb again before it honours an approval, and that is the check that holds.
// droppedVerifiedSenders says, one line each, every kind that declares `verified-sender` and was composed
// without it, naming the kind, its machine and why. whys is each machine's verdict reason (rootVerdict.Why).
func droppedVerifiedSenders(r broker.Records, where broker.Placements, whys map[string]string) []string {
var routerNodes []string
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
routerNodes = append(routerNodes, node)
}
}
}
}
sort.Strings(routerNodes)
var out []string
for _, k := range where.Kinds {
declared := false
for _, d := range r.Assigned[k.Node] {
for _, s := range d.Holds {
if d.Module == k.Module && s.Name == k.Seat && s.Kind == k.Kind && slices.Contains(s.Capabilities, "verified-sender") {
declared = true
}
}
}
if !declared || slices.Contains(k.Capabilities, "verified-sender") {
continue
}
var why string
switch {
case !r.RootFree[k.Node]:
why = k.Node + " is not judged root-free: " + whys[k.Node]
case len(routerNodes) != 1:
why = fmt.Sprintf("the router is placed on %d machines, not one", len(routerNodes))
case !r.RootFree[routerNodes[0]]:
why = "the router's machine " + routerNodes[0] + " is not judged root-free: " + whys[routerNodes[0]]
default:
why = k.Module + " does not run as an account of its own"
}
out = append(out, fmt.Sprintf(" %s kind %s of %s on %s is composed without its verified sender: %s",
k.Seat, k.Kind, k.Module, k.Node, why))
}
return out
}
// withVerifiedSenders is a membership as issued with its seat traffic's kinds replaced by the composed ones,
// and whether that changed it: only when a composed kind carries a verified sender the issued one lacks. Pure.
func withVerifiedSenders(issued []byte, composed *broker.SeatTraffic) ([]byte, bool, error) {
if composed == nil {
return issued, false, nil
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(issued, &fields); err != nil {
return nil, false, err
}
var traffic map[string]json.RawMessage
var had []broker.KindHeld
if raw, ok := fields["seat-traffic"]; ok {
if err := json.Unmarshal(raw, &traffic); err != nil {
return nil, false, err
}
if raw, ok := traffic["kinds"]; ok {
if err := json.Unmarshal(raw, &had); err != nil {
return nil, false, err
}
}
}
gains := false
for _, k := range composed.Kinds {
if !slices.Contains(k.Capabilities, "verified-sender") {
continue
}
held := false
for _, h := range had {
if h.Seat == k.Seat && h.Kind == k.Kind && h.Module == k.Module && h.Node == k.Node &&
slices.Contains(h.Capabilities, "verified-sender") {
held = true
}
}
gains = gains || !held
}
if !gains {
return issued, false, nil
}
if traffic == nil {
traffic = map[string]json.RawMessage{}
}
raw, err := json.Marshal(composed.Kinds)
if err != nil {
return nil, false, err
}
traffic["kinds"] = raw
if fields["seat-traffic"], err = json.Marshal(traffic); err != nil {
return nil, false, err
}
body, err := json.Marshal(fields)
return body, true, err
}
// reissueVerifiedSenders issues again each membership that lacks a verified sender the records now give it:
// read as last issued, its kinds replaced, published only if no newer one was issued since (a push runs beside
// the self-check, and its newer membership must not be overwritten by the older fields read here). One never issued is left to its own send. Failures are
// said and passed over; the next self-check tries again.
func reissueVerifiedSenders(r broker.Records, where broker.Placements,
last func(node, module string) ([]byte, uint64, error), publish func(node, module string, body []byte, seq uint64) error,
say func(format string, args ...any)) int {
issued := 0
for node, declared := range r.Assigned {
for _, d := range declared {
composed := broker.MembershipFor(node, d, where).SeatTraffic
if composed == nil || !slices.ContainsFunc(composed.Kinds, func(k broker.KindHeld) bool {
return slices.Contains(k.Capabilities, "verified-sender")
}) {
continue
}
was, seq, err := last(node, d.Module)
if err != nil {
if !errors.Is(err, nats.ErrMsgNotFound) {
say(" %s on %s: its membership could not be read to give its verified sender back: %v", d.Module, node, err)
}
continue
}
body, changed, err := withVerifiedSenders(was, composed)
if err != nil {
say(" %s on %s: its membership could not be read to give its verified sender back: %v", d.Module, node, err)
continue
}
if !changed {
continue
}
if err := publish(node, d.Module, body, seq); err != nil {
// A push issued another since it was read: that one is newer, and the next self-check looks again.
if strings.Contains(err.Error(), "wrong last sequence") {
say(" %s on %s: a newer membership was issued meanwhile; its verified sender is looked at again next time",
d.Module, node)
continue
}
say(" %s on %s: its verified sender could not be issued again: %v", d.Module, node, err)
continue
}
say(" %s on %s: issued its membership again, with the verified sender its machines now pass for", d.Module, node)
issued++
}
}
return issued
}
// rootVerdictsOf is each machine's verdict reason, for saying why one was not free.
func rootVerdictsOf(verdicts []rootVerdict) (free map[string]bool, whys map[string]string) {
free, whys = map[string]bool{}, map[string]string{}
for _, v := range verdicts {
if v.Free {
free[v.Machine] = true
}
whys[v.Machine] = strings.TrimSpace(v.Why)
}
return free, whys
}
// giveVerifiedSendersBack is the self-check's step: every machine judged, the memberships that lost a verified
// sender to a failed judgement issued again. Never fails the probe: what could not be done is said.
func giveVerifiedSendersBack(ctx context.Context, d *doctor) {
if d.js == nil || d.open == nil || d.open.inventory == nil {
return
}
records, err := d.open.inventory.BusRecords(ctx)
if err != nil {
fmt.Printf(" the memberships could not be read to give a verified sender back: %v\n", err)
return
}
// Judged for every machine the records name, as a push does, not only those the self-check judged.
r := newRootReader(ctx, d.open.inventory, d.js.Conn())
all := judgeRootFree(ctx, r, records.Nodes, time.Now())
records.RootFree, _ = rootVerdictsOf(all)
where := broker.PlacementsOf(records, records.Interchangeable)
reissueVerifiedSendersOn(ctx, link.OverNATS{Conn: d.js.Conn(), JS: d.js.Context()}, records, where)
}
// reissueVerifiedSendersOn is reissueVerifiedSenders on the bus: memberships read from the AssignmentsStream and
// published to it.
func reissueVerifiedSendersOn(ctx context.Context, bus link.OverNATS, records broker.Records, where broker.Placements) int {
return reissueVerifiedSenders(records, where,
func(node, module string) ([]byte, uint64, error) {
m, err := bus.JS.GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject(node, module))
if err != nil {
return nil, 0, err
}
return m.Data, m.Sequence, nil
},
func(node, module string, body []byte, seq uint64) error {
return bus.PublishMembershipAfter(ctx, node, module, body, seq)
},
func(format string, args ...any) { fmt.Printf(format+"\n", args...) })
}
@@ -0,0 +1,187 @@
package main
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/testbus"
)
func verifiedSenderRecords(free map[string]bool) (broker.Records, broker.Declared) {
tg := broker.Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show"}, Kinded: true, Kind: "telegram",
DeclaredBy: "messenger", Capabilities: []string{"choice", "verified-sender"}}
router := broker.Declared{Module: "messenger", RunsAs: "messenger", Holds: []broker.Seat{{Name: "operator-channel",
Scope: "mesh", Accepts: []string{"ask"}}}, Uses: []broker.Seat{{Name: "channel", Scope: "mesh", Kinded: true,
DeclaredBy: "messenger"}}}
return broker.Records{Nodes: []string{"novox"}, RootFree: free, Assigned: map[string][]broker.Declared{
"novox": {router, {Module: "telegram", RunsAs: "telegram", Holds: []broker.Seat{tg}}}}}, router
}
type fakeBus struct {
last map[string][]byte
published map[string][]byte
seq map[string]uint64
// between runs once, after a read and before the write: a push issuing meanwhile.
between func()
}
func (f *fakeBus) get(node, module string) ([]byte, uint64, error) {
if b, ok := f.last[node+"/"+module]; ok {
return b, f.seq[node+"/"+module], nil
}
return nil, 0, nats.ErrMsgNotFound
}
func (f *fakeBus) put(node, module string, body []byte, seq uint64) error {
if f.between != nil {
f.between()
f.between = nil
}
if seq != f.seq[node+"/"+module] {
return errors.New("nats: wrong last sequence")
}
f.published[node+"/"+module] = body
f.last[node+"/"+module] = body
f.seq[node+"/"+module]++
return nil
}
func hasVerified(t *testing.T, membership []byte) bool {
t.Helper()
var m broker.Membership
if err := json.Unmarshal(membership, &m); err != nil {
t.Fatal(err)
}
for _, k := range m.SeatTraffic.Kinds {
if k.Kind == "telegram" {
return strings.Contains(strings.Join(k.Capabilities, ","), "verified-sender")
}
}
t.Fatal("telegram not in the router's membership")
return false
}
// A membership composed while the judgement failed is issued again once it passes, with the verified sender
// (novox/hq issue 930); while it fails, nothing is given back (ADR 0259 §8).
func TestAMembershipThatLostItsVerifiedSenderGetsItBackWhenTheJudgementPasses(t *testing.T) {
failed, router := verifiedSenderRecords(nil)
whereFailed := broker.PlacementsOf(failed, nil)
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
body, _ := json.Marshal(broker.MembershipFor("novox", router, whereFailed))
bus.last["novox/messenger"] = body
if hasVerified(t, body) {
t.Fatal("composed with a failed judgement, and the verified sender is kept")
}
say := func(string, ...any) {}
if n := reissueVerifiedSenders(failed, whereFailed, bus.get, bus.put, say); n != 0 || len(bus.published) != 0 {
t.Fatalf("issued %d while the judgement still fails", n)
}
passing, _ := verifiedSenderRecords(map[string]bool{"novox": true})
wherePassing := broker.PlacementsOf(passing, nil)
if n := reissueVerifiedSenders(passing, wherePassing, bus.get, bus.put, say); n != 1 {
t.Fatalf("issued %d memberships after the judgement passed, want 1", n)
}
if !hasVerified(t, bus.published["novox/messenger"]) {
t.Fatal("the membership issued again lacks the verified sender")
}
if n := reissueVerifiedSenders(passing, wherePassing, bus.get, bus.put, say); n != 0 {
t.Fatalf("issued %d again with nothing changed", n)
}
}
// What a composition drops it says, naming the kind, the machine and the judgement's reason.
func TestADroppedVerifiedSenderIsSaidWithItsReason(t *testing.T) {
records, _ := verifiedSenderRecords(nil)
where := broker.PlacementsOf(records, nil)
lines := droppedVerifiedSenders(records, where, map[string]string{"novox": "not measured: no verb heard"})
if len(lines) != 1 || !strings.Contains(lines[0], "telegram") || !strings.Contains(lines[0], "novox") ||
!strings.Contains(lines[0], "not measured: no verb heard") {
t.Fatalf("said %q", lines)
}
records, _ = verifiedSenderRecords(map[string]bool{"novox": true})
if lines := droppedVerifiedSenders(records, broker.PlacementsOf(records, nil), nil); len(lines) != 0 {
t.Fatalf("said %q of a kind that kept its verified sender", lines)
}
}
func TestAMembershipNeverIssuedIsLeftToItsOwnSend(t *testing.T) {
records, _ := verifiedSenderRecords(map[string]bool{"novox": true})
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
var said []string
n := reissueVerifiedSenders(records, broker.PlacementsOf(records, nil), bus.get, bus.put,
func(f string, a ...any) { said = append(said, f) })
if n != 0 || len(said) != 0 {
t.Fatalf("issued %d, said %v", n, said)
}
}
// **On a real bus** (novox/hq issue 930): a router's membership issued without the verified sender because the
// judgement failed is, once the judgement passes, published again to the AssignmentsStream with it in its seat
// traffic's kinds, and a second pass publishes nothing.
func TestNatsAMembershipIssuedWithoutItsVerifiedSenderIsIssuedAgainWhenTheJudgementPasses(t *testing.T) {
js, err := broker.Dial(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(js.Close)
if err := broker.AssertMeshStreams(js); err != nil {
t.Fatal(err)
}
bus := link.OverNATS{Conn: js.Conn(), JS: js.Context()}
failed, router := verifiedSenderRecords(nil)
body, err := json.Marshal(broker.MembershipFor("novox", router, broker.PlacementsOf(failed, nil)))
if err != nil {
t.Fatal(err)
}
if err := bus.PublishMembership(t.Context(), "novox", "messenger", body); err != nil {
t.Fatal(err)
}
issued := func() []byte {
m, err := js.Context().GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject("novox", "messenger"))
if err != nil {
t.Fatal(err)
}
return m.Data
}
if hasVerified(t, issued()) {
t.Fatal("issued with a failed judgement, and the verified sender is kept: the test proves nothing")
}
if n := reissueVerifiedSendersOn(t.Context(), bus, failed, broker.PlacementsOf(failed, nil)); n != 0 {
t.Fatalf("issued %d memberships while the judgement still fails", n)
}
passing, _ := verifiedSenderRecords(map[string]bool{"novox": true})
where := broker.PlacementsOf(passing, nil)
if n := reissueVerifiedSendersOn(t.Context(), bus, passing, where); n != 1 {
t.Fatalf("issued %d memberships after the judgement passed, want 1", n)
}
if !hasVerified(t, issued()) {
t.Fatal("the membership on the bus lacks the verified sender after the judgement passed")
}
if n := reissueVerifiedSendersOn(t.Context(), bus, passing, where); n != 0 {
t.Fatalf("a second pass issued %d memberships", n)
}
}
// A push issuing a newer membership between the self-check's read and its write is not overwritten (issue 930).
func TestANewerMembershipIssuedMeanwhileIsNotOverwritten(t *testing.T) {
failed, router := verifiedSenderRecords(nil)
bus := &fakeBus{last: map[string][]byte{}, published: map[string][]byte{}, seq: map[string]uint64{}}
old, _ := json.Marshal(broker.MembershipFor("novox", router, broker.PlacementsOf(failed, nil)))
bus.last["novox/messenger"], bus.seq["novox/messenger"] = old, 1
newer := []byte(`{"node":"novox","module":"messenger","newer":true}`)
bus.between = func() { bus.last["novox/messenger"], bus.seq["novox/messenger"] = newer, 2 }
passing, _ := verifiedSenderRecords(map[string]bool{"novox": true})
if n := reissueVerifiedSenders(passing, broker.PlacementsOf(passing, nil), bus.get, bus.put, func(string, ...any) {}); n != 0 {
t.Fatalf("issued %d over a newer membership", n)
}
if string(bus.last["novox/messenger"]) != string(newer) || len(bus.published) != 0 {
t.Fatalf("the newer membership was overwritten: %s", bus.last["novox/messenger"])
}
}
+85 -1
View File
@@ -1,8 +1,10 @@
package main
import (
"context"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
@@ -11,7 +13,9 @@ import (
// related when they share a repository, a module, a delivery group's head, or an edge of the module graph
// between their modules. A module is every one the walk builds or sends: the modules its merges moved and the
// modules that depend on them, not only the ones the merges touched. Unrelated changes may walk beside each
// other (ADR 0304 decision 2, ADR 0282 decision 3); nothing walks so yet, and nothing but tests reads this.
// other (ADR 0304 decision 2, ADR 0282 decision 3); a closed batch is cut beside an open walk it is
// not related to, and a walk about to ask its first tier waits only for a started walk it is related to (issue 973,
// ADR 0407 decision 4).
// footprint is what one change reaches: its repositories (lower case), its modules, and its delivery group
// heads (the head branch names of its merges). unknown says a merge of it was kept without the modules it
@@ -109,3 +113,83 @@ func firstShared(a, b map[string]bool) string {
sort.Strings(both)
return both[0]
}
// openWalk is a walk open beside a change: its id, what it reaches, and whether it is on the controller's own path.
type openWalk struct {
id string
fp footprint
own bool
}
// relatedOpen is the first open walk a change is related to, with the reason; empty when it is related to none
// and may walk beside them all (issue 973, ADR 0407 decision 4, ADR 0306 decision 3).
//
// **The controller's own path is related to everything** (decided for issue 973): a walk or batch that replaces
// the controller, the node-engine, the tool runner, the bus or mesh-delivery never runs beside another, and
// nothing runs beside it, whatever the footprints say.
func relatedOpen(cand footprint, candOwn bool, open []openWalk, edges []inventory.Edge) (behind, why string) {
for _, w := range open {
if candOwn {
return w.id, "it is on the controller's own path"
}
if w.own {
return w.id, w.id + " is on the controller's own path"
}
if ok, why := related(cand, w.fp, edges); ok {
return w.id, why
}
}
return "", ""
}
// ownWalk says a walk is on the controller's own path: its batch says so, or it plans a module of that path
// (issue 973).
func ownWalk(p inventory.Plan) bool {
if p.OwnPath() {
return true
}
for name := range p.Modules {
if _, own := onTheControllersPath[name]; own {
return true
}
}
return false
}
// startedWalks are the open walks started beside the walk id, made at created: a merge's walk past its wait, not
// the backlog's, with a tier asked, or on its way (let go, or waiting for nobody) before this one.
func startedWalks(plans []inventory.Plan, id string, created time.Time) []inventory.Plan {
var out []inventory.Plan
for _, q := range plans {
if q.ID == id || q.Release != nil || q.Waiting() {
continue
}
if q.Tier > 0 || askedAny(q) || q.Created.Before(created) {
out = append(out, q)
}
}
return out
}
// openWalkOf is an open walk as a change is judged against it: what it reaches, from its planned modules and the
// merges the record holds for it.
func openWalkOf(ctx context.Context, inv *inventory.Inventory, p inventory.Plan) (openWalk, error) {
kept, err := inv.MergesOf(ctx, p.ID)
if err != nil {
return openWalk{}, err
}
return openWalk{id: p.ID, fp: footprintOfWalk(p, kept), own: ownWalk(p)}, nil
}
// openWalksOf are openWalkOf for each of plans.
func openWalksOf(ctx context.Context, inv *inventory.Inventory, plans []inventory.Plan) ([]openWalk, error) {
var out []openWalk
for _, p := range plans {
w, err := openWalkOf(ctx, inv, p)
if err != nil {
return nil, err
}
out = append(out, w)
}
return out, nil
}
+112
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
@@ -108,3 +109,114 @@ func TestABatchWhoseMovesAreNotKnownIsRelatedToEverything(t *testing.T) {
t.Fatalf("a known, unrelated batch read as related: %q", why)
}
}
// TestRelatedOpenNamesTheFirstOpenWalkAChangeIsRelatedTo: a change that shares nothing with any open walk is
// held behind none (issue 973, ADR 0407 decision 4); one that shares a repository, a module, a graph edge or a
// group head is held behind the first walk it shares it with, with the reason; one whose moves are not known
// is held behind the first; a walk or change on the controller's own path is related to every other.
func TestRelatedOpenNamesTheFirstOpenWalkAChangeIsRelatedTo(t *testing.T) {
edges := []inventory.Edge{{From: "nzbget", To: "base-image", Kind: inventory.EdgeStandsOn}}
walk := func(id, repo string, own bool, modules ...string) openWalk {
return openWalk{id: id, fp: footprintOfWalk(walkOf(repo, modules...), nil), own: own}
}
one, two := walk("plan-1", "novox/one", false, "notes"), walk("plan-2", "novox/two", false, "nzbget")
cand := func(repo, head string, moves ...string) footprint {
return footprintOfBatch([]inventory.BatchedMerge{heardMerge(repo, head, moves...)}, edges)
}
cases := []struct {
name string
fp footprint
own bool
open []openWalk
behind string
why string
}{
{"nothing open", cand("novox/x", "fix/a", "app"), false, nil, "", ""},
{"unrelated to every open walk", cand("novox/x", "fix/a", "app"), false, []openWalk{one, two}, "", ""},
{"a repository", cand("novox/ONE", "fix/a", "app"), false, []openWalk{two, one}, "plan-1", "both change novox/one"},
{"a module through its dependents", cand("novox/x", "fix/a", "base-image"), false, []openWalk{one, two},
"plan-2", "both move nzbget"},
{"a graph edge", cand("novox/x", "fix/a", "nzbget"),
false, []openWalk{one, walk("plan-3", "novox/y", false, "base-image")}, "plan-3",
"nzbget depends on base-image (stands-on)"},
{"moves not known", cand("novox/x", "fix/a"), false, []openWalk{one, two}, "plan-1",
"the modules a merge of novox/x moves are not known until its batch is cut"},
{"the candidate on the own path", cand("novox/x", "fix/a", "app"), true, []openWalk{one}, "plan-1",
"it is on the controller's own path"},
{"an open walk on the own path", cand("novox/x", "fix/a", "app"), false,
[]openWalk{one, walk("plan-4", "novox/mesh-controller", true, "mesh-controller")}, "plan-4",
"plan-4 is on the controller's own path"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
behind, why := relatedOpen(c.fp, c.own, c.open, edges)
if behind != c.behind || why != c.why {
t.Errorf("relatedOpen = %q, %q; want %q, %q", behind, why, c.behind, c.why)
}
})
}
}
// A walk is on the controller's own path when its batch says so or it plans a module of that path (issue 973).
func TestAWalkOnTheControllersPathIsKnownByItsFlagOrItsModules(t *testing.T) {
if ownWalk(walkOf("novox/one", "notes")) {
t.Error("an ordinary walk read as on the controller's own path")
}
flagged := walkOf("novox/one", "notes")
flagged.Delivery = &inventory.PlanDelivery{Batch: &inventory.PlanBatch{Own: true}}
if !ownWalk(flagged) {
t.Error("a walk whose batch is own-path read as ordinary")
}
var module string
for m := range onTheControllersPath {
module = m
}
if !ownWalk(walkOf("novox/one", "notes", module)) {
t.Errorf("a walk planning %s read as ordinary", module)
}
}
// A walk about to ask its first tier waits only for a started walk it is related to (issue 973): started is a
// tier asked, or a walk made before it; a waiting walk, the backlog's and itself are not.
func TestWalksStartedBesideAreTheOpenOnesBeforeIt(t *testing.T) {
at := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
mk := func(id string, created time.Time, tier int) inventory.Plan {
p := walkOf("novox/"+id, id)
p.ID, p.Created, p.Tier, p.State = id, created, tier, inventory.PlanBuilding
return p
}
older, newer, ticking := mk("older", at, 0), mk("newer", at.Add(time.Minute), 0), mk("ticking", at.Add(2*time.Minute), 1)
backlog := mk("backlog", at, 1)
backlog.Release = &inventory.PlanRelease{}
got := startedWalks([]inventory.Plan{older, newer, ticking, backlog, mk("me", at.Add(time.Minute), 0)}, "me", at.Add(time.Minute))
var ids []string
for _, p := range got {
ids = append(ids, p.ID)
}
if strings.Join(ids, ",") != "older,ticking" {
t.Errorf("started beside me = %v; want older, ticking", ids)
}
}
// A retry is refused beside an open walk it is related to and allowed beside one it shares nothing with
// (issue 973); a retry on the controller's own path is refused beside any open walk.
func TestARetryWaitsOnlyForAnOpenWalkItIsRelatedTo(t *testing.T) {
failed := walkOf("novox/one", "notes")
failed.ID, failed.State = "plan-1", inventory.PlanFailed
open := func(id, repo string, modules ...string) inventory.Plan {
p := walkOf(repo, modules...)
p.ID, p.State = id, inventory.PlanBuilding
return p
}
if err := oneWalkAtATime(failed, []inventory.Plan{open("plan-2", "novox/two", "app")}); err != nil {
t.Errorf("refused beside an unrelated walk: %v", err)
}
err := oneWalkAtATime(failed, []inventory.Plan{open("plan-3", "novox/three", "notes")})
if err == nil || !strings.Contains(err.Error(), "plan-3") || !strings.Contains(err.Error(), "both move notes") {
t.Errorf("a retry beside a walk moving the same module: %v", err)
}
failed.Delivery = &inventory.PlanDelivery{Batch: &inventory.PlanBatch{Own: true}}
if err := oneWalkAtATime(failed, []inventory.Plan{open("plan-2", "novox/two", "app")}); err == nil {
t.Error("an own-path retry went beside an open walk")
}
}
+28 -18
View File
@@ -618,23 +618,32 @@ func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed s
advanceHeld(ctx, open)
}
// startedBeside is the id of a started walk open beside this one — a merge's walk past its wait, not the
// backlog's — or empty: one walk at a time (novox/hq ADR 0276).
func startedBeside(ctx context.Context, inv *inventory.Inventory, id string, created time.Time) (string, error) {
// startedBeside is the id of a started walk open beside this one that it is related to — a merge's walk past
// its wait, not the backlog's — with why, or empty: a walk waits only for a started walk it shares something with
// (novox/hq ADR 0276 decision 4, ADR 0407 decision 4, issue 973).
func startedBeside(ctx context.Context, inv *inventory.Inventory, p inventory.Plan) (behind, why string, err error) {
plans, err := inv.OpenPlans(ctx)
if err != nil {
return "", err
return "", "", err
}
for _, q := range plans {
if q.ID == id || q.Release != nil || q.Waiting() {
continue
}
// Started: a tier asked, or on its way (let go, or waiting for nobody) before this one.
if q.Tier > 0 || askedAny(q) || q.Created.Before(created) {
return q.ID, nil
}
started := startedWalks(plans, p.ID, p.Created)
if len(started) == 0 {
return "", "", nil
}
return "", nil
edges, err := inv.Dependencies(ctx)
if err != nil {
return "", "", err
}
open, err := openWalksOf(ctx, inv, started)
if err != nil {
return "", "", err
}
mine, err := openWalkOf(ctx, inv, p)
if err != nil {
return "", "", err
}
behind, why = relatedOpen(mine.fp, mine.own, open, edges)
return behind, why, nil
}
// deferredNote begins the note of a walk deferred behind another.
@@ -793,13 +802,14 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
tier := p.Tiers[p.Tier]
// Not yet asked: ask — the whole tier, or what of it is still unasked (novox/hq issue 754).
if unasked := unaskedOf(*p); len(unasked) > 0 {
// **One walk at a time** (novox/hq ADR 0276): a walk about to ask its first tier while another started
// walk is open waits for that one to end, let go or not, and says so.
// **One walk at a time beside a related one** (novox/hq ADR 0276, ADR 0407 decision 4, issue 973): a walk
// about to ask its first tier while a started walk it is related to is open waits for that one to end,
// let go or not, and says so and why. A started walk it shares nothing with does not hold it.
if p.Tier == 0 && !askedInTier(*p) {
if behind, err := startedBeside(ctx, inv, p.ID, p.Created); err != nil {
if behind, why, err := startedBeside(ctx, inv, *p); err != nil {
return false, err
} else if behind != "" {
note := fmt.Sprintf("%s%s ended — one walk at a time", deferredNote, behind)
note := fmt.Sprintf("%s%s ended — %s", deferredNote, behind, why)
changed := p.Note != note
p.Note = note
return changed, nil
@@ -1722,7 +1732,7 @@ func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.D
return fmt.Sprintf("%s · %s, %s, for %s", name, where, waitingSaid(p, waits[p.ID]), since)
}
if deferred(p) {
// Nor is waiting for the walk before it to end (one walk at a time, novox/hq ADR 0276).
// Nor is waiting for the walk before it to end (a related walk, novox/hq ADR 0276, ADR 0407).
return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, since)
}
if waiting, paused := pausedWaiting(p, pause, now); paused {
+84
View File
@@ -160,3 +160,87 @@ func TestNatsOnlyMeshConfigsAccountPutsToItsBuckets(t *testing.T) {
}
}
}
// **The controller asks mesh-config's `config` in its own name** (issue 925): on a real server whose users are
// composed from records that hold mesh-config on novox serving `config`, the controller's request on
// `mesh.seat.mesh-config.call.config.controller` reaches mesh-config's account and its reply reaches the
// controller's inbox; the same request under another caller's name is refused by the server.
func TestNatsTheControllerAsksMeshConfigInItsOwnName(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
seat := Seat{Name: "mesh-config", Scope: "mesh", Serves: []string{"config"}}
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{
"novox": {{Module: RuntimeModule}, {Module: "mesh-config", RunsAs: "mesh-config", Holds: []Seat{seat}}},
}})
if err != nil {
t.Fatal(err)
}
for i := range users {
users[i].PasswordHash = string(hash)
}
accounts, err := ComposeAccounts(users)
if err != nil {
t.Fatal(err)
}
conf := filepath.Join(t.TempDir(), "bus.conf")
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\n"+accounts), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatalf("the composed accounts do not parse: %v", err)
}
opts.NoLog, opts.NoSigs = true, true
s, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
connect := func(user string) (*nats.Conn, chan error) {
t.Helper()
refused := make(chan error, 8)
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err }))
if err != nil {
t.Fatalf("%s cannot connect: %v", user, err)
}
t.Cleanup(nc.Close)
return nc, refused
}
owner, _ := connect("novox.mesh-config")
subject := CalledSubject("mesh.seat.mesh-config.tool.config", ControllerName)
if subject != "mesh.seat.mesh-config.call.config.controller" {
t.Fatalf("the subject is %s", subject)
}
if _, err := owner.Subscribe("mesh.seat.mesh-config.call.config.*", func(m *nats.Msg) {
_ = m.Respond([]byte(`{"ok":true}`))
}); err != nil {
t.Fatal(err)
}
if err := owner.Flush(); err != nil {
t.Fatal(err)
}
controller, refused := connect(ControllerName)
reply, err := controller.Request(subject, []byte(`{"action":"set"}`), 5*time.Second)
if err != nil || string(reply.Data) != `{"ok":true}` {
t.Fatalf("the controller's own-name request got %v (%v), want mesh-config's reply", reply, err)
}
if err := controller.Publish(CalledSubject("mesh.seat.mesh-config.tool.config", "anchor.node-tools"), nil); err != nil {
t.Fatal(err)
}
_ = controller.Flush()
select {
case err := <-refused:
if !errors.Is(err, nats.ErrPermissionViolation) {
t.Errorf("the server said %v, want a permissions violation", err)
}
case <-time.After(3 * time.Second):
t.Error("the server did not refuse the controller a request in another's name")
}
}
@@ -226,3 +226,45 @@ func TestTheControllerMayAskTheVaultInItsOwnName(t *testing.T) {
}
}
}
// **The controller may ask mesh-config's `config` in its own name** (issue 925): move-config and the settings verb
// set a trusted value through mesh-config, which takes it as the controller carries it only from the subject that
// names the controller as caller. Granted when the records hold a holder serving the verb, and not otherwise;
// the installer's first user list carries none.
func TestTheControllerMayAskMeshConfigInItsOwnName(t *testing.T) {
config := Declared{Module: "mesh-config", Holds: []Seat{{Name: "mesh-config", Serves: []string{"config"}}}}
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {config}}})
if err != nil {
t.Fatal(err)
}
if strings.Join(users[0].AsksTheConfig, ",") != "config" {
t.Fatalf("composed %v", users[0].AsksTheConfig)
}
p, err := PermissionsFor(users[0])
if err != nil {
t.Fatal(err)
}
if !slices.Contains(p.Publish, "mesh.seat.mesh-config.call.config.controller") {
t.Errorf("the controller may not publish mesh.seat.mesh-config.call.config.controller: %v", p.Publish)
}
if !MayPublish(p, CalledSubject("mesh.seat.mesh-config.tool.config", ControllerName)) {
t.Error("the controller may not ask mesh-config's config in its own name")
}
if MayPublish(p, CalledSubject("mesh.seat.mesh-config.tool.config", "anchor.node-tools")) {
t.Error("the controller may ask mesh-config's config in another's name")
}
// No holder serving it composes no grant, nor does genesis.
other := Declared{Module: "mesh-config", Holds: []Seat{{Name: "mesh-config", Serves: []string{"list"}}}}
for _, r := range []Records{{Nodes: []string{"novox"}}, {Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {other}}}} {
users, _ = Users(r)
if len(users[0].AsksTheConfig) != 0 {
t.Errorf("composed %v without a holder serving config", users[0].AsksTheConfig)
}
}
genesis, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
for _, s := range genesis.Publish {
if strings.Contains(s, "mesh-config") {
t.Errorf("the installer's controller may publish %s", s)
}
}
}
+19
View File
@@ -141,6 +141,12 @@ type Principal struct {
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
AsksTheVault []string
// AsksTheConfig are the mesh-config verbs the controller asks in its own name (VerbsTheControllerAsksTheConfig,
// issue 925), those mesh-config's holder serves: on the subject that names its caller, so mesh-config takes a
// value as the controller carries it (ADR 0348 §8). Composed from the records like AsksTheVault, so the
// installer's first user list carries none.
AsksTheConfig []string
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
@@ -316,6 +322,12 @@ var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "acce
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"},
{Seat: "mesh-vault", Verb: "move-asks"}, {Seat: "mesh-vault", Verb: "get"}}
// VerbsTheControllerAsksTheConfig are the mesh-config seat's verbs the controller calls in its own name (issue 925):
// `config`, the one verb mesh-config serves, which move-config uses to set each value and the settings verb to set,
// clear and propose through mesh-config. mesh-config takes a value as the controller carries it only from the
// subject that names the controller as caller (ADR 0348 §8); granted as Principal.AsksTheConfig says.
var VerbsTheControllerAsksTheConfig = []SeatVerb{{Seat: "mesh-config", Verb: "config"}}
// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its
// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says.
var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"}
@@ -1089,6 +1101,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
}
}
// **And mesh-config's verb** (issue 925): it takes a trusted value from the controller alone, known from the
// subject's last token.
for _, v := range VerbsTheControllerAsksTheConfig {
if slices.Contains(p.AsksTheConfig, v.Verb) {
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
}
}
}
sort.Strings(pub)
+7 -3
View File
@@ -102,7 +102,8 @@ type Records struct {
// is a mesh that cannot be told anything, and there is no state of the records in which that is
// correct.
func Users(r Records) ([]Principal, error) {
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r)}}
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig)}}
for _, node := range sortedCopy(r.Nodes) {
witness := false
@@ -290,13 +291,16 @@ func holdsSeat(d Declared, seat string) bool {
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
// is granted to ask it in its own name. None while no holder serves them.
func vaultVerbsOf(r Records) []string {
func vaultVerbsOf(r Records) []string { return servedOf(r, VerbsTheControllerAsksTheVault) }
// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name.
func servedOf(r Records, asked []SeatVerb) []string {
seen := map[string]bool{}
var out []string
for _, node := range sortedCopy(r.Nodes) {
for _, d := range r.Assigned[node] {
for _, s := range d.Holds {
for _, v := range VerbsTheControllerAsksTheVault {
for _, v := range asked {
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
seen[v.Verb] = true
out = append(out, v.Verb)
+2 -2
View File
@@ -111,7 +111,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
}
held.From, held.At, held.Module = node.Name, at, m.Module
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
held.CredentialForm = m.CredentialFormOf(n.Name)
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
return held, true, ""
}
matching := bound.among(world.Offered[n.Name])
@@ -125,7 +125,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
identity := DefaultIdentityBound
if pm, known := catalogue[p.Module]; known {
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
held.CredentialForm = pm.CredentialFormOf(n.Name)
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
identity = pm.IdentityBoundOf(n.Name)
}
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
+4 -1
View File
@@ -104,6 +104,9 @@ type PlanProvision struct {
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
CredentialForm string `json:"credential-form,omitempty"`
// CredentialTool is, for an offer whose credential the provider's software issues (issue 928), the provider's tool
// the vault asks for each holder's value; empty for any other form.
CredentialTool string `json:"credential-tool,omitempty"`
Path string `json:"path,omitempty"`
// Contributes is what the consumer's manifest tells its provider for this provision (Manifest.Contributes: a
// database's name, its extensions), so the provider's helper learns it from the bus (issue 776). Nil where the
@@ -134,7 +137,7 @@ func provisionsOf(needs []Needed, module, as string, contributes map[string]map[
continue
}
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
CredentialForm: n.CredentialForm, Contributes: maps.Clone(contributes[n.Name]),
CredentialForm: n.CredentialForm, CredentialTool: n.CredentialTool, Contributes: maps.Clone(contributes[n.Name]),
At: n.At, From: n.From, As: as}
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
// vault's row for this holder.
+34
View File
@@ -123,3 +123,37 @@ func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *tes
t.Fatalf("the plan names %v, want admin and internal-token", names)
}
}
// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a
// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes
// handed files the client fills it and the module composes; where it does not the controller must fill it from what
// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with
// (ADR 0349 §17: the old way, byte for byte).
func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) {
text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n"
moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
`"requires":["postgres-database"],"own-secrets":{"token":"${dir:state}/token"}`, 1)
unmoved := strings.Replace(moved, `"moved":true,`, ``, 1)
if unmoved == moved {
t.Fatal("the twin is still marked moved")
}
c, got := composeWith(t, withDatabase(t, moved, text, true), handingTo("store:5000"))
if strings.Contains(got, "known_host") || len(c.Filled) == 0 {
t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got)
}
for name, with := range map[string]Rendering{
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
} {
_, err := withDatabase(t, moved, text, true).Compose(sealedForBot(with))
if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) {
t.Fatalf("%s: the key is not refused: %v", name, err)
}
_, before := withDatabase(t, unmoved, text, false).Compose(sealedForBot(with))
if before == nil || before.Error() != err.Error() {
t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before)
}
}
}
@@ -242,3 +242,33 @@ func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) {
t.Fatalf("the flag changed what is composed or handed:\nbefore: %s\nnow: %s\n%v %v", before, got, c2.Filled, c2.Plans)
}
}
// A plan entry names the tool of an offer whose credential the provider's software issues (issue 928), so the vault
// asks that tool for each holder; an offer of any other form names none.
func TestAHandedModulesPlanNamesTheCredentialToolOfASoftwareIssuedOffer(t *testing.T) {
for credential, want := range map[string]string{
``: "",
`,"credential":{"own":"admin"}`: "",
`,"credential":{"issued-by":"outside"}`: "",
`,"credential":{"issued-by":"software","tool":"gitea_agent_token"}`: "gitea_agent_token",
} {
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
credential + `}],"serves":{"postgres-database":{"port":5432}}`
if strings.Contains(credential, `"own"`) {
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"}}`
}
node := workstation()
node.At = "workstation.internal"
consumer := notifierConsumer(t, true, false)
r, err := Resolve(shelf(consumer, mustParse(t, raw+`}`)), []string{consumer.Module}, node, twoStores("anchor"))
if err != nil {
t.Fatal(err)
}
r.Account = "op"
c, _ := composeWith(t, r, handingTo("store:5000"))
got := c.Plans["notify"].Provisions
if len(got) != 1 || got[0].CredentialTool != want {
t.Errorf("an offer with %q: the plan names %+v, want the tool %q", credential, got, want)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package catalogue
import (
"strings"
"testing"
)
// A jail whose pattern names <HOST> twice is refused by name: fail2ban would read it as a duplicate
// capture group and refuse its whole configuration, leaving the machine with no bans at all — which
// is what happened on the control node the day this was built (novox/hq ADR 0179).
func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) {
twice := Manifest{Module: "route-proxy", Jails: []Jail{{
Name: "route-proxy",
Failregex: `^.*(?:refused for <HOST>:\d+|asked from <HOST>:\d+)$`,
Jail: "backend = systemd",
}}}
problems := twice.jailProblems()
if len(problems) != 1 || !strings.Contains(problems[0], "names <HOST> 2 times") {
t.Fatalf("a pattern naming <HOST> twice was not refused by name: %v", problems)
}
if !strings.Contains(problems[0], "no bans at all") {
t.Errorf("the refusal does not say what it costs: %q", problems[0])
}
// The same two shapes, one pattern each, is how it is written — and is accepted.
split := Manifest{Module: "route-proxy", Jails: []Jail{{
Name: "route-proxy",
Failregex: "^.*refused for <HOST>:\\d+$\n ^.*asked from <HOST>:\\d+$",
Jail: "backend = systemd",
}}}
if problems := split.jailProblems(); len(problems) != 0 {
t.Fatalf("one <HOST> per pattern was refused: %v", problems)
}
}
// A jail with no name, no pattern, or a name another jail of the same module already took is refused.
func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) {
m := Manifest{Module: "mailu", Jails: []Jail{
{Name: "", Failregex: "^.*rip=<HOST>$"},
{Name: "mailu-front", Failregex: " "},
{Name: "mailu-front", Failregex: "^.*rip=<HOST>$"},
}}
problems := strings.Join(m.jailProblems(), "\n")
for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} {
if !strings.Contains(problems, want) {
t.Errorf("missing %q in:\n%s", want, problems)
}
}
}
+7
View File
@@ -303,6 +303,13 @@ func (m Manifest) IssuedCredentialOf(provision string) (string, bool) {
return "", false
}
// CredentialToolOf is the tool a plan names for a provision, set only where the offer says the provider's software
// issues its credential (issue 928); empty otherwise.
func (m Manifest) CredentialToolOf(provision string) string {
tool, _ := m.IssuedCredentialOf(provision)
return tool
}
// IssuedOutsideOf is whether this module's offer of a provision says only a person outside the mesh can give its
// credential (novox/hq ADR 0337): the mesh never makes a value for it, and a consumer holds one only once a person
// gave it with `secret accept`.
+7 -5
View File
@@ -230,6 +230,8 @@ type Needed struct {
// CredentialForm is the credential form the provider's offer declares (Manifest.CredentialFormOf, issue 768),
// named in a handed consumer's plan for the vault; empty where the provider's manifest is not in hand.
CredentialForm string
// CredentialTool is the tool of the offer that the provider's software issues the credential through (issue 928).
CredentialTool string
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
@@ -543,7 +545,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
needs = append(needs, Needed{
Name: want, From: node.Name, At: at, Module: by.Module,
Serves: servedByOne(by, want), For: because[want],
SharedOwn: sharedByOne(by, want), CredentialForm: by.CredentialFormOf(want),
SharedOwn: sharedByOne(by, want), CredentialForm: by.CredentialFormOf(want), CredentialTool: by.CredentialToolOf(want),
Identity: by.IdentityBoundOf(want)})
} else if served := servedByOne(by, want); len(served) > 0 {
// Answered here with no credential to mint, but the provider serves facts the
@@ -565,7 +567,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
}
needs = append(needs, Needed{
Name: want, From: node.Name, At: at, Serves: served, For: because[want], Module: by.Module,
CredentialForm: by.CredentialFormOf(want)})
CredentialForm: by.CredentialFormOf(want), CredentialTool: by.CredentialToolOf(want)})
}
continue
}
@@ -594,17 +596,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
node.Name, want, p.Node, meshNetwork))
return
}
shared, form := "", ""
shared, form, tool := "", "", ""
// A provider whose definition is not in hand is held to the tightest bound the
// mesh knows, not to none: what it keeps is not known here (ADR 0225).
bound := DefaultIdentityBound
if pm, known := catalogue[p.Module]; known {
shared, _ = pm.SharedCredentialOf(want)
form = pm.CredentialFormOf(want)
form, tool = pm.CredentialFormOf(want), pm.CredentialToolOf(want)
bound = pm.IdentityBoundOf(want)
}
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module,
Serves: p.Serves, For: because[want], SharedOwn: shared, CredentialForm: form, Identity: bound,
Serves: p.Serves, For: because[want], SharedOwn: shared, CredentialForm: form, CredentialTool: tool, Identity: bound,
LeftOut: p.LeftOut})
}
switch {
+9 -2
View File
@@ -11,9 +11,16 @@ import (
// whole of ~/.ssh/config.d/00-mesh, every other node with its account. ~/.ssh and ~/.ssh/config.d
// are created 0700 and owned by the account; the operator's own config below the region is kept.
func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
shelf := shelf(catalogueManifest(t, "ssh-client"))
shelf := shelf(catalogueManifest(t, "ssh-client"), catalogueManifest(t, "gitea"))
// ssh-client requires forge-account since mesh-catalog #340 (novox/hq issue 955), answered by the forge's
// holder on another node; this test is about the operator's config, so the forge is offered from there
// (issue 985).
forge := World{Offered: map[string][]Provider{
"forge-account": {{Node: "marge", At: "marge.internal", Module: "gitea", Serves: map[string]any{
"ssh_host": "marge.internal", "ssh_port": 222, "known_host": "[marge.internal]:222 ssh-ed25519 AAAA",
"ssh_base": "ssh://git@marge.internal:222/", "https_base": "https://git.example.org/"}}}}}
got, err := Resolve(shelf, []string{"ssh-client"},
Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, World{})
Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, forge)
if err != nil {
t.Fatal(err)
}
+4
View File
@@ -112,6 +112,10 @@ type Machine struct {
// NodeEngine and NodeTools are the builds it runs.
NodeEngine string `json:"node-engine,omitempty"`
NodeTools string `json:"node-tools,omitempty"`
// Contract is the contract of the node-engine's newest health statement (0 when it never stated one): what
// says whether the machine takes handed files (novox/hq issue 969). A check that composed it without would
// judge a moved module the old way where the mesh hands it.
Contract int `json:"contract,omitempty"`
// Capabilities are what it reported it can do; the detail kept only where it is a version.
Capabilities []Capability `json:"capabilities,omitempty"`
// Site, Hub, Public: where it is on the private network — its site (a pseudonym), whether it is the
+2
View File
@@ -119,6 +119,8 @@ type PlanBatch struct {
ClosesAt time.Time `json:"closes_at"`
AtMost time.Time `json:"at_most"`
Behind string `json:"behind,omitempty"`
// BehindWhy says why the batch waits behind that walk: what the two share (issue 973).
BehindWhy string `json:"behind_why,omitempty"`
// Own says the batch holds merges on the controller's own path, whose walk waits for nobody's word: such
// a merge never shares a batch with one that waits for mesh-delivery's (ADR 0276, decided during the
// build, 2026-10-10).
+4 -12
View File
@@ -81,18 +81,10 @@ type ProvisionedCollection struct {
}
// ProvisionedCredential is one provision the module requires, under one local name, and its provider (issue 760);
// with a name, a credential issued for this holder alone (issue 757).
type ProvisionedCredential struct {
Name string `json:"name,omitempty"`
Provision string `json:"provision"`
Local string `json:"local,omitempty"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
// CredentialForm is the form the provider's offer declares for its credential (issue 768): shared, pair,
// issued or given; empty when the provider's manifest was not in hand.
CredentialForm string `json:"credential-form,omitempty"`
Path string `json:"path,omitempty"`
}
// with a name, a credential issued for this holder alone (issue 757). It is the composition's own type, not a copy:
// the plan is kept and read back as the one it was sent as, so no field of it (contributes, at, from, as, names) can
// be lost between the two (issue 923).
type ProvisionedCredential = catalogue.PlanProvision
// ProvisionedSecret is one of the module's own secrets, by its name in the manifest. Path, when set, is the file
// holding the secret alone (the manifest's `own-secrets`), which the client fills with its value and nothing else.
+39
View File
@@ -352,3 +352,42 @@ func TestAPlanMayHoldSeveralNamedItemsOfOneCollectionAndRefusesABadOne(t *testin
}
}
}
// **A kept plan's provisions keep every field the composition sets** (issue 923): the names, the place, the machine
// and the identity the provider's helper writes into the consumer's entry, and what the manifest contributes, are
// read back as they were sent, so the assignments mirror carries them to the provider.
func TestAKeptPlansProvisionsKeepContributesAtFromAsAndNames(t *testing.T) {
sent := catalogue.PlanProvision{Provision: "route", Provider: "novox", ProviderModule: "route-proxy",
Contributes: map[string]any{"database": "gitea"}, At: "10.0.0.1", From: "novox", As: "gitea@novox",
Names: map[string]string{"name-web": "git.example.org", "internal-name": "gitea.mesh"}}
raw, err := json.Marshal(map[string]any{"gitea": map[string]any{
"provisions": []catalogue.PlanProvision{sent}, "required-by": "forge"}})
if err != nil {
t.Fatal(err)
}
var kept map[string]keptPlan
if err := json.Unmarshal(raw, &kept); err != nil {
t.Fatal(err)
}
got := kept["gitea"].Provisions
if len(got) != 1 || !reflect.DeepEqual(got[0], sent) {
t.Fatalf("the kept plan's provisions are %+v, want %+v", got, sent)
}
}
// A kept plan's provision keeps the tool the vault asks for a software-issued credential (issue 928).
func TestAKeptPlansProvisionKeepsItsCredentialTool(t *testing.T) {
sent := catalogue.PlanProvision{Provision: "gitea-read", Provider: "novox", ProviderModule: "gitea",
CredentialForm: catalogue.CredentialFormIssued, CredentialTool: "gitea_agent_token"}
raw, err := json.Marshal(map[string]any{"claude-code": map[string]any{"provisions": []catalogue.PlanProvision{sent}}})
if err != nil {
t.Fatal(err)
}
var kept map[string]keptPlan
if err := json.Unmarshal(raw, &kept); err != nil {
t.Fatal(err)
}
if got := kept["claude-code"].Provisions; len(got) != 1 || got[0].CredentialTool != "gitea_agent_token" {
t.Fatalf("the kept plan's provisions are %+v, want the tool kept", got)
}
}
+13
View File
@@ -179,6 +179,19 @@ func (b OverNATS) PublishMembership(ctx context.Context, node, module string, bo
return nil
}
// PublishMembershipAfter issues a membership only if the one read at sequence seq is still the newest of its
// subject: a push that issued another since is not overwritten, and the refusal comes back (novox/hq issue 930).
func (b OverNATS) PublishMembershipAfter(ctx context.Context, node, module string, body []byte, seq uint64) error {
ctx, cancel := context.WithTimeout(ctx, MembershipWait)
defer cancel()
_, err := b.JS.Publish(broker.MembershipSubject(node, module), body, nats.Context(ctx),
nats.ExpectLastSequencePerSubject(seq))
if err != nil {
return fmt.Errorf("issuing %s on %s its membership after sequence %d: %w", module, node, seq, err)
}
return nil
}
func (b OverNATS) PublishDeclaration(ctx context.Context, node string, body []byte) error {
_, err := b.JS.Publish(DeclareSubject(node), body, nats.Context(ctx))
if err != nil {
+4
View File
@@ -8,6 +8,7 @@
./cmd/mesh-controller TestAStopWithoutAMergeLeavesItOutOfABatch
./cmd/mesh-controller TestSavingPlansAndMovingMergesIsOneAct
./cmd/mesh-controller TestAPlanFollowedFromTheStoreAnswersWhenItMoves
./cmd/mesh-controller TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself
./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered
./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend
./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore
@@ -127,3 +128,6 @@
./cmd/mesh-controller TestAGroupsCompositionIntoAReleaseBranchIsJudgedByItsOwnController
./cmd/mesh-controller TestAGroupsCompositionJudgedWithTheValidatorIntoAReleaseBranchIsJudgedByItsOwnController
./cmd/mesh-controller TestAssigningTheClientIssuesItsAccount
./cmd/mesh-controller TestAnUnrelatedBatchWalksBesideAnOpenWalk
./cmd/mesh-controller TestARelatedBatchWaitsBehindTheOpenWalk
./cmd/mesh-controller TestAnOwnPathBatchNeverWalksBeside