From 4469cab7f46f8cb19fef5e0bf03cfda5c445c665 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:18:26 +0200 Subject: [PATCH] Say what each verb replaces, so the agent is pointed at it instead of a shell command (hq ADR 0245) A seat's verb names the shell commands it is the mesh's way to do, and a module says it for its own tools in its manifest; the tools verb and module list --json carry both, for the mesh MCP server's search, the agent's instructions and the guard on its shell. --- cmd/mesh-controller/modules.go | 5 +- cmd/mesh-controller/plan.go | 4 +- cmd/mesh-controller/push.go | 2 +- cmd/mesh-controller/recorded_kept.go | 4 +- cmd/mesh-controller/recorded_kept_test.go | 4 +- cmd/mesh-controller/release.go | 2 +- cmd/mesh-controller/release_plan.go | 4 +- cmd/mesh-controller/seatverbs.go | 10 +- cmd/mesh-controller/seatverbs_test.go | 17 ++++ internal/catalogue/manifest.go | 55 ++++++++++ internal/catalogue/recreates.go | 2 +- internal/catalogue/replaces_test.go | 119 ++++++++++++++++++++++ internal/catalogue/seats.go | 70 ++++++++----- internal/catalogue/verbs.go | 10 +- internal/inventory/plans.go | 2 +- 15 files changed, 269 insertions(+), 41 deletions(-) create mode 100644 internal/catalogue/replaces_test.go diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 699ce941..950bc762 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -182,6 +182,9 @@ func moduleCommand(ctx context.Context, args []string) error { Requires []string `json:"requires,omitempty"` Claims []string `json:"claims,omitempty"` Capabilities []string `json:"capabilities,omitempty"` + // Replaces is what each of its own tools replaces (novox/hq ADR 0245), for the console's + // search and the agent's instructions. + Replaces map[string][]string `json:"replaces,omitempty"` } out := make([]listed, 0, len(entries)) for _, e := range entries { @@ -189,7 +192,7 @@ func moduleCommand(ctx context.Context, args []string) error { l := listed{Module: m.Module, Version: m.Version, Built: e.Source.BuiltFrom, Head: e.Source.Head, Current: e.Provided || e.Source.Repository == "" || e.Source.Current(), Provided: e.Provided, On: append([]string{}, e.On...), Provides: m.Offers(), Requires: m.Requires, - Capabilities: m.Capabilities, Tools: declaresTools(m)} + Capabilities: m.Capabilities, Tools: declaresTools(m), Replaces: m.Replaces} for _, c := range m.Claims { l.Claims = append(l.Claims, c.At()+"/"+c.Name) } diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 2dee7479..be9dc8fd 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -128,7 +128,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso } // A recorded module is composed at the build this machine runs, on any send but a person's push - // (novox/hq issue 295, ADR 0242). + // (novox/hq issue 295, ADR 0245). if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil { return catalogue.Resolution{}, nil, err } @@ -439,7 +439,7 @@ func declarationWith(ctx context.Context, open *stores, node string, names = append(names, m.Module) } out.Builds = carriedBuilds(names, composed.LeftOut, current, before) - // A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0242). + // A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245). kept, err := recordedKept(ctx, open, node) if err != nil { return sendable{}, err diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 5565a8a2..281c1830 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -1082,7 +1082,7 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er return nil, err } - // **A recorded build moves only by a person's push** (novox/hq issue 295, ADR 0242): this send — a + // **A recorded build moves only by a person's push** (novox/hq issue 295, ADR 0245): this send — a // plan's, a release plan's, a rollback's, a healer's, a rotation's — composes every recorded module at // the build its machine runs. The bus step is a person's word for the bus alone. if ctx, err = sendKeeps(ctx, inv); err != nil { diff --git a/cmd/mesh-controller/recorded_kept.go b/cmd/mesh-controller/recorded_kept.go index 8dbc376f..bbe6f18f 100644 --- a/cmd/mesh-controller/recorded_kept.go +++ b/cmd/mesh-controller/recorded_kept.go @@ -9,7 +9,7 @@ import ( "github.com/novox/mesh-controller/internal/inventory" ) -// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0242). +// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0245). // // **A send carries the machine's whole declaration** (ADR 0221), composed from the build the mesh holds // of every module on it. A module whose upgrade policy records — postgres, mongodb, keycloak, the @@ -121,7 +121,7 @@ func keepRecorded(ctx context.Context, open *stores, node string, shelf map[stri if !found { return nil, fmt.Errorf("%s records rather than rolls out, and %s runs its build %s, which the build "+ "records no longer hold: this send cannot keep it and does not move it — `push %s` sends the new "+ - "one on a person's word (novox/hq ADR 0242)", m, node, short(kept[m]), node) + "one on a person's word (novox/hq ADR 0245)", m, node, short(kept[m]), node) } shelf[m] = ran } diff --git a/cmd/mesh-controller/recorded_kept_test.go b/cmd/mesh-controller/recorded_kept_test.go index dbc78d63..bbd8e192 100644 --- a/cmd/mesh-controller/recorded_kept_test.go +++ b/cmd/mesh-controller/recorded_kept_test.go @@ -11,7 +11,7 @@ import ( "github.com/novox/mesh-controller/internal/link" ) -// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0242). +// A recorded build reaches a machine only by a person's push (novox/hq issue 295, ADR 0245). // aContainerBuild is a build outcome of a module of containers, each named by id with the image and the // health it is given; a policy when one is said. @@ -189,7 +189,7 @@ func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) { } } -// The send says what it recreates (ADR 0242): mail's health checks adopted recreate both its +// The send says what it recreates (ADR 0245): mail's health checks adopted recreate both its // containers, with no new image, every one at once. func TestASendSaysWhatItRecreates(t *testing.T) { image := "registry.invalid:5000/mailu/smtp@sha256:" + strings.Repeat("c", 64) diff --git a/cmd/mesh-controller/release.go b/cmd/mesh-controller/release.go index 680e7516..e2ec6c00 100644 --- a/cmd/mesh-controller/release.go +++ b/cmd/mesh-controller/release.go @@ -671,7 +671,7 @@ func backlogCommand(ctx context.Context, sub string, args []string) error { } // sayRecreations says, on each move a send carries, what it does to the module's containers (novox/hq -// ADR 0242): the build the machine ran against the one it is sent, container by container. Left unsaid +// ADR 0245): the build the machine ran against the one it is sent, container by container. Left unsaid // for a move whose earlier build is not in the records. func sayRecreations(ctx context.Context, open *stores, moves []inventory.CarriedMove) { if len(moves) == 0 { diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index c8d2c9ce..768bc29c 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -904,7 +904,7 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string strings.Join(sent, ", ")) fmt.Printf("%s: tier %d built; sent %d module(s) to %s first in one send (%s), the rest once its gate passes\n", p.ID, p.Tier, len(modules), strings.Join(sent, ", "), strings.Join(modules, ", ")) - // What the send recreates, said with it (novox/hq ADR 0242). + // What the send recreates, said with it (novox/hq ADR 0245). if said := recreationsSaid(carried); said != "" { p.Note += "; " + said fmt.Printf("%s: %s\n", p.ID, said) @@ -1287,7 +1287,7 @@ func plansCommand(ctx context.Context, args []string) error { case s != nil && s.Gate != nil: fmt.Printf(" %-22s %s\n", "", gateLine(s.Gate)) } - // What the send to its first machine did to its containers (ADR 0242). + // What the send to its first machine did to its containers (ADR 0245). if s != nil && s.Gate != nil { for _, c := range s.Gate.Carried { if c.Recreates != "" { diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 6236a695..90cf5afe 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -970,9 +970,15 @@ func seatTools() map[string]any { } var tools []map[string]any for _, v := range s.Serves { - tools = append(tools, map[string]any{ + tool := map[string]any{ "name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output, - }) + } + // What the verb is the mesh's way to do (novox/hq ADR 0245): read by the console's search and + // the agent's instructions. + if len(v.Replaces) > 0 { + tool["replaces"] = v.Replaces + } + tools = append(tools, tool) } seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools}) } diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 4e8b1c62..0124c7d9 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -144,6 +144,23 @@ func TestToolsAnswersTheSeatsRecords(t *testing.T) { if !found { t.Fatal("the mesh-controller seat is not in the listing") } + // And what a verb replaces travels with it (novox/hq ADR 0245): the console and the agent's + // instructions read it from here. + var journal []string + for _, s := range seats { + if s["seat"] != catalogue.ServiceManagerSeat { + continue + } + tools, _ := s["tools"].([]map[string]any) + for _, tool := range tools { + if tool["name"] == "journal" { + journal, _ = tool["replaces"].([]string) + } + } + } + if len(journal) == 0 || journal[0] != "journalctl" { + t.Fatalf("the service manager's journal does not say it replaces journalctl: %v", journal) + } } // A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index d13d2725..f59f553a 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -426,6 +426,13 @@ type Manifest struct { // module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118). Tools []string `json:"tools,omitempty"` + // Replaces says, for a tool of this module's own, the shell commands it is the mesh's way to do — + // `{"docker_logs": ["docker logs"]}` — as a seat's verb says it in its definition (novox/hq ADR + // 0241). A seat's verb carries its own: what a role replaces is the role's, so a module never says it + // for a verb it serves under a claim. The console's search, the agent's instructions and the guard on + // its shell are built from both. + Replaces map[string][]string `json:"replaces,omitempty"` + // Instances says whether this module's instances are the same anywhere — `interchangeable` — // so a call that names no machine may be answered by any of them (novox/hq ADR 0160). A fact // about the software, not about the bus: a stateless web tool says it; a database does not, @@ -1692,6 +1699,7 @@ func ParseManifest(raw []byte) (Manifest, error) { } } problems = append(problems, invokeProblems(m)...) + problems = append(problems, replacesProblems(m)...) problems = append(problems, endpointNameProblems(m)...) problems = append(problems, RouteProblems(m)...) for _, port := range m.Guards { @@ -2306,6 +2314,53 @@ func EndpointPort(m Manifest, name string) (int, bool) { return 0, false } +// MaxReplaced is the longest command a tool may say it replaces: a command's name and the words that +// make it this command, never a script. +const MaxReplaced = 80 + +// replacesProblems judges what a module says its tools replace (novox/hq ADR 0245): each key a tool it +// declares, each entry a command line of one line, short, and said once. +func replacesProblems(m Manifest) []string { + var problems []string + tools := map[string]bool{} + for _, t := range m.Tools { + tools[t] = true + } + keys := make([]string, 0, len(m.Replaces)) + for k := range m.Replaces { + keys = append(keys, k) + } + sort.Strings(keys) + for _, tool := range keys { + if !tools[tool] { + problems = append(problems, fmt.Sprintf("%s says what %q replaces, and declares no such tool: "+ + "a module says it for a tool in its `tools`; a seat's verb says it in the seat's definition "+ + "(novox/hq ADR 0245)", m.Module, tool)) + continue + } + if len(m.Replaces[tool]) == 0 { + problems = append(problems, fmt.Sprintf("%s says %s replaces nothing: leave it out", m.Module, tool)) + } + seen := map[string]bool{} + for _, c := range m.Replaces[tool] { + c = strings.TrimSpace(c) + switch { + case c == "": + problems = append(problems, fmt.Sprintf("%s: %s replaces an empty command", m.Module, tool)) + case strings.ContainsAny(c, "\n\r"): + problems = append(problems, fmt.Sprintf("%s: %s replaces %q, which is more than one line", m.Module, tool, c)) + case len(c) > MaxReplaced: + problems = append(problems, fmt.Sprintf("%s: %s replaces %q, longer than %d characters: "+ + "the command's name and the words that make it this command", m.Module, tool, c, MaxReplaced)) + case seen[c]: + problems = append(problems, fmt.Sprintf("%s: %s replaces %q twice", m.Module, tool, c)) + } + seen[c] = true + } + } + return problems +} + // invokeProblems judges what a module says it calls (novox/hq ADR 0152). // // Refused here, in the manifest's words, rather than at the next composition of the bus's user diff --git a/internal/catalogue/recreates.go b/internal/catalogue/recreates.go index 130ac0fc..db8aab2c 100644 --- a/internal/catalogue/recreates.go +++ b/internal/catalogue/recreates.go @@ -8,7 +8,7 @@ import ( "strings" ) -// What a move does to a module's containers (novox/hq ADR 0242). +// What a move does to a module's containers (novox/hq ADR 0245). // // A container whose declaration changes is recreated, whatever changed in it: an image, an environment // variable, a health check adopted. A module whose containers are recreated in one send is down while diff --git a/internal/catalogue/replaces_test.go b/internal/catalogue/replaces_test.go new file mode 100644 index 00000000..4b343205 --- /dev/null +++ b/internal/catalogue/replaces_test.go @@ -0,0 +1,119 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The verbs an agent works around most say what they replace (novox/hq ADR 0245): the journal, a +// restart, a unit's status, the hosts file — each the command the agent would otherwise type over ssh. +func TestTheVerbsWorkedAroundSayWhatTheyReplace(t *testing.T) { + want := map[string]string{ + ServiceManagerSeat + ".journal": "journalctl", + ServiceManagerSeat + ".restart": "systemctl restart", + ServiceManagerSeat + ".status": "systemctl status", + "node-hostname.add": "edit /etc/hosts", + ControllerSeatName + ".node": "hostnamectl", + } + for _, s := range DefaultSeats() { + for _, v := range s.Serves { + cmd, ok := want[s.Name+"."+v.Name] + if !ok { + continue + } + delete(want, s.Name+"."+v.Name) + found := false + for _, r := range v.Replaces { + found = found || r == cmd + } + if !found { + t.Errorf("%s.%s does not say it replaces %q: %v", s.Name, v.Name, cmd, v.Replaces) + } + } + } + for verb := range want { + t.Errorf("%s is not a verb of the compiled seats", verb) + } +} + +// Every command a seat's verb replaces is one short line, said once — what the guard and the search match. +func TestEveryReplacedCommandIsOneShortLine(t *testing.T) { + for _, s := range DefaultSeats() { + for _, v := range s.Serves { + seen := map[string]bool{} + for _, r := range v.Replaces { + if strings.TrimSpace(r) == "" || strings.ContainsAny(r, "\n\r") || len(r) > MaxReplaced || seen[r] { + t.Errorf("%s.%s replaces %q, which is not one short line said once", s.Name, v.Name, r) + } + seen[r] = true + } + } + } +} + +// A verb's definition in a manifest may say what it replaces, like the mesh's own. +func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["refund"],` + + `"seats":[{"name":"shop-till","serves":[{"name":"refund","replaces":["psql -c refund"]}]}],` + + `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) + if err != nil { + t.Fatal(err) + } + if got := m.DefinesSeats[0].Serves[0].Replaces; len(got) != 1 || got[0] != "psql -c refund" { + t.Fatalf("replaces not read: %v", got) + } +} + +// A module says what its own tools replace, and only for a tool it declares. +func TestAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + + `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) + if err != nil { + t.Fatal(err) + } + if m.Replaces["docker_logs"][0] != "docker logs" || m.Replaces["docker_list"][0] != "docker ps" { + t.Fatalf("replaces not read: %v", m.Replaces) + } + for _, c := range []struct{ manifest, says string }{ + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_lgos":["docker logs"]}}`, + "declares no such tool"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs\nrm -rf /"]}}`, + "more than one line"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, + "twice"}, + {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", MaxReplaced+1) + `"]}}`, + "longer than"}, + } { + if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: want a refusal saying %q, got %v", c.manifest, c.says, err) + } + } +} + +// A seat row the store seeded before `replaces` existed carries none; the working set takes the compiled +// one, so the `tools` answer says it on a mesh whose rows exist. +func TestASeededRowTakesWhatItsVerbsReplaceFromTheCompiledSeat(t *testing.T) { + was := Seats() + t.Cleanup(func() { UseSeats(was) }) + var row Seat + for _, s := range DefaultSeats() { + if s.Name == ServiceManagerSeat { + row = s + } + } + stored := make([]Verb, len(row.Serves)) + for i, v := range row.Serves { + v.Replaces = nil + stored[i] = v + } + row.Serves = stored + UseSeats([]Seat{row}) + got, _ := SeatNamed(ServiceManagerSeat) + for _, v := range got.Serves { + if v.Name == "journal" && (len(v.Replaces) == 0 || v.Replaces[0] != "journalctl") { + t.Fatalf("the seeded journal verb says it replaces %v", v.Replaces) + } + } +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 5f05acd4..64736de1 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -102,10 +102,10 @@ var defaultSeats = append([]Seat{ {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079", Serves: []Verb{ {Name: "databases", Description: "Every database the store holds, with its on-disk size.", - Input: schema(map[string]string{}, nil)}, + Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}}, {Name: "query", Description: "One read-only statement against one database the store holds.", Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"}, - []string{"database", "sql"})}, + []string{"database", "sql"}), Replaces: []string{"psql"}}, }}, // **Delivers the mesh's own bus, not `amqp`.** Those were the same word until // ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is @@ -174,15 +174,16 @@ var defaultSeats = append([]Seat{ Serves: []Verb{ {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + "the operator's, or the block of the module or tool that writes it.", - Input: schema(map[string]string{}, nil)}, + Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}}, {Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " + "/etc/hosts — a name for this machine's own programs, not the mesh's.", Input: schema(map[string]string{"address": "the IPv4 or IPv6 address", - "names": "the names for it, separated by spaces"}, []string{"address", "names"})}, + "names": "the names for it, separated by spaces"}, []string{"address", "names"}), + Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}, {Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " + "lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"}, - []string{"name"})}, + []string{"name"}), Replaces: []string{"sed -i /etc/hosts"}}, }}, // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all @@ -191,15 +192,17 @@ var defaultSeats = append([]Seat{ Serves: []Verb{ {Name: "status", Description: "Every jail on this machine with how many it is watching and " + "holding now, and the totals since the jail started; one jail's detail when named.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)}, + Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}}, {Name: "banned", Description: "Every address banned on this machine right now, with the jail " + "that holds it and when the ban ends.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)}, + Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}}, {Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " + "operator's act on the live ban list, which the mesh never writes itself.", - Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"})}, + Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}), + Replaces: []string{"fail2ban-client set banip"}}, {Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.", - Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"})}, + Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}), + Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}}, }}, // The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever // filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did @@ -210,15 +213,17 @@ var defaultSeats = append([]Seat{ "ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " + "chain when asked.", Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)", - "chain": "one chain of that table (optional)"}, nil)}, + "chain": "one chain of that table (optional)"}, nil), + Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}}, {Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " + "and answer with the mesh's table as loaded.", - Input: schema(map[string]string{}, nil)}, + Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}}, {Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " + "host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " + "DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " + "active found firewall's chains. An operator's act, by name, never a flush.", - Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})}, + Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}), + Replaces: []string{"nft delete", "iptables -X"}}, }}, // The machine's service manager (novox/hq ADR 0177). The host applies every declared unit, // system or user scope; the holder answers questions and operator acts about them, each verb @@ -349,19 +354,32 @@ func UseSeats(s []Seat) { // is never stored (Verb.Optional), and a row is what the working set holds once the store is read: a verb // the seeding added to the row — `failed`, `checks` — would otherwise come back required, and every holder // not serving it yet would be refused at registration and at handover, which the mark exists to prevent. +// +// **And what a verb replaces is the compiled one** (novox/hq ADR 0245): a row seeded before the field +// existed carries none, and re-seeding widens a verb's arguments only, so read from the row alone the +// `tools` answer — what the mesh MCP server's search and the agent's instructions are built from — would +// say no verb replaces anything on a mesh whose rows exist. The mesh's statement of what a role replaces +// is this binary's, like the optional mark. func optionalAsCompiled(row, compiled []Verb) []Verb { optional := map[string]bool{} + replaces := map[string][]string{} for _, v := range compiled { if v.Optional { optional[v.Name] = true } + if len(v.Replaces) > 0 { + replaces[v.Name] = v.Replaces + } } - if len(optional) == 0 { + if len(optional) == 0 && len(replaces) == 0 { return row } out := make([]Verb, len(row)) for i, v := range row { v.Optional = optional[v.Name] + if r, ok := replaces[v.Name]; ok { + v.Replaces = r + } out[i] = v } return out @@ -597,19 +615,19 @@ func serviceManagerVerbs() []Verb { unit := map[string]string{"unit": "the unit's name, as the service manager knows it"} return []Verb{ {Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", - Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil)}, + Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}}, {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}}, {Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}}, {Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}}, {Name: "restart", Description: "Restart one unit.", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}}, {Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}}, {Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).", - Input: scoped(unit, []string{"unit"})}, + Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}}, // **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the // minutes it happened in, and with no window on the verb a person reached for a shell. Every // argument is the holder's to validate — passed to journalctl as one word of its own, never through @@ -624,14 +642,16 @@ func serviceManagerVerbs() []Verb { "until": "the window's end, in the same forms (optional; now when absent)", "match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", "priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", - }, []string{"unit"})}, + }, []string{"unit"}), + Replaces: []string{"journalctl"}}, // What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers // it, so a caller asks every machine the same way. **Optional while its holders catch up**: the // systemd module running today serves it as its own systemd_failed, and a required verb would // refuse it before the version serving `failed` could be delivered. {Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " + "account's; a manager that does not answer is reported with its error, never as nothing failed.", - Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil)}, + Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil), + Replaces: []string{"systemctl --failed"}}, } } @@ -644,10 +664,10 @@ func backupVerbs() []Verb { return []Verb{ {Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " + "its last good night, how many restore points are kept and the repository's size.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil)}, + Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}}, {Name: "now", Description: "Take a backup now, of one module or of every module on this " + "machine — before a migration, a retirement or anything else that could go wrong.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil)}, + Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}}, {Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " + "data, never over it: each directory as .restored-. Swapping it in is a " + "person's act. Lists the restore points when none is named.", @@ -655,7 +675,7 @@ func backupVerbs() []Verb { "module": "the module", "snapshot": "the restore point (from `backed-up`; the newest when omitted)", "path": "one of the module's directories (all of them when omitted)", - }, []string{"module"})}, + }, []string{"module"}), Replaces: []string{"restic restore"}}, } } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index af3318fd..5e96e812 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -30,6 +30,13 @@ type Verb struct { // the mark is removed and the verb is a condition of holding like the rest. Never stored or said: the // seat set's protocol is the compiled one. Optional bool `json:"-"` + // Replaces are the shell commands this verb is the mesh's way to do, each as it is typed — + // `journalctl`, `systemctl restart` — so the agent finds the verb by the command it would have run + // and is told to call it instead (novox/hq ADR 0245): the mesh MCP server's search matches them, the agent's + // instructions list them, and the guard on its shell names the verb when it refuses a work-around. + // A command line is matched by its words in order, the first being the command's name; `edit + // /etc/hosts` and `HOSTALIASES` name the guard's two local work-arounds for a mesh name. + Replaces []string `json:"replaces,omitempty"` } func (v *Verb) UnmarshalJSON(raw []byte) error { @@ -94,7 +101,8 @@ var ControllerVerbs = []Verb{ {Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.", Input: schema(nil, nil)}, {Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.", - Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, + Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}), + Replaces: []string{"hostnamectl", "uptime"}}, {Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " + "and which machines run it.", Input: schema(nil, nil)}, diff --git a/internal/inventory/plans.go b/internal/inventory/plans.go index b3ceed48..a86bf512 100644 --- a/internal/inventory/plans.go +++ b/internal/inventory/plans.go @@ -153,7 +153,7 @@ type CarriedMove struct { To string `json:"to"` Build string `json:"build,omitempty"` // Recreates is what the send does to the module's containers, in the mesh's words — how many it - // recreates, and whether with a new image or only their declaration (novox/hq ADR 0242); empty when + // recreates, and whether with a new image or only their declaration (novox/hq ADR 0245); empty when // it recreates none, or when the build the machine ran is not known. Recreates string `json:"recreates,omitempty"` }