Say what each verb replaces, so the agent is pointed at it instead of a shell command (hq ADR 0244)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

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 console's search,
the agent's instructions and the guard on its shell.
This commit is contained in:
jochen
2026-10-07 19:53:26 +02:00
parent a5a132ac15
commit 0e5af8598f
7 changed files with 215 additions and 27 deletions
+4 -1
View File
@@ -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 0244), 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)
}
+8 -2
View File
@@ -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 0244): 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})
}
+17
View File
@@ -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 0244): 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
+55
View File
@@ -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 0244): 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 0244)", 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
+93
View File
@@ -0,0 +1,93 @@
package catalogue
import (
"strings"
"testing"
)
// The verbs an agent works around most say what they replace (novox/hq ADR 0244): 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)
}
}
}
+29 -23
View File
@@ -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
@@ -573,21 +578,22 @@ 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", "systemctl --failed"}},
{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"}},
{Name: "journal", Description: "The last lines of one unit's journal.",
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"}),
Replaces: []string{"journalctl"}},
}
}
@@ -600,10 +606,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 <path>.restored-<date>. Swapping it in is a " +
"person's act. Lists the restore points when none is named.",
@@ -611,7 +617,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"}},
}
}
+9 -1
View File
@@ -22,6 +22,13 @@ type Verb struct {
Description string `json:"description,omitempty"`
Input map[string]any `json:"input,omitempty"`
Output map[string]any `json:"output,omitempty"`
// 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 0244): the console'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 {
@@ -86,7 +93,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)},