From b5a441a9784682f75b24f130c9e8a8f664ad4e78 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 17:18:23 +0200 Subject: [PATCH 1/5] Let a module-declared seat carry needs, so mail-notifier can need node-notifier (issue 802) ADR 0220's dependency mechanism served compiled seats only and retired its one user with node-resolver-config (commit f68521da). ADR 0378 S8 revives it for module-declared seats: SeatDeclaration gains Needs, judged by the catalogue check (node scope only, a name that resolves to a node seat somewhere in the catalogue, never itself), and DependsOn now takes the catalogue so a claimed seat's needs are found wherever they are declared. --- .../contribution_dependencies_test.go | 4 +- internal/catalogue/data_test.go | 6 +- .../resolver_file_is_the_uplinks_test.go | 2 +- internal/catalogue/seat_contributions_test.go | 2 +- internal/catalogue/seat_data_test.go | 4 +- internal/catalogue/seat_dependencies.go | 49 +++++++++++---- internal/catalogue/seat_dependencies_test.go | 36 ++++++++++- internal/catalogue/seats_declared.go | 61 +++++++++++++++++++ internal/catalogue/seats_declared_test.go | 47 ++++++++++++++ 9 files changed, 188 insertions(+), 23 deletions(-) diff --git a/internal/catalogue/contribution_dependencies_test.go b/internal/catalogue/contribution_dependencies_test.go index e56c9579..91e9bc03 100644 --- a/internal/catalogue/contribution_dependencies_test.go +++ b/internal/catalogue/contribution_dependencies_test.go @@ -34,7 +34,7 @@ func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) { "an empty environment": {empty, nil}, } for name, c := range cases { - got := DependsOn(c.m) + got := DependsOn(nil, c.m) if len(got) == 0 { got = nil } @@ -118,7 +118,7 @@ func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) { for _, moment := range powerMoments { m := mod("laptop", nil, nil, nil) m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}} - if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) { + if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) { t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat) } if p := m.shellProblems(); len(p) != 0 { diff --git a/internal/catalogue/data_test.go b/internal/catalogue/data_test.go index fde16ca2..1f379cb1 100644 --- a/internal/catalogue/data_test.go +++ b/internal/catalogue/data_test.go @@ -197,9 +197,9 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { } // What keeps something irreplaceable depends on the machine's backup holder — it backs it up or // watches its array; valuable data alone is backed up where a holder is, and refused nowhere. - if !slices.Contains(DependsOn(media), BackupSeat) || !slices.Contains(DependsOn(pg), BackupSeat) || - slices.Contains(DependsOn(agent), BackupSeat) { - t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(media), DependsOn(pg), DependsOn(agent)) + if !slices.Contains(DependsOn(nil, media), BackupSeat) || !slices.Contains(DependsOn(nil, pg), BackupSeat) || + slices.Contains(DependsOn(nil, agent), BackupSeat) { + t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(nil, media), DependsOn(nil, pg), DependsOn(nil, agent)) } if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" { t.Fatalf("an operator's path reads %+v", it) diff --git a/internal/catalogue/resolver_file_is_the_uplinks_test.go b/internal/catalogue/resolver_file_is_the_uplinks_test.go index 768d37eb..dc2de447 100644 --- a/internal/catalogue/resolver_file_is_the_uplinks_test.go +++ b/internal/catalogue/resolver_file_is_the_uplinks_test.go @@ -14,7 +14,7 @@ func TestTheResolverConfigSeatIsGone(t *testing.T) { t.Error("node-resolver-config is still in the mesh's set; the uplink's holder writes the resolver file") } // An uplink's holder needs no seat beside it for the file: it is its own. - if got := DependsOn(mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 { + if got := DependsOn(nil, mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 { t.Errorf("an uplink holder with nothing declared depends on %v", got) } } diff --git a/internal/catalogue/seat_contributions_test.go b/internal/catalogue/seat_contributions_test.go index 303e40ba..ad6d31e2 100644 --- a/internal/catalogue/seat_contributions_test.go +++ b/internal/catalogue/seat_contributions_test.go @@ -95,7 +95,7 @@ func TestAContributionDependsOnItsSeat(t *testing.T) { {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, {Seat: DisplaySessionSeat, Kind: "config", Content: "y"}, }} - if got, want := DependsOn(m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) { + if got, want := DependsOn(nil, m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) { t.Errorf("depends on %v, want %v", got, want) } catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()} diff --git a/internal/catalogue/seat_data_test.go b/internal/catalogue/seat_data_test.go index de83c37b..3389ede1 100644 --- a/internal/catalogue/seat_data_test.go +++ b/internal/catalogue/seat_data_test.go @@ -333,9 +333,9 @@ func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) { block(`{"bar":"bottom","place":"status","shows":"battery"}`), {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, }} - for _, seat := range DependsOn(power) { + for _, seat := range DependsOn(nil, power) { if seat == BarSeat { - t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(power)) + t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(nil, power)) } } // And on a machine with no bar it is placed nowhere, and nothing fails. diff --git a/internal/catalogue/seat_dependencies.go b/internal/catalogue/seat_dependencies.go index 08e30a3e..bde015a9 100644 --- a/internal/catalogue/seat_dependencies.go +++ b/internal/catalogue/seat_dependencies.go @@ -6,10 +6,12 @@ import ( "strings" ) -// A module depends on the node seats that apply its resources (novox/hq ADR 0207) and on the -// seats it contributes to (novox/hq ADR 0210). A third source — what a seat it holds needs beside it -// (novox/hq ADR 0220) — had one user, node-resolver-config needing node-uplink, and went with that -// seat when the resolver file became the uplink's own (novox/hq ADR 0223). +// A module depends on the node seats that apply its resources (novox/hq ADR 0207), on the +// seats it contributes to (novox/hq ADR 0210), and on the seats a seat it holds needs beside it +// (novox/hq ADR 0220). The third source once served only compiled seats and had one user, +// node-resolver-config needing node-uplink, which went with that seat when the resolver file became +// the uplink's own (novox/hq ADR 0223) — and now serves module-declared seats instead: mail-notifier +// needing node-notifier, and the like (novox/hq ADR 0378 §8). // // Some of what a module declares is applied through software on the machine that is itself a // module: a service through the service manager, a package through the package manager, a container @@ -76,13 +78,16 @@ func isFoundation(m Manifest) bool { return foundationModules[m.Module] || m.Computed != "" } -// DependsOn is every seat a module needs held on its node, derived from the resource types it -// declares itself (novox/hq ADR 0207 §2), sorted. +// DependsOn is every seat a module needs held on its node: derived from the resource types it +// declares itself (novox/hq ADR 0207 §2), from what it contributes to (ADR 0210), and from what a +// node seat it claims needs beside it — read from whichever manifest in `catalogue` declares that +// seat (novox/hq ADR 0220, ADR 0378 §8). Sorted. // -// **The module's own `resources` only.** What the controller composes around a module — its -// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the -// module is assigned, and depending on it would make the module answer for the mesh's choices. -func DependsOn(m Manifest) []string { +// **The module's own `resources` only**, for the first source. What the controller composes around +// a module — its filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put +// there because the module is assigned, and depending on it would make the module answer for the +// mesh's choices. +func DependsOn(catalogue map[string]Manifest, m Manifest) []string { if isFoundation(m) { return nil } @@ -95,6 +100,9 @@ func DependsOn(m Manifest) []string { for _, seat := range contributedTo(m) { seen[seat] = true } + for _, seat := range neededBesideClaims(catalogue, m) { + seen[seat] = true + } out := make([]string, 0, len(seen)) for s := range seen { out = append(out, s) @@ -135,6 +143,25 @@ func contributedTo(m Manifest) []string { return out } +// neededBesideClaims is every seat a node seat the module claims needs held on the same node +// (novox/hq ADR 0220, ADR 0378 §8): a module-declared seat's needs are read from whichever manifest +// in the catalogue declares that seat, never written again in the claimant's own manifest. Derived +// from the claim, as a resource's seat is derived from its type, so a module that claims the seat +// cannot leave the dependency out. A compiled seat carries no needs (novox/hq ADR 0378 §8). +func neededBesideClaims(catalogue map[string]Manifest, m Manifest) []string { + var out []string + for _, seat := range nodeSeatsClaimed(m) { + for _, candidate := range catalogue { + for _, s := range candidate.DefinesSeats { + if s.Name == seat { + out = append(out, s.Needs...) + } + } + } + } + return out +} + // claimsSeat is whether a module claims a node seat, by its current name or one it used to have // (ADR 0122), so a rename leaves the dependency met. func claimsSeat(m Manifest, seat string) bool { @@ -205,7 +232,7 @@ func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manife if judged != nil && !judged[m.Module] { continue } - for _, seat := range DependsOn(m) { + for _, seat := range DependsOn(catalogue, m) { if held[seat] { continue } diff --git a/internal/catalogue/seat_dependencies_test.go b/internal/catalogue/seat_dependencies_test.go index fc76f9c6..7c717d63 100644 --- a/internal/catalogue/seat_dependencies_test.go +++ b/internal/catalogue/seat_dependencies_test.go @@ -51,7 +51,7 @@ func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *test "action": nil, "network": nil, "access": nil, } for kind, want := range cases { - got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x"))) + got := DependsOn(nil, withResources(mod("m", nil, nil, nil), res(kind, "x"))) if len(got) == 0 { got = nil } @@ -59,7 +59,7 @@ func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *test t.Errorf("a %s resource depends on %v, want %v", kind, got, want) } } - all := DependsOn(withResources(mod("m", nil, nil, nil), + all := DependsOn(nil, withResources(mod("m", nil, nil, nil), res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d"))) if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) { t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want) @@ -207,7 +207,7 @@ func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) { cat := shelf(append(coreThree(), network, host)...) for _, m := range []Manifest{network, host} { - if d := DependsOn(m); len(d) != 0 { + if d := DependsOn(cat, m); len(d) != 0 { t.Errorf("%s, the foundation's, depends on %v", m.Module, d) } } @@ -223,6 +223,36 @@ func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) { } } +// A module-declared seat's needs are judged exactly as a compiled seat's once were (novox/hq ADR +// 0220, ADR 0378 §8): "mail" declares the node seat "mail-notifier" needing node-notifier, the +// mesh's own (desktop notifications); "notifier" claims mail-notifier; "dunst" claims node-notifier, +// as it does in the real catalogue. +func mailNotifierAndDunst() map[string]Manifest { + return shelf( + Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ + Name: "mail-notifier", Scope: ScopeNode, Needs: []string{NotifierSeat}, + }}}, + mod("notifier", nil, nil, nil, Claim{Name: "mail-notifier"}), + mod("dunst", nil, nil, nil, Claim{Name: NotifierSeat}), + ) +} + +func TestAModuleDeclaredSeatsNeedsAreJudgedLikeACompiledSeatsOnceWere(t *testing.T) { + cat := mailNotifierAndDunst() + _, err := AssignRefusal(cat, "workstation", nil, []string{"notifier"}) + var refusal *Refusal + if !errors.As(err, &refusal) || !strings.Contains(err.Error(), "node-notifier") { + t.Fatalf("notifier assigned without dunst was not refused naming node-notifier: %v", err) + } + if _, err := AssignRefusal(cat, "workstation", []string{"dunst"}, []string{"notifier"}); err != nil { + t.Errorf("notifier beside dunst was refused: %v", err) + } + if err := UnassignRefusal(cat, "workstation", []string{"dunst", "notifier"}, []string{"dunst"}); err == nil || + !strings.Contains(err.Error(), "node-notifier") { + t.Errorf("taking dunst from under notifier was not refused: %v", err) + } +} + func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) { sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd")) cat := shelf(append(coreThree(), sshd)...) diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index 77c64810..d53d3e04 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -71,6 +71,12 @@ type SeatDeclaration struct { // mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat // names. A module's seat never steps back. Paths []string `json:"paths,omitempty"` + // Needs is every node seat this seat's holder needs held on its own node (novox/hq ADR 0220, ADR + // 0378 §8): a role whose holder is only right while another role is filled beside it. A module + // claiming this seat depends on each, exactly as a module declaring a service depends on the + // service manager (ADR 0207) — derived from the claim, never written again in the claimant's own + // manifest. Only a node seat may carry needs, and a compiled seat carries none. + Needs []string `json:"needs,omitempty"` } // KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator, @@ -168,6 +174,7 @@ func declaredSeatProblems(m Manifest) []string { } problems = append(problems, trafficProblems(m, s)...) problems = append(problems, declaredSeatPathProblems(m, s)...) + problems = append(problems, declaredSeatNeedsProblems(m, s)...) } for _, u := range m.Uses { @@ -228,6 +235,30 @@ func trafficProblems(m Manifest, s SeatDeclaration) []string { return problems } +// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR +// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may +// carry needs; and a seat needing itself names nothing that was not already true of holding it. +// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems' +// to say, once every manifest is known. +func declaredSeatNeedsProblems(m Manifest, s SeatDeclaration) []string { + var problems []string + if len(s.Needs) == 0 { + return problems + } + if s.At() != ScopeNode { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing %s, and only a node seat needs another held beside it "+ + "(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", "))) + } + for _, need := range s.Needs { + if need == s.Name { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name)) + } + } + return problems +} + // A Shelf is every manifest the mesh has registered, by module name. type Shelf map[string]Manifest @@ -280,6 +311,36 @@ func CatalogueProblems(shelf Shelf) []string { return ok } + // atNode says whether a seat, compiled or declared, is a node seat — judged here because only + // the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8). + atNode := func(seat string) bool { + if s, isMesh := SeatNamed(seat); isMesh { + return s.Scope == ScopeNode + } + if s, ok := declared[seat]; ok { + return s.At() == ScopeNode + } + return false + } + + // A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8): + // whether one exists at all, compiled or declared by any module, only the whole catalogue can + // say — a single manifest's own problems (declaredSeatProblems) judge its needs no further. + for _, module := range shelfOrder(shelf) { + for _, s := range shelf[module].DefinesSeats { + for _, need := range s.Needs { + if !exists(need) { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing %s, "+UndeclaredSeat, module, s.Name, need)) + } else if !atNode(need) { + problems = append(problems, fmt.Sprintf( + "%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)", + module, s.Name, need)) + } + } + } + } + // Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses. named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy) problems = append(problems, pathProblems...) diff --git a/internal/catalogue/seats_declared_test.go b/internal/catalogue/seats_declared_test.go index 42958a83..33b3ba6c 100644 --- a/internal/catalogue/seats_declared_test.go +++ b/internal/catalogue/seats_declared_test.go @@ -116,6 +116,53 @@ func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) { } } +// Only a node seat may carry needs (novox/hq ADR 0378 §8): a per-mesh seat's holder is not on any +// one node to depend on anything beside it there. +func TestOnlyANodeSeatMayCarryNeeds(t *testing.T) { + m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ + Name: "mail-notifier", Scope: ScopeMesh, Needs: []string{"node-notifier"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + if !strings.Contains(got, "needing") || !strings.Contains(got, "node seat") { + t.Fatalf("a mesh-scope seat's needs were accepted: %q", got) + } +} + +// A seat needing itself names nothing (novox/hq ADR 0378 §8). +func TestASeatCannotNeedItself(t *testing.T) { + m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ + Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"mail-notifier"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + if !strings.Contains(got, "needing itself") { + t.Fatalf("a seat needing itself was accepted: %q", got) + } +} + +// A need naming nothing node-scope — undeclared, or declared at another scope — is the whole +// catalogue's to say (novox/hq ADR 0378 §8). +func TestANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) { + mail := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ + Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"desk-beeper"}}}} + + // Nobody declares desk-beeper at all. + got := problemsFor(t, Shelf{"mail": mail}) + if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "no module declares") { + t.Fatalf("needing an undeclared seat was accepted: %q", got) + } + + // Declared, but at mesh scope: still not a node seat to hold beside it. + notNode := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeMesh}}} + got = problemsFor(t, Shelf{"mail": mail, "dunst": notNode}) + if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "not a node seat") { + t.Fatalf("needing a mesh-scope seat was accepted: %q", got) + } + + // Declared at node scope: accepted. + dunst := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeNode}}} + if got := problemsFor(t, Shelf{"mail": mail, "dunst": dunst}); got != "" { + t.Fatalf("needing a node seat another module declares was refused: %q", got) + } +} + // A refusal that reorders itself between runs is a refusal nobody can diff. func TestTheProblemsAreStable(t *testing.T) { shelf := Shelf{"telegram": telegram(), "shop": {Module: "shop", Uses: []string{"nope"}}, -- 2.54.0 From 435468c1519718b391605d75520e7c47ffb80b5d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 17:45:45 +0200 Subject: [PATCH 2/5] A secret given tells the operator once, opens no condition (issue 835) ADR 0380: giving a module's own secret now asks the operator channel's tell over the bus, naming the secret, where, when and who asked, and raises no secret-given condition. Until nothing serves tell yet, or the controller's grant is refused (link.ErrNothingServes / the new link.ErrGrantRefused), the condition is raised exactly as before, so the change composes whichever side of issue 797 merges first. A successful tell also retires every open secret-given condition. The terminal's own secret accept now records a hand act too, as the desk path already did. The controller's bus grant for operator-channel.tell is added to the composed config (golden fixture updated). --- cmd/mesh-controller/desk_secret.go | 92 ++++++++++++++++-- cmd/mesh-controller/desk_secret_test.go | 122 ++++++++++++++++++++++++ cmd/mesh-controller/secret.go | 19 +++- internal/broker/nats.go | 9 ++ internal/broker/testdata/composed.conf | 2 +- internal/link/queue.go | 4 +- internal/link/retirement.go | 5 + 7 files changed, 242 insertions(+), 11 deletions(-) diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go index 67f242cb..96524993 100644 --- a/cmd/mesh-controller/desk_secret.go +++ b/cmd/mesh-controller/desk_secret.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "strings" "time" @@ -253,8 +254,9 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error { return err } defer open.Close() + askedBy := link.Caller() d := deskGive{ - askedBy: link.Caller(), + askedBy: askedBy, open: func(node, module, name, desk string) (int64, error) { return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk) }, @@ -291,7 +293,9 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error { return err }) }, - announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) }, + announce: func(node, module, name, how string) error { + return announceSecretGiven(ctx, node, module, name, how, askedBy) + }, } words, err := d.give(node, module, name, desk) if err != nil { @@ -342,10 +346,84 @@ func secretNameWords(name string) string { return strings.Join(strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == '.' }), " ") } -// announceSecretGiven raises it on this controller's keeper. -func announceSecretGiven(ctx context.Context, node, module, name, how string) error { - return withKeeper(ctx, func(k *conditions.Keeper) error { - _, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, time.Now())) - return err +// tellWithin is how long the operator channel's `tell` is given to answer: well under a human's patience, +// and short because nothing in the call waits on the operator — it asks nothing (novox/hq ADR 0380). +const tellWithin = 10 * time.Second + +// askOperatorChannel asks the operator channel's seat (the holder issue 797 gives it) its `tell` verb, +// through the controller's grant (novox/hq ADR 0380 §8), over this controller's own bus connection; a seam +// a test replaces whole. The holder's own refusal is an error naming it; an absent holder or a refused +// grant answer as link.ErrNothingServes or link.ErrGrantRefused (checked with errors.Is), so the caller can +// tell the two apart from any other failure. +var askOperatorChannel = func(ctx context.Context, args map[string]any) (json.RawMessage, error) { + var result json.RawMessage + err := onTheBus(func(conn *nats.Conn) error { + answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell", args, tellWithin) + if err != nil { + return err + } + if answer.Error != "" { + return fmt.Errorf("operator-channel.tell refused: %s", answer.Error) + } + result = answer.Result + return nil }) + return result, err +} + +// announceSecretGiven tells the operator, once, that a module's own secret was given (novox/hq ADR 0380): +// one plain message through the operator channel's `tell`, naming the secret, the module, the machine, +// where and when it was given and who asked — asking nothing, and opening no condition. **The controller's +// change waits for that verb at run time, not at merge** (clause 9): while nothing on the bus yet serves it, +// or the controller's own grant is refused, the secret-given condition is raised exactly as before ADR 0380 +// — any other failure (the holder itself refusing, or answering something unreadable) is the announcement's +// own failure, said to the caller as it always was. +func announceSecretGiven(ctx context.Context, node, module, name, how, askedBy string) error { + at := time.Now() + body := map[string]any{ + "headline": fmt.Sprintf("A password was entered for %s's %s", module, secretNameWords(name)), + "lines": []string{ + fmt.Sprintf("%s at %s, asked by %s.", how, at.Local().Format("15:04"), askedByName(askedBy)), + "If that was not you, give it again yourself at the controller's terminal.", + }, + } + _, told := askOperatorChannel(ctx, body) + switch { + case told == nil: + clearSecretGivenConditions(ctx) + return nil + case errors.Is(told, link.ErrNothingServes), errors.Is(told, link.ErrGrantRefused): + log.Printf("secret-given: %s's %s on %s could not be told through the operator channel (%v); the "+ + "secret-given condition is raised as it was before novox/hq ADR 0380 (its clause 9)", module, name, + node, told) + return withKeeper(ctx, func(k *conditions.Keeper) error { + _, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, at)) + return err + }) + default: + return told + } +} + +// clearSecretGivenConditions retires every open secret-given condition once a plain message has replaced +// it (novox/hq ADR 0380 §10): called after the first time a secret-given message is told successfully. No +// resolved line is sent — the condition is simply gone — and why is kept in the keeper's own history. +func clearSecretGivenConditions(ctx context.Context) { + if err := withKeeper(ctx, func(k *conditions.Keeper) error { + open, err := k.Open(ctx) + if err != nil { + return err + } + for _, c := range open { + if c.Kind != kindSecretGiven { + continue + } + if _, err := k.ClearSaying(ctx, c.Key, "retired by novox/hq ADR 0380", ""); err != nil { + return err + } + } + return nil + }); err != nil { + log.Printf("secret-given: the open secret-given conditions, retired by ADR 0380, could not be cleared: %v", err) + } } diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go index d2cc26a9..00e41f81 100644 --- a/cmd/mesh-controller/desk_secret_test.go +++ b/cmd/mesh-controller/desk_secret_test.go @@ -556,3 +556,125 @@ func TestASecretAskIsNeverASecretRead(t *testing.T) { } } } + +// askOperatorChannelFake replaces askOperatorChannel for the test and records every call it was given. +func askOperatorChannelFake(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) *[]map[string]any { + t.Helper() + var calls []map[string]any + was := askOperatorChannel + askOperatorChannel = func(_ context.Context, args map[string]any) (json.RawMessage, error) { + calls = append(calls, args) + return answer(args) + } + t.Cleanup(func() { askOperatorChannel = was }) + return &calls +} + +// announceWithRealTell wires a deskGive's announce to the real announceSecretGiven, as askAtDesk does, +// rather than the fakes the tests above use to look at what `give` does around it. +func announceWithRealTell(ctx context.Context, askedBy string) func(node, module, name, how string) error { + return func(node, module, name, how string) error { + return announceSecretGiven(ctx, node, module, name, how, askedBy) + } +} + +// novox/hq ADR 0380 clauses 1-2: a secret given at the desk is told to the operator once, in a plain +// message through the operator channel's `tell`, naming the secret, the module, where and when and who +// asked, and opens no condition — the kind the review of 2026-10-09 (M4) raised is retired. +func TestASecretGivenAtTheDeskIsToldOnceAndOpensNoCondition(t *testing.T) { + ctx := context.Background() + k, _ := withConditionsInMemory(t) + calls := askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, nil }) + d, _, _, _ := aDesk(t, sealedTo(t, typed)) + d.announce = announceWithRealTell(ctx, "mail-seats-835@novox") + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + if len(*calls) != 1 { + t.Fatalf("told the operator %d times, want exactly 1: %v", len(*calls), *calls) + } + call := (*calls)[0] + if call["headline"] != "A password was entered for telegram's telegram token" { + t.Errorf("headline %v", call["headline"]) + } + lines, _ := call["lines"].([]string) + if len(lines) != 2 || !strings.Contains(lines[0], "at the desk on laptop at") || + !strings.Contains(lines[0], "asked by mail-seats-835@novox") || + lines[1] != "If that was not you, give it again yourself at the controller's terminal." { + t.Errorf("lines %v", lines) + } + open, err := k.Open(ctx) + if err != nil { + t.Fatal(err) + } + if len(open) != 0 { + t.Errorf("a condition was raised though the operator was told: %+v", open) + } +} + +// novox/hq ADR 0380 §9: until the operator channel's `tell` is delivered — nothing yet serves it, or the +// controller's own grant is refused — the secret-given condition is raised exactly as before. +func TestASecretGivenAtTheDeskRaisesTheConditionWhileNothingServesTell(t *testing.T) { + ctx := context.Background() + for _, failure := range []error{link.ErrNothingServes, link.ErrGrantRefused} { + k, _ := withConditionsInMemory(t) + askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, failure }) + d, _, _, _ := aDesk(t, sealedTo(t, typed)) + d.announce = announceWithRealTell(ctx, "mesh-mail-835, novox") + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + open, err := k.Open(ctx) + if err != nil { + t.Fatal(err) + } + if len(open) != 1 || open[0].Kind != kindSecretGiven { + t.Fatalf("%v: the condition was not raised as before: %+v", failure, open) + } + } +} + +// novox/hq ADR 0380 §10: once the operator channel is told successfully, every secret-given condition +// still open from before this change is retired, without a resolved line of its own. +func TestASuccessfulTellRetiresEveryOpenSecretGivenCondition(t *testing.T) { + ctx := context.Background() + k, _ := withConditionsInMemory(t) + for _, module := range []string{"telegram", "mesh-mail"} { + if _, err := k.Observe(ctx, secretGivenObservation("anchor", module, "a-secret", "at the desk on laptop", + time.Now())); err != nil { + t.Fatal(err) + } + } + askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, nil }) + if err := announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the desk on laptop", "me"); err != nil { + t.Fatal(err) + } + open, err := k.Open(ctx) + if err != nil { + t.Fatal(err) + } + if len(open) != 0 { + t.Fatalf("the open secret-given conditions were not retired: %+v", open) + } +} + +// novox/hq ADR 0380 clause 7: a trusted party's secret given at the controller's terminal is announced +// before it is kept, and not kept when the announcement fails. It still fails when telling the operator +// AND raising the fallback condition both fail — nothing silently keeps a trusted party's value unheard. +func TestATrustedPartysSecretAtTheTerminalIsNotKeptWhenTellAndTheConditionBothFail(t *testing.T) { + ctx := context.Background() + askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, link.ErrNothingServes }) + // conditionsFrom is left nil and there is no bus to dial here, so the fallback condition cannot be + // raised either: both of ADR 0380's ways to tell the operator fail. + kept := false + if _, _, err := keepGiven(true, + func() error { + return announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the controller's terminal", "me") + }, + func() (bool, error) { kept = true; return false, nil }); err == nil { + t.Error("a trusted party's secret was kept though telling the operator and raising the condition both failed") + } + if kept { + t.Error("the value was kept despite the failure") + } +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 373dbd7b..09e69c12 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -12,8 +12,11 @@ import ( "strings" "time" + "github.com/nats-io/nats.go" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/secrets" ) @@ -151,8 +154,11 @@ func secretCommand(ctx context.Context, args []string) error { if err != nil { return err } + askedBy := link.Caller() untilStart, unannounced, err := keepGiven(trusted, - func() error { return announceSecretGiven(ctx, node, module, name, "at the controller's terminal") }, + func() error { + return announceSecretGiven(ctx, node, module, name, "at the controller's terminal", askedBy) + }, func() (bool, error) { return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) }) if err != nil { if trusted && unannounced != nil { @@ -164,6 +170,17 @@ func secretCommand(ctx context.Context, args []string) error { if unannounced != nil { fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", unannounced) } + // The giving is recorded, never the value (novox/hq ADR 0380 §6): the desk already logs its own act; the + // terminal's own secret accept did not until now. + act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name}, + Why: fmt.Sprintf("the operator gave %s for %s on %s at the controller's terminal", name, module, node), + Cause: "given-at-the-terminal"} + if err := onTheBus(func(conn *nats.Conn) error { + _, err := link.RecordHandAct(ctx, conn, act) + return err + }); err != nil { + fmt.Printf(" this act could NOT be recorded in the hand-act log, and is done anyway: %v\n", err) + } // Not printed back, and there is nowhere it could be printed from: it is sealed to that // machine and the mesh cannot read it again. fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index b1f0b831..79994946 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -279,6 +279,11 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"}, {Seat: ControllerSeat, Verb: "plans"}} +// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq +// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named, +// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject. +var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}} + // perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the // holder's machine (novox/hq ADR 0219): `paused.`, the build agent saying whether it takes work. var perMachineEvents = map[string]bool{"paused.*": true} @@ -500,6 +505,10 @@ func PermissionsFor(p Principal) (Permissions, error) { for _, v := range VerbsTheControllerAsksForASecret { pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") } + // And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380). + for _, v := range VerbsTheControllerAsksTheOperatorChannel { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) + } // And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239). for _, v := range VerbsTheControllerAsksTheDeliveryOwner { pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 8270aaf3..971c1491 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,7 +24,7 @@ accounts { jetstream: enabled users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/link/queue.go b/internal/link/queue.go index 6b6f5fb1..b30d570b 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -641,8 +641,8 @@ func AskMeshSeatTool(ctx context.Context, conn *nats.Conn, seat, verb string, ar reply, err = r.msg, r.err case why := <-refused: cancel() - return Answer{}, fmt.Errorf("the bus refused the controller asking %s.%s — its grants do not name %s: %v", - seat, verb, subject, why) + return Answer{}, fmt.Errorf("%w: the bus refused the controller asking %s.%s — its grants do not name %s: %v", + ErrGrantRefused, seat, verb, subject, why) } switch { case errors.Is(err, nats.ErrNoResponders): diff --git a/internal/link/retirement.go b/internal/link/retirement.go index c0bd8330..201b4836 100644 --- a/internal/link/retirement.go +++ b/internal/link/retirement.go @@ -167,6 +167,11 @@ const ( // is older than the tool. var ErrNothingServes = errors.New("nothing serves it") +// ErrGrantRefused is the bus refusing this principal's own call: its grants do not name the subject +// asked. Distinct from ErrNothingServes (nobody there to answer) — this is the asker's own grant, not +// the holder's absence (novox/hq ADR 0380 §9). +var ErrGrantRefused = errors.New("the bus refused the ask") + // ModuleToolOn is where one machine's instance of a module answers a tool: the module's tool subject // with the machine as its last token — the subject the node tools bind beside the shared one. func ModuleToolOn(module, tool, node string) string { return ToolSubject(module, tool) + "." + node } -- 2.54.0 From 5f24a47df69da0af936b03e9b093bf495d5d5bd9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 17:55:30 +0200 Subject: [PATCH 3/5] Refresh captured mesh-host at c3b2fce, carrying the operator-channel.tell grant (issue 835) mesh-host joined feat/mail-seats to add the controller's new operator-channel.tell publish subject to its genesis user list. Moved per testdata/beside/CAPTURED's own recipe (git archive, never by hand); the refreshed snapshot also carries main's drift since the last capture (ADR 0338's store placement). TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose now passes; merge-check.sh green. --- testdata/beside/CAPTURED | 2 +- .../examples/foundation-first-node.lock | 108 +++++++++++++----- 2 files changed, 80 insertions(+), 30 deletions(-) diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 5e6e1f87..83d967cc 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile -mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock +mesh-host c3b2fce0a29f0969dedf517c1f3e1570d64a8e74 examples/foundation-first-node.lock mesh-sdk 5933eb757d443293631be2bed27a96288c2348d8 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/testdata/beside/mesh-host/examples/foundation-first-node.lock b/testdata/beside/mesh-host/examples/foundation-first-node.lock index 289e3e2a..38176945 100644 --- a/testdata/beside/mesh-host/examples/foundation-first-node.lock +++ b/testdata/beside/mesh-host/examples/foundation-first-node.lock @@ -28,11 +28,12 @@ // image, and this file is applied on a machine with no mesh to ask about anything (ADR 0006). The // registry prefix is the lab's; the digests are upstream's. // -// The store's data is a NAMED VOLUME, not a directory on the machine. A directory the host -// creates is owned by root, and the database runs as somebody else inside the container — so it -// could not write, and the container crash-looped. A named volume lets the image set up its own -// ownership, and outlives the container, which is what you want for the thing holding the mesh's -// state. +// **The store is raised where the `postgres` module keeps it** (novox/hq ADR 0338): the container +// `postgres`, on the module's pgvector image, its data in the directory /var/lib/mesh-store owned by +// the image's postgres user, its superuser password read from the module's own-secret file — so the +// store step adopts it as it runs. Pinned upstream by the module's own digest, not through the lab's +// registry prefix, because the two must be the same reference (mesh-host internal/bootstrap holds +// them to each other against the module's manifest). { "declaration": 1, @@ -83,17 +84,28 @@ "boot": "enabled", "restart-on": ["base-filter"] }, + // The store's data is a directory owned by the image's postgres user (999), made before the + // container starts: a directory the runtime made would be root's, and the database could not + // write it. Measured on the module's image 2026-10-13: it initialises a new store on a directory + // made so (novox/hq ADR 0338 §5). + { + "id": "store-data", + "type": "directory", + "path": "/var/lib/mesh-store", + "mode": "0700", + "owner": "999:70" + }, { "id": "store", "type": "container", - "name": "mesh-store", - "image": "192.0.2.250:5000/postgres@sha256:7abf537131b66ed5af448d90653abf1679b0c7e9a1f07efdd4c3108a401b259a", + "name": "postgres", + "image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f", "env": { "POSTGRES_PASSWORD": "bootstrap", "PGDATA": "/var/lib/postgresql/data/pgdata" }, "ports": ["5432:5432"], - "volumes": ["mesh-store-data:/var/lib/postgresql/data"] + "volumes": ["/var/lib/mesh-store:/var/lib/postgresql/data"] }, // Over TCP, not the socket. While the store initialises it runs a temporary server on the // socket ONLY, then stops it and starts the real one — so a socket check passes, the action @@ -102,21 +114,21 @@ { "id": "store-ready", "type": "action", - "in": "mesh-store", + "in": "postgres", "command": ["sh", "-c", "for i in $(seq 1 180); do pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; echo 'the store did not answer within 180s; its own last words follow'; pg_isready -h 127.0.0.1 -U postgres; tail -n 20 /var/lib/postgresql/data/log/*.log 2>/dev/null; exit 1"], "verify": ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"] }, { "id": "inventory-database", "type": "action", - "in": "mesh-store", + "in": "postgres", "command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE inventory'"], "verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw inventory"] }, { "id": "identity-database", "type": "action", - "in": "mesh-store", + "in": "postgres", "command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE identity'"], "verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw identity"] }, @@ -126,20 +138,44 @@ { "id": "licences-database", "type": "action", - "in": "mesh-store", + "in": "postgres", "command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE licences'"], "verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw licences"] }, { "id": "context-schemas", "type": "action", - "command": ["docker", "run", "--rm", "--network", "container:mesh-store", + "command": ["docker", "run", "--rm", "--network", "container:postgres", "-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable", "-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable", "-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable", "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", "migrate"], - "verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"] + "verify": ["sh", "-c", "docker exec postgres psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec postgres psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec postgres psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"] + }, + // **The bus is raised where the `nats` module keeps it** (novox/hq ADR 0333 §1): the container + // `mesh-broker-nats`, its configuration and user list in /var/lib/nats-module/conf, its streams in + // /var/lib/mesh-broker-nats and its certificate in /var/lib/mesh-broker-tls — the names and paths the + // catalogue's module declares. So adopting it as that module at the broker step keeps it: the + // module's built image takes the place of the upstream one below, once, on the same streams, users + // and certificate, and nothing else moves. mesh-host's internal/bootstrap holds the two to each other + // against the module's own manifest (busLayout), at the broker step and in its tests. + // + // Directories on the machine, not volumes the runtime names: the module mounts these paths, and the + // control plane, once it runs as the mesh's process, reads the certificate at its path on the machine. + // The certificate's directory is readable by everyone because the certificate is public and the control + // plane runs as nobody; its key is written 0600 by the program that makes it. + { + "id": "bus-tls-dir", + "type": "directory", + "path": "/var/lib/mesh-broker-tls", + "mode": "0755" + }, + { + "id": "bus-data-dir", + "type": "directory", + "path": "/var/lib/mesh-broker-nats", + "mode": "0700" }, { "id": "bus-certificate", @@ -151,49 +187,63 @@ // it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins // this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh // to ask an authority of. - // `--user 0:0` because the volume is root's and this image runs as nobody, which is right - // for the long-running control plane and wrong for a one-shot writing into a fresh volume. - "command": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls", + // `--user 0:0` because the directory is root's and this image runs as nobody, which is right + // for the long-running control plane and wrong for a one-shot writing into a fresh directory. + "command": ["docker", "run", "--rm", "--user", "0:0", "-v", "/var/lib/mesh-broker-tls:/tls", "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", "broker", "certificate", "--into", "/tls"], - "verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls", + "verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "/var/lib/mesh-broker-tls:/tls", "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", "broker", "certificate", "--check", "--into", "/tls"] }, + // **Seeds, written only where nothing is** (create-once). The bus's configuration and user list are + // the bundle's until the bus is adopted, and the module's from then on (ADR 0333 §4): an installer + // run again after the adoption applies this bundle again, and an ordinary file would put the + // bundle's one user back over every user the mesh composed, and leave the node unable to reach its + // bus. Until the adoption the installer places the composed list over the seed (enrol.go). { "id": "bus-conf-dir", "type": "directory", - "path": "/var/lib/mesh-bus-conf", + "path": "/var/lib/nats-module/conf", "mode": "0700" }, { "id": "bus-conf", "type": "file", - "path": "/var/lib/mesh-bus-conf/nats.conf", + "path": "/var/lib/nats-module/conf/nats.conf", "mode": "0644", - "content": "port: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n" + "create-once": true, + "content": "port: 4222\nhttp: 0.0.0.0:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n" }, { "id": "bus-accounts", "type": "file", - "path": "/var/lib/mesh-bus-conf/accounts.conf", + "path": "/var/lib/nats-module/conf/accounts.conf", "mode": "0600", - "content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_lease.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n" + "create-once": true, + "content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_lease.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n" }, + // The upstream server, until the broker step adopts it: the `nats` module's own image, built by the + // mesh, takes its place there once, on these same directories (ADR 0333 §3), as the bus's planned + // step (ADR 0236). The release is the one the module's image is built on (its NATS_BASE, 2.11.17), + // pinned upstream by that same digest: the adoption changes no server release, so it can be undone + // by putting the old image back and is said reversible. The published port is the + // node's bus port, 5671 unless given, which every enrolment token names; the installer gives the + // module the setting that maps its declared 4222 to it (ADR 0333 §2). { "id": "broker", "type": "container", - "name": "mesh-broker", - "image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927", + "name": "mesh-broker-nats", + "image": "nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00", "ports": ["5671:4222", "127.0.0.1:8222:8222"], - "volumes": ["mesh-broker-data:/data", "mesh-broker-tls:/tls:ro", "/var/lib/mesh-bus-conf:/etc/nats:ro"], + "volumes": ["/var/lib/mesh-broker-nats:/data", "/var/lib/nats-module/conf:/etc/nats:ro", "/var/lib/mesh-broker-tls:/tls:ro"], "args": ["-c", "/etc/nats/nats.conf", "-js"] }, { "id": "broker-ready", "type": "action", - "command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"], - "verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"] + "command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"], + "verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00 -c 'nc -z 127.0.0.1 5671'"] }, { "id": "control-plane", @@ -202,7 +252,7 @@ "image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace", "network": "host", "args": ["serve"], - "volumes": ["mesh-broker-tls:/broker-tls:ro"], + "volumes": ["/var/lib/mesh-broker-tls:/broker-tls:ro"], "env": { "MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable", "MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable", -- 2.54.0 From a18c17d3cc5f90ffb11e4be78cb6a46064062904 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 18:45:03 +0200 Subject: [PATCH 4/5] Name the machine in the secret-given message (issue 835, review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0380 §1 says the message names the module and the machine it is for; the words named no machine at the terminal and only the desk's machine at the desk. The headline now ends "on " and the tell carries "subject": in both cases. Also covers two gaps review round 2 flagged: a tell failure other than ErrNothingServes/ErrGrantRefused surfaces and raises no condition, and the terminal's hand act now names who asked (not just where), extracted into terminalGiveHandAct so it is testable on its own. --- cmd/mesh-controller/desk_secret.go | 3 +- cmd/mesh-controller/desk_secret_test.go | 49 ++++++++++++++++++++++++- cmd/mesh-controller/secret.go | 14 +++++-- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go index 96524993..930a29da 100644 --- a/cmd/mesh-controller/desk_secret.go +++ b/cmd/mesh-controller/desk_secret.go @@ -381,7 +381,8 @@ var askOperatorChannel = func(ctx context.Context, args map[string]any) (json.Ra func announceSecretGiven(ctx context.Context, node, module, name, how, askedBy string) error { at := time.Now() body := map[string]any{ - "headline": fmt.Sprintf("A password was entered for %s's %s", module, secretNameWords(name)), + "headline": fmt.Sprintf("A password was entered for %s's %s on %s", module, secretNameWords(name), node), + "subject": node, "lines": []string{ fmt.Sprintf("%s at %s, asked by %s.", how, at.Local().Format("15:04"), askedByName(askedBy)), "If that was not you, give it again yourself at the controller's terminal.", diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go index 00e41f81..85bbb40e 100644 --- a/cmd/mesh-controller/desk_secret_test.go +++ b/cmd/mesh-controller/desk_secret_test.go @@ -594,9 +594,12 @@ func TestASecretGivenAtTheDeskIsToldOnceAndOpensNoCondition(t *testing.T) { t.Fatalf("told the operator %d times, want exactly 1: %v", len(*calls), *calls) } call := (*calls)[0] - if call["headline"] != "A password was entered for telegram's telegram token" { + if call["headline"] != "A password was entered for telegram's telegram token on anchor" { t.Errorf("headline %v", call["headline"]) } + if call["subject"] != "anchor" { + t.Errorf("subject %v", call["subject"]) + } lines, _ := call["lines"].([]string) if len(lines) != 2 || !strings.Contains(lines[0], "at the desk on laptop at") || !strings.Contains(lines[0], "asked by mail-seats-835@novox") || @@ -678,3 +681,47 @@ func TestATrustedPartysSecretAtTheTerminalIsNotKeptWhenTellAndTheConditionBothFa t.Error("the value was kept despite the failure") } } + +// Review round 2 of mesh-controller (feat/mail-seats, head 5f24a47d): ADR 0380 §9's fallback — raising the +// secret-given condition — answers only link.ErrNothingServes and link.ErrGrantRefused. Any other failure of +// the operator channel's `tell` (the holder itself refusing, or answering something unreadable) is the +// announcement's own failure, surfaced to the caller, and opens no condition. +func TestAnOperatorChannelFailureOtherThanNothingServesOrGrantRefusedSurfacesAndRaisesNoCondition(t *testing.T) { + ctx := context.Background() + k, _ := withConditionsInMemory(t) + refused := errors.New("operator-channel.tell refused: too many lines") + askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, refused }) + if err := announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the desk on laptop", "me"); err != refused { + t.Fatalf("announceSecretGiven = %v, want the refusal itself surfaced", err) + } + open, err := k.Open(ctx) + if err != nil { + t.Fatal(err) + } + if len(open) != 0 { + t.Errorf("a condition was raised though the failure was neither ErrNothingServes nor ErrGrantRefused: %+v", open) + } +} + +// Review round 2 of mesh-controller: a secret accepted at the controller's terminal writes a hand act naming +// who asked, where and when it was given — and never the value (novox/hq ADR 0380 §6). +func TestASecretAcceptedAtTheTerminalWritesAHandActNamingWhoWhereAndNeverTheValue(t *testing.T) { + act := terminalGiveHandAct("anchor", "telegram", "telegram-token", "mail-seats-835@novox") + if act.Verb != "secret accept" || act.Cause != "given-at-the-terminal" { + t.Fatalf("act %+v", act) + } + if !strings.Contains(act.Why, "at the controller's terminal") { + t.Errorf("the act does not name where: %q", act.Why) + } + if !strings.Contains(act.Why, "asked by mail-seats-835@novox") { + t.Errorf("the act does not name who: %q", act.Why) + } + if strings.Contains(act.Why, typed) { + t.Error("the act carries the value") + } + // When is RecordHandAct's own job (it stamps At with time.Now when the act leaves none, and By with the + // caller when the act leaves that empty too): this act leaves both for it to fill. + if act.By != "" || !act.At.IsZero() { + t.Errorf("the act sets its own who/when instead of leaving them to RecordHandAct: %+v", act) + } +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 09e69c12..85b7b20b 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -172,9 +172,7 @@ func secretCommand(ctx context.Context, args []string) error { } // The giving is recorded, never the value (novox/hq ADR 0380 §6): the desk already logs its own act; the // terminal's own secret accept did not until now. - act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name}, - Why: fmt.Sprintf("the operator gave %s for %s on %s at the controller's terminal", name, module, node), - Cause: "given-at-the-terminal"} + act := terminalGiveHandAct(node, module, name, askedBy) if err := onTheBus(func(conn *nats.Conn) error { _, err := link.RecordHandAct(ctx, conn, act) return err @@ -558,6 +556,16 @@ func whoAsked() string { return "the mesh" } +// terminalGiveHandAct is the hand-act log's entry for a secret accepted at the controller's terminal +// (novox/hq ADR 0380 §6): who asked, where and that it happened — never the value, which the act never +// carries a field for. +func terminalGiveHandAct(node, module, name, askedBy string) link.HandAct { + return link.HandAct{Verb: "secret accept", Args: []string{node, module, name}, + Why: fmt.Sprintf("the operator gave %s for %s on %s at the controller's terminal, asked by %s", name, module, + node, askedByName(askedBy)), + Cause: "given-at-the-terminal"} +} + // keepGiven keeps a value given at the controller's terminal, and announces it on the operator's channels // (the confirmation review of 2026-10-09, N1-give). **A trusted party's — a module running as an account of its // own: the router, a verified channel — is announced before it is kept, and not kept when the announcement -- 2.54.0 From 5cf8938f55b3c088988028ea7bb91ffc25e9eba5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 19:29:13 +0200 Subject: [PATCH 5/5] Let the genesis test accept a degradable grant missing from the installer's list (issue 835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forge check of mesh-controller#345: the merge check clones mesh-host at the commit the machines run, not its main, so a change that only edits mesh-host's examples (the operator-channel.tell grant) can never be seen there until mesh-host's own module next builds. operator-channel.tell is the one grant with a fallback for a refusal (the secret-given condition, ADR 0380 §9), so mark it degradable and let TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose pass without it while every other grant stays required. --- internal/broker/genesis_template_test.go | 55 +++++++++++++++++++++++- internal/broker/nats.go | 23 ++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/internal/broker/genesis_template_test.go b/internal/broker/genesis_template_test.go index b598cbae..0f25ea63 100644 --- a/internal/broker/genesis_template_test.go +++ b/internal/broker/genesis_template_test.go @@ -35,7 +35,7 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T) carriedPub := subjectsIn(accounts, "publish") carriedSub := subjectsIn(accounts, "subscribe") - if diff := missing(want.Publish, carriedPub); len(diff) > 0 { + if diff := requiredPublishGap(t, want.Publish, carriedPub); len(diff) > 0 { t.Errorf("the installer's user list does not let the controller publish %v — it would come up "+ "and be refused on the first thing it tried", diff) } @@ -62,6 +62,59 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T) } } +// requiredPublishGap is what in want is missing from carried, leaving out a grant DegradableGrants names +// and saying why in the test log instead (novox/hq issue 835, the forge check of mesh-controller#345): the +// merge check reads mesh-host at the commit the machines run, which a change to mesh-host's examples alone +// never moves, so the carried list can lag the controller's derived one without the controller refusing to +// come up. +func requiredPublishGap(t *testing.T, want, carried []string) []string { + t.Helper() + diff := missing(want, carried) + var required []string + for _, subject := range diff { + if reason, ok := degradableReason(subject); ok { + t.Logf("the installer's user list does not let the controller publish %s, but that falls back "+ + "rather than refuses: %s", subject, reason) + continue + } + required = append(required, subject) + } + return required +} + +// degradableReason says whether a subject is one of DegradableGrants, and why. +func degradableReason(subject string) (string, bool) { + for _, g := range DegradableGrants { + if g.Subject == subject { + return g.Reason, true + } + } + return "", false +} + +// TestADegradableGrantMissingFromTheInstallersListPasses is the forge check's own case (mesh-controller#345, +// novox/hq issue 835): an installer list that lacks operator-channel.tell alone, because mesh-host has not +// yet built with the grant, still passes — the controller falls back to the secret-given condition rather +// than coming up refused. +func TestADegradableGrantMissingFromTheInstallersListPasses(t *testing.T) { + want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"} + carried := []string{"mesh.seat.node-service-manager.tool.restart.*"} + if diff := requiredPublishGap(t, want, carried); len(diff) > 0 { + t.Errorf("a degradable grant missing from the installer's list must not fail the genesis test, got %v", diff) + } +} + +// TestANonDegradableGrantMissingFromTheInstallersListStillFails proves the exemption is named, not general: +// every other grant the controller derives stays required. +func TestANonDegradableGrantMissingFromTheInstallersListStillFails(t *testing.T) { + want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"} + carried := []string{"mesh.seat.operator-channel.tool.tell"} + diff := requiredPublishGap(t, want, carried) + if len(diff) != 1 || diff[0] != "mesh.seat.node-service-manager.tool.restart.*" { + t.Errorf("a missing grant that is not degradable must still fail the genesis test, got %v", diff) + } +} + // theCarriedAccounts is the accounts file the installer's template writes at genesis. func theCarriedAccounts(t *testing.T) string { t.Helper() diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 79994946..dceeacc9 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -284,6 +284,29 @@ var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", // where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject. var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}} +// DegradableGrant is a publish grant whose absence from the installer's carried first-user list is not a +// bug in the template: a fallback covers the refusal. +type DegradableGrant struct { + Subject string + Reason string +} + +// DegradableGrants are the only publish grants TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose +// may find missing from the installer's carried list without failing (novox/hq issue 835, the forge check +// of mesh-controller#345): a change that only edits mesh-host's examples can never satisfy a check that +// reads the mesh-host commit the machines actually run, so the carried list lags behind the controller's +// derived one until mesh-host's own module next builds. operator-channel.tell is the one grant the +// controller already has a fallback for: refused (no holder, or the bus refuses it), the controller raises +// the secret-given condition exactly as before (ADR 0380 §9), so an older carried list still comes up and +// works. Every other grant stays required — missing one of those is a controller that comes up and is +// refused on the first thing it tries, with no fallback that covers it. +var DegradableGrants = []DegradableGrant{ + { + Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb, + Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)", + }, +} + // perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the // holder's machine (novox/hq ADR 0219): `paused.`, the build agent saying whether it takes work. var perMachineEvents = map[string]bool{"paused.*": true} -- 2.54.0