Compare commits

...
Author SHA1 Message Date
mesh-admin 4d30b5de13 Merge pull request 'A module's own secret is asked for by a verb and typed at the desk, bounded, the prompt naming who asked (hq ADR 0277)' (#201) from feat/0277-secret-ask into main 2026-10-10 13:34:20 +00:00
jochen c97942d591 A module's own secret is asked for by a verb and typed at the desk, bounded, the prompt naming who asked (hq ADR 0277)
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The give verb opened the desk's hidden prompt for a module's own secret, with the desk named. Now the
secret-ask verb (secret ask <node> <module> <name> [--at <desk>]) opens the same prompt from anywhere on
the mesh, with the desk the module's machine unless named, and give composes the same line. Every ask
is recorded in the store before the prompt opens (migration 0091): one open ask per secret, three an
hour, so an agent cannot keep a prompt in front of the operator. The prompt names who asked, from the
bus's word on the caller cut to a name's characters, never an argument of the call. The value stays
typed at the desk, sealed to the one call and then to the module's machine, never in an argument, a
log or an event; a secret the mesh makes itself and a trusted party's secret are refused as before.
2026-10-10 15:24:08 +02:00
mesh-admin 9c69b9da17 Merge pull request 'An own-path merge never shares a batch with a catalogue merge, and plans names a walk by what it moves (tracker issues 377, 378)' (#200) from fix/377-378-own-path-batches-and-named-plan-lines into main 2026-10-10 13:14:37 +00:00
jochen 16362e1bbd A deferred walk is one whose note says so: a failed first ask is watched as before (review of #200)
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-10 14:51:27 +02:00
jochen 3496b58f66 A walk let go behind another reads as a wait: its note on the line, never late, and no tier of it watched (hq ADR 0276 review of #200)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
2026-10-10 14:47:11 +02:00
jochen 04fcce2fcc An own-path batch is cut first and folds no waiting walk; a walk let go beside a started one starts once it ended; a late catalogue merge is not answered by a walk that waited for nobody (hq ADR 0276 review, tracker issue 377)
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-10 14:33:10 +02:00
17 changed files with 901 additions and 159 deletions
+122 -60
View File
@@ -38,8 +38,9 @@ import (
// by that one.
//
// 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, so no catalogue delivery skips its turn
// behind a controller merge (decided during the build, 2026-10-10).
// one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds
// no waiting catalogue walk, and a catalogue walk let go while another walk runs starts once it ended — so no
// catalogue batch's walk starts without the word (decided during the build, 2026-10-10).
//
// The batch, its merges and their times are in the store (batched_merge, and the batch's own plan record in
// the state `assembling` or `queued`), so a restarted controller resumes the window where it stood.
@@ -181,7 +182,12 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
if _, known, err := inv.MergeOf(ctx, repository, m.Commit); err != nil || known {
return notNow(err)
}
event, err := json.Marshal(m)
var moves []string
for _, e := range touches {
moves = append(moves, e.Manifest.Module)
}
sort.Strings(moves)
event, err := json.Marshal(keptMerge{SourceMoved: m, Moves: moves})
if err != nil {
return err
}
@@ -193,7 +199,7 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
// word** (decided during the build, 2026-10-10): batched together, the catalogue's deliveries would start
// with the controller's and skip their turn. Each kind has a batch of its own.
own := ownPath(touches)
if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own); err != nil {
if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own, deliverySeatHeld(entries)); err != nil {
return notNow(err)
} else if ok {
heard.Plan = p.ID
@@ -251,7 +257,7 @@ func owedLate(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved,
// later merge, which contains it. A failed or stopped walk answers nothing more, and a walk folded into
// another is followed to that one. False when none does: the merge joins the next batch.
func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge,
moves []inventory.Entry, own bool) (inventory.Plan, bool, error) {
moves []inventory.Entry, own, held bool) (inventory.Plan, bool, error) {
later, err := inv.LaterMergesOf(ctx, heard.Repository, heard.Branch, heard.Merged)
if err != nil || len(later) == 0 {
return inventory.Plan{}, false, err
@@ -268,7 +274,11 @@ func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard
return p, true, nil
}
case p.Open() || p.State == inventory.PlanDone:
if buildsEvery(p, moves) {
// A walk that waited for nobody's word is not a catalogue merge's to be answered by while the delivery
// seat has a holder: its delivery would skip its turn (decided during the build, 2026-10-10). With no
// holder on record nothing waits, and any walk that built it answers.
waited := p.Delivery != nil && p.Delivery.Awaits != ""
if buildsEvery(p, moves) && (own || waited || !held) {
return p, true, nil
}
}
@@ -431,7 +441,7 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
if repo == "" {
repo = m.Repository
}
n := inventory.PlanMerge{Repository: repo, Commit: m.Commit}
n := namedOf(m, repo)
if l := latest[k]; l.Commit != m.Commit {
n.Carried = l.Commit
}
@@ -443,15 +453,34 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
return commits, named
}
// keptMerge is a merge as the record keeps it: the forge's announcement, and the modules it moved when it was
// heard. The announcement reads back as a SourceMoved alone, which ignores the rest.
type keptMerge struct {
link.SourceMoved
Moves []string `json:"moves,omitempty"`
}
// announcedOf is a kept merge as the forge announced it, and what it moved; empty where the record says none.
func announcedOf(m inventory.BatchedMerge) keptMerge {
var k keptMerge
_ = json.Unmarshal(m.Event, &k)
return k
}
// spelledOf is a kept merge's repository as the forge spelled it; empty when its announcement does not say.
func spelledOf(m inventory.BatchedMerge) string {
var e link.SourceMoved
if json.Unmarshal(m.Event, &e) == nil && e.Owner != "" {
if e := announcedOf(m); e.Owner != "" {
return e.Owner + "/" + e.Repo
}
return ""
}
// namedOf is one merge as a walk or batch names it: its commit, and its pull request and moves as announced.
func namedOf(m inventory.BatchedMerge, repository string) inventory.PlanMerge {
k := announcedOf(m)
return inventory.PlanMerge{Repository: repository, Commit: m.Commit, Number: k.Number, Title: k.Title, Moves: k.Moves}
}
// laterMerge says a was merged after b: by the forge's merge time, then by when each was heard.
func laterMerge(a, b inventory.BatchedMerge) bool {
if !a.Merged.Equal(b.Merged) {
@@ -480,10 +509,11 @@ func nameMerges(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan
// The walk's own commits stand: a merge heard late is carried by the one of its repository's branch.
var named []inventory.PlanMerge
for _, m := range merges {
n := inventory.PlanMerge{Repository: m.Repository, Commit: m.Commit}
repo := m.Repository
if e := spelledOf(m); e != "" {
n.Repository = e
repo = e
}
n := namedOf(m, repo)
if c := p.CommitOn(m.Repository, m.Branch); c != "" && c != m.Commit {
n.Carried = c
}
@@ -579,18 +609,18 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
if err := keepAll(""); err != nil {
return err
}
// The oldest batch whose window closed is cut; one of each kind at most is open (theOpenBatch).
// 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); one of each kind at most is
// open (theOpenBatch).
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: those merges are batched
// again, behind it (decided during the build, 2026-10-10).
if err := rebatchWaiting(ctx, inv, waiting, b.ID, now); err != nil {
return err
}
// 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 {
@@ -608,46 +638,6 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
return nil
}
// rebatchWaiting puts the merges of the walks waiting for their word into the open batch of their kind — a new
// one when none is — queued behind the walk about to be cut, and closes the walks as taken over by that batch:
// the batch keeps its id when it is cut, so the delivery's owner follows them to the walk that answers them.
func rebatchWaiting(ctx context.Context, inv *inventory.Inventory, waiting []inventory.Plan, behind string, now time.Time) error {
for i := range waiting {
w := waiting[i]
merges, err := inv.MergesOf(ctx, w.ID)
if err != nil {
return err
}
if len(merges) == 0 {
continue // nothing of the record's: left waiting
}
batch, err := theOpenBatch(ctx, inv, merges[0], now, false)
if err != nil {
return err
}
for _, m := range merges {
if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, batch.ID, m.Alone); err != nil {
return err
}
}
if err := keepBatch(ctx, inv, &batch, now, behind); err != nil {
return err
}
w.State = inventory.PlanSuperseded
if w.Delivery == nil {
w.Delivery = &inventory.PlanDelivery{}
}
w.Delivery.TakenOverBy = batch.ID
w.Note = fmt.Sprintf("superseded at tier %d by %s before it started: a walk on the controller's own path "+
"(%s) goes first, and that batch answers its merges after it", w.Tier, batch.ID, behind)
if err := inv.SavePlan(ctx, &w); err != nil {
return err
}
fmt.Printf(" %s is %s\n", w.ID, w.Note)
}
return nil
}
// cutBatch makes a closed batch one walk, folding in the walks that wait for their word: it keeps its id, and
// the folded walks name it as the walk that took them over.
func cutBatch(ctx context.Context, open *stores, batch *inventory.Plan, waiting []inventory.Plan, now time.Time) error {
@@ -754,6 +744,9 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
}
}
}
// **Whether it waits for its delivery's word** is read from what its merges moved (novox/hq ADR 0239), never
// from what a folded walk carried along, which holds dependents too.
awaits := awaitsFor(entries, moved)
held := map[string]bool{}
for _, e := range entries {
held[e.Manifest.Module] = true
@@ -773,9 +766,7 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
plan.Commits = commits
newest := newestCommit(commits)
plan.Repository, plan.Branch, plan.Commit, plan.Merged = newest.Repository, newest.Branch, newest.Commit, newest.Merged
// **Whether it waits for its delivery's word** (novox/hq ADR 0239): while the mesh-delivery seat has a
// holder on record, a walk that moves no module on the controller's own path is opened and waits.
plan.Delivery = awaitsFor(entries, moved)
plan.Delivery = awaits
if plan.Delivery == nil {
plan.Delivery = &inventory.PlanDelivery{}
}
@@ -1088,6 +1079,77 @@ func groupedWords(b inventory.Plan) string {
return strings.Join(out, ", ")
}
// batchLines is a batch's grouped list as `plans` prints it under its line (asked by the operator, 2026-10-10):
// one line per repository, its pull request and title, the earlier merges it answers, and what its merges move.
func batchLines(b inventory.Plan) []string {
var out []string
for _, c := range b.Carried() {
var head *inventory.PlanMerge
var answers []string
moves := map[string]bool{}
if b.Delivery != nil {
for i := range b.Delivery.Merges {
m := &b.Delivery.Merges[i]
if !strings.EqualFold(m.Repository, c.Repository) {
continue
}
for _, n := range m.Moves {
moves[n] = true
}
switch {
case m.Commit == c.Commit:
head = m
case m.Carried == c.Commit:
answers = append(answers, pullWords(*m))
}
}
}
line := repoName(c.Repository) + " " + short(c.Commit)
if head != nil && head.Number > 0 {
line = repoName(c.Repository) + " " + pullWords(*head)
}
if len(answers) > 0 {
line += " (answers " + strings.Join(answers, ", ") + ")"
}
if len(moves) > 0 {
line += " · " + strings.Join(sortedKeysOf(boolsToStrings(moves)), ", ")
}
out = append(out, line)
}
return out
}
// pullWords is a merge as its pull request: "#175 a tap shows its outcome", the title cut at fifty runes; the
// commit where the announcement named no pull request.
func pullWords(m inventory.PlanMerge) string {
if m.Number == 0 {
return short(m.Commit)
}
s := fmt.Sprintf("#%d", m.Number)
if t := cutTitle(m.Title, 50); t != "" {
s += " " + t
}
return s
}
// cutTitle is a title cut at n runes, with an ellipsis where it was cut.
func cutTitle(title string, n int) string {
r := []rune(strings.TrimSpace(title))
if len(r) <= n {
return string(r)
}
return strings.TrimSpace(string(r[:n-1])) + "…"
}
// boolsToStrings is a set's members, for sortedKeysOf.
func boolsToStrings(set map[string]bool) map[string]string {
out := make(map[string]string, len(set))
for k := range set {
out[k] = ""
}
return out
}
// secondsWords is a short wait as a person reads it.
func secondsWords(d time.Duration) string {
if d < 2*time.Minute {
+187 -45
View File
@@ -136,7 +136,7 @@ func TestTwoMergesSecondsApartAreOneWalkAtTheLaterCommit(t *testing.T) {
}
want := []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: claude.Commit, Carried: dunst.Commit},
{Repository: "novox/mesh-catalog", Commit: dunst.Commit}}
if w.Delivery == nil || !slices.Equal(w.Delivery.Merges, want) {
if w.Delivery == nil || !slices.EqualFunc(w.Delivery.Merges, want, sameMerge) {
t.Fatalf("the walk answers %+v, want %+v", w.Delivery, want)
}
if len(*asked) != 2 || (*asked)[0][2] != "main" || (*asked)[1][2] != "main" {
@@ -229,17 +229,28 @@ func TestTheAssemblingBatchIsShown(t *testing.T) {
inventory.PlanSaved = func(p inventory.Plan) { said = append(said, p) }
t.Cleanup(func() { inventory.PlanSaved = was })
now := time.Now().UTC()
hear(t, open, catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second)), now.Add(-19*time.Second))
hear(t, open, catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second)), now.Add(-time.Second))
claude := catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second))
claude.Number, claude.Title = 174, "claude-code: an agent proposes a section"
dunst := catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second))
dunst.Number, dunst.Title = 175, "dunst: the font the operator chose"
hear(t, open, claude, now.Add(-19*time.Second))
hear(t, open, dunst, now.Add(-time.Second))
hear(t, open, repoMerge("one", "a6bc0931one", now), now)
out := captured(t, func() error { return plansCommand(t.Context(), nil) })
first := strings.SplitN(out, "\n", 2)[0]
lines := strings.Split(out, "\n")
first := lines[0]
for _, want := range []string{"assembling: ", " s left (at the latest ", "grouped: novox/mesh-catalog@48bda475 " +
"(answers 553b7191), novox/one@a6bc0931; plan not yet calculated"} {
if !strings.Contains(first, want) {
t.Fatalf("plans' first line %q does not say %q", first, want)
}
}
// Under it, one line per repository: the pull request, what it answers, what it moves.
if len(lines) < 3 || strings.TrimSpace(lines[1]) != "mesh-catalog #175 dunst: the font the operator chose (answers "+
"#174 claude-code: an agent proposes a section) · app, notes" ||
strings.TrimSpace(lines[2]) != "one a6bc0931 · one" {
t.Fatalf("the grouped list reads %q", lines[1:4])
}
if len(said) == 0 || said[len(said)-1].State != inventory.PlanAssembling || said[len(said)-1].Delivery.Batch == nil ||
len(said[len(said)-1].Delivery.Merges) != 3 {
t.Fatalf("the batch was not said as assembling with its merges: %+v", said)
@@ -364,7 +375,7 @@ func TestALateMergeIsAnsweredByTheWalkOfTheLaterOne(t *testing.T) {
hear(t, open, catalogueMerge("553b7191early", "notes", t0), t0.Add(15*time.Minute))
got, _ := open.inventory.PlanByID(ctx, w.ID)
if got.State != inventory.PlanDone || len(got.Delivery.Merges) != 2 ||
got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early",
!sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early",
Carried: later.Commit}) {
t.Fatalf("the late merge is not named by the walk that carried it: %+v", got.Delivery.Merges)
}
@@ -404,7 +415,7 @@ func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) {
ws, _ = walks(t, open)
alone := ws[0]
if alone.ID == failed.ID || alone.Commit != middle.Commit || len(alone.Delivery.Merges) != 1 ||
alone.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) {
!sameMerge(alone.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) {
t.Fatalf("the newest earlier merge was not walked alone on its own commit: %+v", alone)
}
if _, in := alone.Modules["app"]; !in || (*asked)[len(*asked)-1] != [3]string{"novox/mesh-catalog", "modules/app",
@@ -425,7 +436,7 @@ func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) {
t.Fatalf("the search went on after a merge was delivered: %+v", ws[0])
}
done, _ := open.inventory.PlanByID(ctx, alone.ID)
if len(done.Delivery.Merges) != 2 || done.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog",
if len(done.Delivery.Merges) != 2 || !sameMerge(done.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog",
Commit: oldest.Commit, Carried: middle.Commit}) {
t.Fatalf("the oldest merge is not answered by the walk that delivered the one after it: %+v", done.Delivery.Merges)
}
@@ -565,7 +576,7 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
}
hear(t, open, repoMerge("one", "c1", t0.Add(-60*time.Second)), t0.Add(15*time.Minute))
got, _ := open.inventory.PlanByID(ctx, w.ID)
if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one",
Commit: "c1", Carried: "c2"}) {
t.Fatalf("the late merge was not named by the walk that carried it: %+v", got.Delivery.Merges)
}
@@ -589,10 +600,11 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
}
}
// One walk at a time holds against the delivery's word too: a waiting walk is not let go while another started.
func TestAWaitingWalkIsNotLetGoBesideAStartedOne(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.
func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
asked := asksWithPaths(t)
ctx := t.Context()
hear(t, open, repoMerge("one", "c1", t0), t0)
cutAt(t, open, t0.Add(2*time.Minute))
@@ -603,9 +615,37 @@ func TestAWaitingWalkIsNotLetGoBesideAStartedOne(t *testing.T) {
if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
t.Fatal(err)
}
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err == nil ||
!strings.Contains(err.Error(), started[0].ID) {
t.Fatalf("a waiting walk was let go beside %s: %v", started[0].ID, err)
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err != nil {
t.Fatalf("the word was refused beside %s: %v", started[0].ID, err)
}
before := len(*asked)
advanceHeld(ctx, open)
got, _ := open.inventory.PlanByID(ctx, waiting.ID)
if len(*asked) != before || !strings.Contains(got.Note, "starts once "+started[0].ID) {
t.Fatalf("a walk let go beside a started one asked (%d → %d) or does not say it waits: %q", before, len(*asked), got.Note)
}
// Read as a wait, an hour on: its note on the line, never LATE, and no tier of it late for S3.
later := time.Now().Add(time.Hour)
if line := planLine(got, later); !strings.Contains(line, "starts once "+started[0].ID) || strings.Contains(line, "LATE") {
t.Fatalf("a deferred walk reads %q", line)
}
facts, _, err := gatherPlans(ctx, open.inventory, later, nil)
if err != nil {
t.Fatal(err)
}
for _, f := range facts {
if f.id == got.ID {
t.Fatalf("a deferred walk is watched as a tier running late: %+v", f)
}
}
done := started[0]
done.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &done); err != nil {
t.Fatal(err)
}
advanceHeld(ctx, open)
if len(*asked) != before+1 {
t.Fatalf("the walk did not start once the started one ended: asked %v", *asked)
}
}
@@ -660,7 +700,7 @@ func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) {
t.Fatal(err)
}
got, _ := open.inventory.PlanByID(ctx, w.ID)
if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one",
if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one",
Commit: "c1", Carried: "c2"}) {
t.Fatalf("the earlier merge the catch-up handed over is not named by the walk that carried it: %+v",
got.Delivery.Merges)
@@ -706,50 +746,29 @@ func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) {
if !strings.Contains(batchWords(ownBatch, t0.Add(30*time.Second)), "own path") {
t.Fatalf("the own-path batch does not say so: %q", batchWords(ownBatch, t0.Add(30*time.Second)))
}
// The catalogue batch, the older, is cut first and waits for its word; the controller's batch is cut next:
// the waiting walk is batched again behind it, not folded into it.
// Both windows closed, the controller's batch is cut first and starts; the catalogue batch queues behind it,
// is cut when the controller's walk ended, and waits for its word with both merges.
cutAt(t, open, t0.Add(2*time.Minute))
ws, _ := walks(t, open)
if len(ws) != 1 || !ws[0].Waiting() {
t.Fatalf("the catalogue batch was not cut into a waiting walk: %+v", ws)
}
catalogueWalk := ws[0]
cutAt(t, open, t0.Add(2*time.Minute+5*time.Second))
ws, bs = walks(t, open)
var ownWalk inventory.Plan
for _, w := range ws {
if w.Open() {
ownWalk = w
}
}
if ownWalk.ID == "" || ownWalk.Waiting() || ownWalk.CommitOf("novox/mesh-controller") != "k1" {
t.Fatalf("the controller's batch was not cut into a started walk: %+v", ws)
ws, bs := walks(t, open)
if len(ws) != 1 || ws[0].Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k1" {
t.Fatalf("the controller's batch was not cut first into a started walk: %+v", ws)
}
ownWalk := ws[0]
for _, m := range []string{"app", "notes"} {
if _, in := ownWalk.Modules[m]; in {
t.Fatalf("the controller's walk builds %s, a catalogue module: it skipped its turn", m)
}
}
for _, a := range *asked {
if a[1] == "modules/app" || a[1] == "modules/notes" {
t.Fatalf("a catalogue module was asked without the word: %v", *asked)
}
if len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() {
t.Fatalf("the catalogue batch does not queue behind the controller's walk: %+v", bs)
}
folded, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID)
if folded.State != inventory.PlanSuperseded || len(bs) != 1 || folded.Delivery.TakenOverBy != bs[0].ID ||
bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() ||
len(bs[0].Delivery.Merges) != 2 {
t.Fatalf("the waiting catalogue walk is %s (taken over by %q); batches %+v", folded.State,
folded.Delivery.TakenOverBy, bs)
}
// The controller's walk done, the catalogue batch is cut and waits for its word with both merges.
ownWalk.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil {
t.Fatal(err)
}
cutAt(t, open, t0.Add(3*time.Minute))
ws, bs = walks(t, open)
if len(bs) != 0 || ws[0].ID != folded.Delivery.TakenOverBy || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 {
if len(bs) != 0 || ws[0].ID != catalogueBatch.ID || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 {
t.Fatalf("the catalogue batch was not cut into a waiting walk once the controller's ended: %+v %+v", ws, bs)
}
for _, m := range []string{"app", "notes"} {
@@ -757,4 +776,127 @@ func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) {
t.Fatalf("the catalogue walk does not build %s: %v", m, ws[0].Modules)
}
}
for _, a := range *asked {
if a[1] == "modules/app" || a[1] == "modules/notes" {
t.Fatalf("a catalogue module was asked without the word: %v", *asked)
}
}
// A catalogue walk waiting for its word when an own-path batch is cut keeps waiting beside the own-path
// walk, is not folded into it, and its word is taken meanwhile: it starts once the own-path walk ended.
catalogueWalk := ws[0]
hear(t, open, repoMerge("mesh-controller", "k2", t0.Add(4*time.Minute)), t0.Add(4*time.Minute))
cutAt(t, open, t0.Add(6*time.Minute))
ws, _ = walks(t, open)
kept, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID)
if !kept.Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k2" || ws[0].Waiting() {
t.Fatalf("the waiting catalogue walk was not kept waiting beside the controller's walk: %s %+v", kept.State, ws)
}
if _, in := ws[0].Modules["app"]; in {
t.Fatalf("the controller's walk folded the waiting catalogue walk in: %v", ws[0].Modules)
}
if _, err := letGo(ctx, open.inventory, catalogueWalk.ID, catalogue.DeliverySeat, "its turn"); err != nil {
t.Fatal(err)
}
before := len(*asked)
advanceHeld(ctx, open)
if len(*asked) != before {
t.Fatalf("the catalogue walk started beside the controller's: asked %v", (*asked)[before:])
}
own2 := ws[0]
own2.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &own2); err != nil {
t.Fatal(err)
}
advanceHeld(ctx, open)
if len(*asked) < before+1 || (*asked)[before][1] != "modules/app" {
t.Fatalf("the catalogue walk did not start once the controller's ended: %v", (*asked)[before:])
}
}
// `plans` names a walk by what it moves (asked by the operator, 2026-10-10): the repository's pull request and
// title, the modules it moves and the machines running them, then the state words; a record naming no pull
// request is named by its commit, and a title is cut at fifty runes.
func TestAPlanLineNamesWhatItMoves(t *testing.T) {
now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
p := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1",
State: inventory.PlanBuilding, Tiers: [][]string{{"messenger", "telegram"}}, TierEntered: now.Add(-2 * time.Minute),
Modules: map[string]*inventory.PlanModule{"messenger": {State: "asked"}, "telegram": {State: "asked"}},
Commits: []inventory.PlanCommit{{Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1"}},
Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-catalog",
Commit: "c1c1c1c1c1", Number: 175, Title: "a tap shows its outcome", Moves: []string{"telegram", "messenger"}}}}}
running := func(module string) []string {
if module == "messenger" || module == "telegram" {
return []string{"novox"}
}
return nil
}
if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running),
"mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want {
t.Fatalf("the line reads %q, want %q", got, want)
}
if got := planLine(p, now); !strings.HasPrefix(got, "mesh-catalog #175 a tap shows its outcome · messenger, telegram · tier") {
t.Fatalf("without the machines the line reads %q", got)
}
old := p
old.Commits, old.Delivery = nil, nil
if got := planLine(old, now); !strings.HasPrefix(got, "mesh-catalog c1c1c1c1 · tier 1 of 1") {
t.Fatalf("a record naming no pull request reads %q", got)
}
long := p
long.Delivery.Merges[0].Title = strings.Repeat("abcdefghij", 6)
if got := planHeadline(long, nil); !strings.Contains(got, "#175 "+strings.Repeat("abcdefghij", 4)+"abcdefghi…") {
t.Fatalf("a long title is not cut at fifty runes: %q", got)
}
}
// sameMerge compares what a walk names of a merge: its repository, commit and the commit carrying it.
func sameMerge(a, b inventory.PlanMerge) bool {
return a.Repository == b.Repository && a.Commit == b.Commit && a.Carried == b.Carried
}
// A catalogue merge heard after a later merge of its branch was walked without the word (a merge that touched
// the bus too, on the controller's own path) is not answered by that walk while the delivery seat has a holder:
// its delivery would skip its turn. It joins the next catalogue batch.
func TestALateCatalogueMergeIsNotAnsweredByAnOwnPathWalk(t *testing.T) {
open := windowed(t)
asksWithPaths(t)
ctx := t.Context()
for _, m := range []struct {
module string
claims []catalogue.Claim
}{
{"nats", nil},
{"mesh-delivery", []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}},
} {
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m.module, Version: "1", Claims: m.claims},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m.module, Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
}
if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
t.Fatal(err)
}
mixed := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "m1xed", MergedAt: t0.Format(time.RFC3339Nano),
Paths: []string{"modules/nats/module.json", "modules/app/module.json"}, ModuleDirs: []string{"modules/nats", "modules/app"},
ModuleDirsSaid: true}
hear(t, open, mixed, t0.Add(time.Second))
cutAt(t, open, t0.Add(2*time.Minute))
ws, _ := walks(t, open)
if len(ws) != 1 || ws[0].Waiting() {
t.Fatalf("the mixed merge's walk waited, or was not cut: %+v", ws)
}
done := ws[0]
done.State = inventory.PlanDone
if err := open.inventory.SavePlan(ctx, &done); err != nil {
t.Fatal(err)
}
hear(t, open, catalogueMerge("ear1ier", "app", t0.Add(-30*time.Second)), t0.Add(3*time.Minute))
got, _ := open.inventory.PlanByID(ctx, done.ID)
_, bs := walks(t, open)
if len(got.Delivery.Merges) != 1 || len(bs) != 1 || bs[0].OwnPath() || bs[0].Delivery.Merges[0].Commit != "ear1ier" {
t.Fatalf("the late catalogue merge was answered by the own-path walk (%+v) rather than the next catalogue batch (%+v)",
got.Delivery.Merges, bs)
}
}
+2 -10
View File
@@ -103,16 +103,8 @@ 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): a walk that started is open, so this one waits for its end.
open, err := inv.OpenPlans(ctx)
if err != nil {
return p, err
}
for _, q := range open {
if q.ID != p.ID && q.Release == nil && !q.Waiting() {
return p, fmt.Errorf("%s is open (%s): one walk at a time — %s is let go once it ended", q.ID, q.Named(), p.ID)
}
}
// **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.
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"
+78 -10
View File
@@ -15,16 +15,23 @@ import (
"github.com/novox/mesh-controller/internal/secrets"
)
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10).
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10, ADR 0277).
//
// mesh-controller secret ask <node> <module> <name> [--at <desk>]
//
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
// server, calls `give` with the machine, the module, the secret's name and the desk — never a value. The
// controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to prompt the
// operator without showing what is typed, and is answered with what was typed **sealed to that key**: no
// plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
// server, calls `secret-ask` (or `give`) with the machine, the module, the secret's name and the desk — never
// a value. The controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to
// prompt the operator without showing what is typed, and is answered with what was typed **sealed to that
// key**: no plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the
// value was taken, or why not.
//
// **Bounded, and the prompt says who asked** (ADR 0277): one open prompt per secret and few an hour, read from
// the store before the prompt opens (inventory.OpenSecretAsk), so an agent cannot keep a prompt in front of the
// operator until they type. The prompt names the module, the secret, the machine and who asked — the caller as
// the bus named it, never a word the caller chose — written by the desk's launcher from those names alone.
//
// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's
// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a
// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could
@@ -54,6 +61,37 @@ type deskGive struct {
// announce raises the condition that says a module's own secret was given (secretGivenObservation), on
// every channel; nil announces nothing (a test that does not look).
announce func(node, module, name, how string) error
// askedBy is who asked, as the bus named the caller: said in the prompt and recorded.
askedBy string
// open records the ask and holds the bounds (one open per secret, few an hour), answering the record's id;
// nil keeps no record (a test that does not look). end closes it with how it ended.
open func(node, module, name, desk string) (int64, error)
end func(id int64, outcome string) error
}
// askedByName is the caller as the prompt names it: the first clause of what the bus said, in the characters
// a name has, at most 80 of them. The desk's launcher refuses anything else, so no words of the caller's own
// reach the prompt.
func askedByName(caller string) string {
first, _, _ := strings.Cut(caller, ",")
var b strings.Builder
for _, r := range strings.TrimSpace(first) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@',
r == '-', r == ' ':
b.WriteRune(r)
default:
b.WriteRune('-')
}
if b.Len() >= 80 {
break
}
}
name := strings.TrimSpace(b.String())
if name == "" || strings.HasPrefix(name, "-") {
return "an unnamed caller"
}
return name
}
// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes.
@@ -95,20 +133,37 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
}
}
// The bounds, read and kept before anybody is asked to type (novox/hq ADR 0277): one open prompt per secret,
// few an hour. How the ask ends is recorded whatever happens below.
outcome := "failed"
if d.open != nil {
id, err := d.open(node, module, name, desk)
if err != nil {
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
}
defer func() {
if d.end != nil {
_ = d.end(id, outcome)
}
}()
}
public, private, err := secrets.Keypair()
if err != nil {
return "", fmt.Errorf("no key could be made to take the value: %w", err)
}
// By name, never by words: the holder writes the prompt from these, and says the controller asks, which
// the bus alone makes true (broker.ControllerOnly).
// the bus alone makes true (broker.ControllerOnly). Who asked is the bus's word on the caller, cut to a
// name's characters — never an argument of the call.
raw, err := d.ask(desk, map[string]any{
"module": module,
"secret": name,
"node": node,
"asked_by": askedByName(d.askedBy),
"seal_to": public,
"timeout_seconds": deskPromptWithin,
})
if err != nil {
outcome = "refused"
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
}
var answer struct {
@@ -121,8 +176,10 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
}
switch {
case answer.TimedOut:
outcome = "timed-out"
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
case answer.Cancelled:
outcome = "dismissed"
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
case answer.Sealed == "":
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
@@ -137,6 +194,7 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
opened[i] = 0
}
if strings.TrimSpace(value) == "" {
outcome = "empty"
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
}
untilStart, err := d.accept(value)
@@ -144,8 +202,10 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
if err != nil {
return "", err
}
outcome = "given"
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s", name, module, node, desk),
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s", name, module, node, desk,
askedByName(d.askedBy)),
Cause: "given-at-the-desk"}
recorded := ""
if err := d.record(act); err != nil {
@@ -165,15 +225,23 @@ func (d deskGive) give(node, module, name, desk string) (string, error) {
return words + recorded, nil
}
// giveAtDesk is `secret accept <node> <module> <name> --at-desk <machine>`: the desk path, on this
// controller's stores and bus.
func giveAtDesk(ctx context.Context, node, module, name, desk string) error {
// askAtDesk is `secret ask <node> <module> <name> [--at <machine>]`, and the terminal's `secret accept … --at-desk
// <machine>`: the desk path, on this controller's stores and bus. The desk is the module's machine unless named.
func askAtDesk(ctx context.Context, node, module, name, desk string) error {
if desk == "" {
desk = node
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
d := deskGive{
askedBy: link.Caller(),
open: func(node, module, name, desk string) (int64, error) {
return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk)
},
end: func(id int64, outcome string) error { return open.inventory.EndSecretAsk(ctx, id, outcome) },
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
known: func(machine string) error {
_, err := open.inventory.NodeByName(ctx, machine)
+117 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
@@ -124,12 +125,21 @@ func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) {
func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) {
argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
if err != nil || strings.Join(argv, " ") != "secret accept anchor telegram telegram-token --at-desk laptop" {
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" {
t.Fatalf("%v %v", argv, err)
}
if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil {
t.Error("give without a desk was taken")
}
// secret-ask is the same line, with the desk the module's machine unless named (novox/hq ADR 0277).
argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"})
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token" {
t.Fatalf("%v %v", argv, err)
}
argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" {
t.Fatalf("%v %v", argv, err)
}
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
if err != nil {
t.Fatal(err)
@@ -266,13 +276,22 @@ func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) {
}
}
// The `give` verb's own line passes the terminal-only rule of ADR 0266, and no other `secret accept` does: a
// value, a file, a provider or an extra word is still the terminal's alone.
// The `give` and `secret-ask` verbs' own line passes the terminal-only rule of ADR 0266, and no `secret accept`
// does: a value, a file, a provider or an extra word is still the terminal's alone (novox/hq ADR 0277).
func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) {
if err := terminalOnly([]string{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"}); err != nil {
t.Errorf("give's line refused: %v", err)
for _, argv := range [][]string{
{"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop"},
{"secret", "ask", "anchor", "telegram", "telegram-token"},
} {
if err := terminalOnly(argv); err != nil {
t.Errorf("%v refused: %v", argv, err)
}
}
for _, argv := range [][]string{
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"},
{"secret", "ask", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
{"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop", "--local"},
{"secret", "ask", "anchor", "telegram", "--at", "laptop"},
{"secret", "accept", "anchor", "telegram", "telegram-token"},
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop", "--local"},
@@ -398,3 +417,96 @@ func TestAGiveNamingAMachineTheMeshDoesNotKnowAsksNobody(t *testing.T) {
}
}
}
// novox/hq ADR 0277: the prompt names who asked — the controller's word on the bus's caller, cut to a name's
// characters — and never a word the caller chose: there is no argument for it.
func TestThePromptNamesWhoAskedFromTheBussWordAlone(t *testing.T) {
d, _, acts, asked := aDesk(t, sealedTo(t, typed))
d.askedBy = "g14/claude-code, through the mesh-controller seat"
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
t.Fatal(err)
}
if got := (*asked)[0]["asked_by"]; got != "g14/claude-code" {
t.Errorf("asked_by %q", got)
}
if len(*acts) != 1 || !strings.Contains((*acts)[0].Why, "asked by g14/claude-code") {
t.Errorf("the record: %+v", *acts)
}
for in, want := range map[string]string{
"jochen at a shell on novox": "jochen at a shell on novox",
"laptop/agent": "laptop/agent",
"Your bank asks\nType your PIN, now": "Your bank asks-Type your PIN",
"": "an unnamed caller",
"<b>x</b>": "an unnamed caller",
strings.Repeat("a", 100): strings.Repeat("a", 80),
"--prompt, something else": "an unnamed caller",
} {
if got := askedByName(in); got != want {
t.Errorf("askedByName(%q) = %q, want %q", in, got, want)
}
}
// The verb's schema has no argument that reaches the prompt's words.
for _, verb := range []string{"give", "secret-ask"} {
for _, free := range []string{"asked_by", "prompt", "message", "value", "from"} {
if _, err := argvFor(verb, map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token",
"at": "laptop", free: "x"}); err == nil {
t.Errorf("%s takes %s", verb, free)
}
}
}
}
// An ask for a secret is bounded before anybody is asked to type (ADR 0277): the record refuses it, nothing is
// asked; and how every ask ends is recorded.
func TestASecretAskIsBoundedAndItsEndRecorded(t *testing.T) {
d, accepted, _, asked := aDesk(t, sealedTo(t, typed))
var ended []string
d.open = func(node, module, name, desk string) (int64, error) { return 7, nil }
d.end = func(id int64, outcome string) error {
ended = append(ended, fmt.Sprintf("%d %s", id, outcome))
return nil
}
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
t.Fatal(err)
}
d.open = func(node, module, name, desk string) (int64, error) {
return 0, errors.New("an ask for telegram-token of telegram on anchor is still open")
}
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
if err == nil || !strings.Contains(err.Error(), "nobody was asked to type anything") || !strings.Contains(err.Error(), "still open") {
t.Errorf("a second ask: %v", err)
}
if len(*asked) != 1 || len(*accepted) != 1 {
t.Errorf("asked %d, accepted %d", len(*asked), len(*accepted))
}
d.open = func(node, module, name, desk string) (int64, error) { return 8, nil }
d.ask = func(string, map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"cancelled":true}`), nil
}
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); !errors.Is(err, errNothingGiven) {
t.Errorf("a dismissed prompt: %v", err)
}
if strings.Join(ended, "; ") != "7 given; 8 dismissed" {
t.Errorf("ended: %v", ended)
}
}
// A secret ask cannot be turned into a secret read: the line carries no value, the answer carries none, and every
// other `secret` line is the terminal's.
func TestASecretAskIsNeverASecretRead(t *testing.T) {
t.Setenv(verbVar, "mesh-controller.secret-ask")
for _, args := range [][]string{
{"ask", "anchor", "telegram", "telegram-token", "the-value"},
{"ask", "anchor", "telegram"},
{"ask", "anchor", "telegram", "telegram-token", "--from", "/dev/null"},
} {
if err := secretCommand(context.Background(), args); err == nil {
t.Errorf("secret %v was taken", args)
}
}
for _, args := range [][]string{{"recover", "anchor", "telegram", "telegram-token"}, {"export"}} {
if err := terminalOnly(append([]string{"secret"}, args...)); err == nil {
t.Errorf("secret %v passed the terminal rule", args)
}
}
}
+131 -10
View File
@@ -446,6 +446,47 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
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) {
plans, err := inv.OpenPlans(ctx)
if err != nil {
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
}
}
return "", nil
}
// deferredNote begins the note of a walk deferred behind another.
const deferredNote = "let go; starts once "
// deferred says a walk has its word and has not started: let go while another walk was started, it waits for
// that one to end (startedBeside), and its note says so. Read as a wait, not as a tier running late: `plans`
// and `status` say its note, and S3 leaves it out. A let-go walk whose first ask failed carries that error as
// its note instead, and is watched as before.
func deferred(p inventory.Plan) bool {
return p.Open() && p.Release == nil && p.Tier == 0 && !askedAny(p) && p.Delivery != nil &&
p.Delivery.Awaits != "" && p.Delivery.Go != nil && strings.HasPrefix(p.Note, deferredNote)
}
// askedAny says a plan asked any module.
func askedAny(p inventory.Plan) bool {
for _, s := range p.Modules {
if s != nil && s.State != "" {
return true
}
}
return false
}
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
// and whose gates are applied gives way to the next; the last tier done is the plan done. Called
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
@@ -578,6 +619,18 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
}
}
if unasked == len(tier) {
// **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.
if p.Tier == 0 {
if behind, err := startedBeside(ctx, inv, p.ID, p.Created); err != nil {
return false, err
} else if behind != "" {
note := fmt.Sprintf("%s%s ended — one walk at a time", deferredNote, behind)
changed := p.Note != note
p.Note = note
return changed, nil
}
}
if err := askTier(ctx, inv, p); err != nil {
return false, err
}
@@ -1181,27 +1234,38 @@ func inTierSince(p inventory.Plan) time.Time {
// planLineWith is planLine knowing whether the build seat is paused (novox/hq ADR 0219): a plan
// waiting on builds nobody will take until a person resumes the seat says so, and is not late. bound is
// the plan's tier bound, the one its stalled condition is raised at (tierBounds): LATE is that condition
// said on the line (novox/hq issue 296).
// said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn.
func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration) string {
return planLineOn(p, now, pause, bound, nil)
}
// planLineOn is planLineWith naming the plan by what it moves and where (planHeadline), given what runs each
// module; nil names no machine.
func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string) string {
name := planHeadline(p, running)
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
switch p.State {
case inventory.PlanAssembling, inventory.PlanQueued:
// A batch not yet a walk (novox/hq ADR 0276): what it holds and how long is left.
return batchWords(p, now)
case inventory.PlanDone:
return fmt.Sprintf("%s done, %d tier(s)", p.Named(), len(p.Tiers))
return fmt.Sprintf("%s · done, %d tier(s)", name, len(p.Tiers))
case inventory.PlanFailed:
return fmt.Sprintf("%s FAILED at %s: %s", p.Named(), where, p.Note)
return fmt.Sprintf("%s · FAILED at %s: %s", name, where, p.Note)
case inventory.PlanSuperseded:
return fmt.Sprintf("%s %s", p.Named(), p.Note)
return fmt.Sprintf("%s · %s", name, p.Note)
}
since := now.Sub(inTierSince(p)).Round(time.Second)
if p.Waiting() {
// Waiting for its delivery's word is no lateness of the walk's (novox/hq ADR 0239).
return fmt.Sprintf("%s %s, %s, for %s", p.Named(), where, waitingNote(p), since)
return fmt.Sprintf("%s · %s, %s, for %s", name, where, waitingNote(p), since)
}
if deferred(p) {
// Nor is waiting for the walk before it to end (one walk at a time, novox/hq ADR 0276).
return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, since)
}
if waiting, paused := pausedWaiting(p, pause, now); paused {
return fmt.Sprintf("%s %s, %s", p.Named(), where, waiting)
return fmt.Sprintf("%s · %s, %s", name, where, waiting)
}
late := ""
if since > bound {
@@ -1211,7 +1275,53 @@ func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.D
if p.State == inventory.PlanRolling {
what = p.Note
}
return fmt.Sprintf("%s %s, %s for %s%s", p.Named(), where, what, since, late)
return fmt.Sprintf("%s · %s, %s for %s%s", name, where, what, since, late)
}
// planHeadline names a plan as a person knows it (asked by the operator, 2026-10-10: a plan called by its
// repository alone said nothing of what it delivers): each repository as its pull request and title, what the
// plan moves, and the machines running that — "mesh-catalog #175 a tap shows its outcome · messenger,
// telegram → novox". A record naming no pull request is named by its commit, as before; one naming no moves
// says none; running nil names no machine.
func planHeadline(p inventory.Plan, running func(string) []string) string {
var repos []string
moves := map[string]bool{}
for _, c := range p.Carried() {
seg := repoName(c.Repository) + " " + short(c.Commit)
if p.Delivery != nil {
for _, m := range p.Delivery.Merges {
if !strings.EqualFold(m.Repository, c.Repository) {
continue
}
for _, n := range m.Moves {
moves[n] = true
}
if m.Commit == c.Commit && m.Number > 0 {
seg = repoName(c.Repository) + " " + pullWords(m)
}
}
}
repos = append(repos, seg)
}
out := strings.Join(repos, " + ")
if len(moves) == 0 {
return out
}
names := sortedKeysOf(boolsToStrings(moves))
out += " · " + strings.Join(names, ", ")
if running == nil {
return out
}
nodes := map[string]bool{}
for _, n := range names {
for _, node := range running(n) {
nodes[node] = true
}
}
if len(nodes) > 0 {
out += " → " + strings.Join(sortedKeysOf(boolsToStrings(nodes)), ", ")
}
return out
}
// planFailedBuild marks the module a failed build was for when the result names no module: by the
@@ -1353,8 +1463,11 @@ func plansCommand(ctx context.Context, args []string) error {
return err
}
bounds := readTierBounds(ctx, inv, now)
fmt.Printf("%s — %s\n", p.ID, planLineWith(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
bounds.of(p.Repository)))
fmt.Printf("%s — %s\n", p.ID, planLineOn(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
bounds.of(p.Repository), func(module string) []string {
on, _ := inv.Running(ctx, module)
return on
}))
if r := p.Release; r != nil {
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "),
@@ -1490,14 +1603,22 @@ func plansCommand(ctx context.Context, args []string) error {
}
for _, b := range batches {
fmt.Printf("%-28s %s\n", b.ID, batchWords(b, now))
// One line per repository: its pull request, what it answers, what it moves.
for _, line := range batchLines(b) {
fmt.Printf("%-28s %s\n", "", line)
}
}
pause := buildSeatPause(ctx, inv, plans)
bounds := readTierBounds(ctx, inv, now)
running := func(module string) []string {
on, _ := inv.Running(ctx, module)
return on
}
for _, p := range plans {
if p.Batch() {
continue
}
fmt.Printf("%-28s %s\n", p.ID, planLineWith(p, now, pause, bounds.of(p.Repository)))
fmt.Printf("%-28s %s\n", p.ID, planLineOn(p, now, pause, bounds.of(p.Repository), running))
}
return nil
}
+21 -5
View File
@@ -768,10 +768,22 @@ func (a *verbArguments) commandLine() ([]string, error) {
}
return append(argv, "--json"), nil
case "give":
// The same line as secret-ask with the desk named (novox/hq ADR 0277): one path, one set of bounds.
if err := need("node", "module", "secret", "at"); err != nil {
return nil, err
}
return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil
return []string{"secret", "ask", str("node"), str("module"), str("secret"), "--at", str("at")}, nil
case "secret-ask":
// A module's own secret asked for, typed by the operator at the desk (novox/hq ADR 0277): never a value
// in the arguments. The desk is the module's machine unless at names another.
if err := need("node", "module", "secret"); err != nil {
return nil, err
}
argv := []string{"secret", "ask", str("node"), str("module"), str("secret")}
if at := str("at"); at != "" {
argv = append(argv, "--at", at)
}
return argv, nil
case "rotate":
if p := str("provision"); p != "" {
argv := []string{"rotate", p}
@@ -1538,10 +1550,11 @@ var terminalOnlyCommands = map[string]string{
"licence": "the licences' secrets",
}
// givenAtTheDesk is exactly the line the `give` verb composes, and nothing beside it: `secret accept <node>
// <module> <secret> --at-desk <machine>`, with no other word — no value, no file, no provider.
// givenAtTheDesk is exactly the line the `give` and `secret-ask` verbs compose, and nothing beside it: `secret ask
// <node> <module> <secret>`, with `--at <machine>` or no other word — no value, no file, no provider (novox/hq
// ADR 0277). The terminal's own `secret accept … --at-desk` is the terminal's.
func givenAtTheDesk(argv []string) bool {
if len(argv) != 7 || argv[0] != "secret" || argv[1] != "accept" || argv[5] != "--at-desk" {
if (len(argv) != 5 && len(argv) != 7) || argv[0] != "secret" || argv[1] != "ask" {
return false
}
for _, w := range argv[2:5] {
@@ -1549,7 +1562,10 @@ func givenAtTheDesk(argv []string) bool {
return false
}
}
return argv[6] != "" && !strings.HasPrefix(argv[6], "-")
if len(argv) == 5 {
return true
}
return argv[5] == "--at" && argv[6] != "" && !strings.HasPrefix(argv[6], "-")
}
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
@@ -276,12 +276,6 @@ var accountedFlags = map[string]map[string]string{
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
},
// The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call.
"secret accept": {
"at-desk": "=at",
"from": "withheld: a file of the control node's is read at a shell, never named by a call",
"provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret",
"local": "withheld: it goes with --provider",
},
"hand-acts": {"json": "set by the verb: the answer is data"},
"conditions": {"json": "set by the verb: the answer is data"},
"retire": {"json": "set by the verb: the answer is data"},
+20 -1
View File
@@ -39,6 +39,8 @@ func secretCommand(ctx context.Context, args []string) error {
}
switch args[0] {
case "accept":
case "ask":
return secretAsk(ctx, args[1:])
case "rotate":
return secretRotate(ctx, args[1:])
case "recover":
@@ -78,7 +80,7 @@ func secretCommand(ctx context.Context, args []string) error {
if *from != "" || *provider != "" {
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
}
return giveAtDesk(ctx, node, module, name, *desk)
return askAtDesk(ctx, node, module, name, *desk)
}
value, err := valueFor(node, module, name, *from)
@@ -148,7 +150,24 @@ func secretCommand(ctx context.Context, args []string) error {
return nil
}
// secretAsk is `secret ask <node> <module> <name> [--at <machine>]` (novox/hq ADR 0277): the operator is asked
// for a module's own secret in a prompt at the desk, which only they answer. The one `secret` line a verb may
// run beside rotate (givenAtTheDesk): it carries no value and answers none.
func secretAsk(ctx context.Context, args []string) error {
rest, flags := split(args)
set := flag.NewFlagSet("secret ask", flag.ContinueOnError)
at := set.String("at", "", "the machine the operator sits at, where the prompt opens; the module's machine when absent")
if err := set.Parse(flags); err != nil {
return err
}
if len(rest) != 3 {
return errors.New("secret ask <node> <module> <name> [--at <machine>]")
}
return askAtDesk(ctx, rest[0], rest[1], rest[2], *at)
}
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
"secret ask <node> <module> <name> [--at <machine>]\n" +
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]"
+5
View File
@@ -490,6 +490,11 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b
awaits: p.Delivery.Awaits, since: p.Created, modules: planModules(p), merges: p.Delivery.Merges})
continue
}
// A walk let go and waiting for the walk before it to end (ADR 0276) is no tier late either: the walk
// before it is the one S3 watches.
if deferred(p) {
continue
}
_, paused := pausedWaiting(p, pause, now)
bound := bounds.of(p.Repository)
out = append(out, planFacts{id: p.ID, repository: p.Repository, commit: p.Commit, tier: p.Tier,
+20 -5
View File
@@ -231,17 +231,32 @@ var ControllerVerbs = []Verb{
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
}, nil)},
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
"§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " +
"back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " +
"The value is never an argument and never in the answer: the answer says it was taken, or why not. " +
"Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " +
"or unanswered, nothing changes. Then push the machine.",
"§10): the same as secret-ask with the desk named. A prompt that does not show what is typed opens on " +
"the machine named by at, its answer comes back sealed to this call alone, and is sealed to the " +
"module's machine as `secret accept` seals it. The value is never an argument and never in the answer: " +
"the answer says it was taken, or why not. Recorded in the hand-act log as a value given at the desk. " +
"The prompt waits 25 seconds; dismissed or unanswered, nothing changes. Then push the machine.",
Input: schema(map[string]string{
"node": "the machine the module runs on, which the secret is sealed to",
"module": "the module's name",
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens",
}, []string{"node", "module", "secret", "at"})},
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
"and never in a verb's argument; the answer comes back sealed to this call alone and is sealed to the " +
"module's machine as `secret accept` seals it. The answer says the value was taken, or why not. " +
"Refused for a secret the mesh issues itself (the bus account, one the mesh may make) and for a module " +
"that runs as an account of its own, whose value is typed at the controller's terminal. Bounded: one " +
"open prompt per secret, three asks an hour. Recorded in the hand-act log, with who asked, and " +
"announced on every channel. Then push the machine.",
Input: schema(map[string]string{
"node": "the machine the module runs on, which the secret is sealed to",
"module": "the module's name",
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens; the module's machine when absent",
}, []string{"node", "module", "secret"})},
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
@@ -0,0 +1,18 @@
-- A module's own secret asked for at a desk (novox/hq ADR 0277).
--
-- Every ask for a module's own secret at a desk: who asked, for which secret of which module on which
-- machine, at which desk, and how it ended. Read before a prompt opens, so that one open ask per secret and
-- few per hour hold: an agent that keeps a prompt in front of the operator until they type is refused.
create table secret_ask (
id bigserial primary key,
node uuid not null references node(id) on delete cascade,
module text not null,
name text not null,
asked_by text not null,
desk text not null,
opened_at timestamptz not null default now(),
ended_at timestamptz,
-- given · dismissed · timed-out · empty · refused · failed
outcome text
);
create index secret_ask_by_secret on secret_ask (node, module, name, opened_at desc);
+5
View File
@@ -71,6 +71,11 @@ type PlanMerge struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
Carried string `json:"carried,omitempty"`
// Number and Title are the merge's pull request as the forge announced it, and Moves the modules the merge
// moved when it was heard: what `plans` names a walk by. Empty where the announcement said none.
Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"`
Moves []string `json:"moves,omitempty"`
}
// PlanBatch is a batch's window while it is one (novox/hq ADR 0276): when it closes unless another merge
+110
View File
@@ -0,0 +1,110 @@
package inventory
import (
"context"
"fmt"
"time"
)
// An ask for a module's own secret at a desk (novox/hq ADR 0277, migration 0091): every one is recorded
// before the prompt opens, and the bounds are read from the record. A verb may ask the operator to type a
// secret; it may not keep a prompt in front of them. **One open ask per secret, and few per hour.**
// The bounds of asking for one secret.
const (
// SecretAskOpenFor is how long an ask that has not ended counts as open: longer than any prompt waits,
// so a process that died with its prompt does not hold the secret for ever.
SecretAskOpenFor = 2 * time.Minute
// SecretAsksPerHour is how many asks for one secret an hour takes.
SecretAsksPerHour = 3
)
// OpenSecretAsk records that an ask for a module's own secret on a machine opens at a desk, or refuses it in
// words when one is still open for that secret or the hour's asks are spent. It answers the record's id, which
// EndSecretAsk closes.
func (i *Inventory) OpenSecretAsk(ctx context.Context, node, module, name, askedBy, desk string) (int64, error) {
record, err := i.NodeByName(ctx, node)
if err != nil {
return 0, err
}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
// Serialised per secret, so two asks at once do not both pass the count.
if _, err := tx.Exec(ctx, `select pg_advisory_xact_lock(hashtext($1))`, node+"/"+module+"/"+name); err != nil {
return 0, err
}
var open int
var openBy string
if err := tx.QueryRow(ctx,
`select count(*), coalesce(min(asked_by), '') from secret_ask
where node = $1 and module = $2 and name = $3 and ended_at is null and opened_at > now() - $4::interval`,
record.ID, module, name, SecretAskOpenFor.String()).Scan(&open, &openBy); err != nil {
return 0, err
}
if open > 0 {
return 0, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a secret, "+
"and this one ends within %s", name, module, node, openBy, SecretAskOpenFor)
}
var lastHour int
if err := tx.QueryRow(ctx,
`select count(*) from secret_ask
where node = $1 and module = $2 and name = $3 and opened_at > now() - interval '1 hour'`,
record.ID, module, name).Scan(&lastHour); err != nil {
return 0, err
}
if lastHour >= SecretAsksPerHour {
return 0, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks for one "+
"secret: the operator is not kept at a prompt", name, module, node, lastHour, SecretAsksPerHour)
}
var id int64
if err := tx.QueryRow(ctx,
`insert into secret_ask (node, module, name, asked_by, desk) values ($1, $2, $3, $4, $5) returning id`,
record.ID, module, name, askedBy, desk).Scan(&id); err != nil {
return 0, err
}
return id, tx.Commit(ctx)
}
// EndSecretAsk closes an ask with how it ended: given, dismissed, timed-out, empty, refused or failed.
func (i *Inventory) EndSecretAsk(ctx context.Context, id int64, outcome string) error {
_, err := i.store.Pool().Exec(ctx,
`update secret_ask set ended_at = now(), outcome = $2 where id = $1 and ended_at is null`, id, outcome)
return err
}
// SecretAsk is one recorded ask for a module's own secret.
type SecretAsk struct {
ID int64
Node string
Module string
Name string
AskedBy string
Desk string
OpenedAt time.Time
EndedAt *time.Time
Outcome string
}
// SecretAsks is every ask for secrets since a moment, newest first.
func (i *Inventory) SecretAsks(ctx context.Context, since time.Time) ([]SecretAsk, error) {
rows, err := i.store.Pool().Query(ctx,
`select a.id, n.name, a.module, a.name, a.asked_by, a.desk, a.opened_at, a.ended_at, coalesce(a.outcome, '')
from secret_ask a join node n on n.id = a.node
where a.opened_at >= $1 order by a.opened_at desc`, since)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SecretAsk
for rows.Next() {
var a SecretAsk
if err := rows.Scan(&a.ID, &a.Node, &a.Module, &a.Name, &a.AskedBy, &a.Desk, &a.OpenedAt, &a.EndedAt, &a.Outcome); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
+60
View File
@@ -0,0 +1,60 @@
package inventory
import (
"strings"
"testing"
"time"
)
// An ask for a module's own secret at a desk is bounded by the record (novox/hq ADR 0277): one open per secret,
// three an hour, and every one says who asked and how it ended.
func TestASecretAskIsOneAtATimeAndFewAnHour(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
if _, err := inv.AddNode(ctx, "shanks"); err != nil {
t.Fatal(err)
}
first, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks")
if err != nil {
t.Fatal(err)
}
if _, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "laptop/agent", "shanks"); err == nil ||
!strings.Contains(err.Error(), "still open") || !strings.Contains(err.Error(), "g14/claude-code") {
t.Errorf("a second ask while one is open: %v", err)
}
// Another secret is its own.
other, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "other", "laptop/agent", "shanks")
if err != nil {
t.Fatal(err)
}
if err := inv.EndSecretAsk(ctx, other, "dismissed"); err != nil {
t.Fatal(err)
}
if err := inv.EndSecretAsk(ctx, first, "given"); err != nil {
t.Fatal(err)
}
for i := 0; i < SecretAsksPerHour-1; i++ {
id, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks")
if err != nil {
t.Fatalf("ask %d: %v", i+2, err)
}
if err := inv.EndSecretAsk(ctx, id, "timed-out"); err != nil {
t.Fatal(err)
}
}
if _, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks"); err == nil ||
!strings.Contains(err.Error(), "times in the last hour") {
t.Errorf("a fourth ask in an hour: %v", err)
}
if _, err := inv.OpenSecretAsk(ctx, "nowhere", "mounts", "smb-credentials", "x", "nowhere"); err == nil {
t.Error("a machine the mesh does not know was taken")
}
asks, err := inv.SecretAsks(ctx, time.Now().Add(-time.Hour))
if err != nil || len(asks) != SecretAsksPerHour+1 {
t.Fatalf("%d asks, %v", len(asks), err)
}
if a := asks[len(asks)-1]; a.Node != "shanks" || a.Module != "mounts" || a.Name != "smb-credentials" || a.AskedBy != "g14/claude-code" ||
a.Outcome != "given" || a.EndedAt == nil {
t.Errorf("the first ask: %+v", a)
}
}
+4 -2
View File
@@ -210,9 +210,11 @@ type SourceMoved struct {
// stands: a directory is a module only when the merge changed its manifest.
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
// Number is the pull request's, and Body its description (novox/hq ADR 0276): a delivery group's
// `after:` lines, read when its members are merged into one batch and their order becomes the walk's.
// Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): a delivery
// group's `after:` lines, read when its members are merged into one batch and their order becomes the
// walk's, and the words `plans` names a walk by.
Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}
+1
View File
@@ -76,6 +76,7 @@
"hand-act",
"drill",
"warranted",
"secret-ask",
"hand-acts",
"durations",
"conditions",