From 657cb2b6c833f60be4dc6ec9e1770a19b83b1654 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 22:14:55 +0100 Subject: [PATCH] A section registered before the controller's terminal took over, or set there, is replaced or removed by a proposal the operator approves It could not be changed at all: propose refused a name set there and never replaced one. The ask now says plainly that it replaces or removes the registered section and binds its text's digest; on Approve every node renders the new text in the place of the old (a removal leaves it out); a replacement keeps the section's own heading form (issue 340 note 14). Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- modules/claude-code/README.md | 14 +- modules/claude-code/cmd/claude-code/config.go | 31 ++- .../cmd/claude-code/proposal_tools.go | 11 +- .../claude-code/cmd/claude-code/proposals.go | 162 +++++++++++++--- .../cmd/claude-code/proposals_test.go | 7 +- .../cmd/claude-code/registrations.go | 17 ++ .../claude-code/replace_registered_test.go | 181 ++++++++++++++++++ 7 files changed, 387 insertions(+), 36 deletions(-) create mode 100644 modules/claude-code/cmd/claude-code/replace_registered_test.go diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 27e576062..16dbccac0 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -122,7 +122,7 @@ The agent kept reaching for `ssh journalctl` while the service manager - **The "instead of" table.** Every seat verb and module tool may say which shell commands it replaces (`replaces`, in the seat's definition or the module's manifest). The module reads the bus — the seats' verbs from the seats' contract, the placed modules' manifests from `mesh-delivery-indexer.modules` and `.manifests`, the - machines and where each module runs from `mesh-delivery-assigner.placement` (issue 1126: the controller that + machines and where each module runs from `mesh-delivery-assigner.assignments` (issue 1126: the controller that answered this is not placed) — at start, every ten minutes and on `claude_code_render`, keeps it in its state (`mesh-tools.json`), and renders it into `CLAUDE.md` as one row per seat or module: `/.` and each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in @@ -206,10 +206,20 @@ judges the record as this module would have made it, composes the ask again from the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed instruction file renders under "Instructions the operator approved". A proposal is decided once per node, and an approval older than the last change applied to its section is never applied; a record deleted from the bus removes nothing. Refused: shell lines, front matter, any `@` after a non-letter (a file the instruction file may read in), invisible or control characters, headings of level one to three, anything shaped like -a secret, more than 16 KiB, a name the terminal sets, and — at once, naming the terminal's line — whenever no channel +a secret, more than 16 KiB, and — at once, naming the terminal's line — whenever no channel that proves its sender is there. The controller's hand-act log records each warrant once (`warranted`), read from the router's record. The text is never logged. +**A section registered before is replaced or removed the same way** (hq issue 340, note 14): a section registered for the +mesh or the node before the controller's terminal took that over, or set there, is proposed over by name. The proposal +carries `over`, the digest of the registered text, which the act the operator's answer binds names; the ask says +plainly that it replaces (or removes) the registered section, with the registered text's fingerprint, and shows the new +text as any other. On Approve each node writes the section into `approved.json` with that `over`, and the managed +instruction file renders it in the place of the registered section (a removal leaves the section out); on Decline or at +expiry nothing changes. A replacement keeps the registered section's own heading form, because it replaces a mesh +section; the heading rule stays for every new shared section. `claude_code_proposals` shows each proposal's `registered +section` and `claude_code_registrations` lists what approvals replaced or removed. + **A session asks the operator a question on Telegram** (hq ADR 0332, issue 589). An agent that needs a decision while the operator may be away from the terminal calls `claude_code_ask` with a headline, the context in plain words, two to four options (a label and what choosing it means, exactly one recommended, with why), what it does if nobody diff --git a/modules/claude-code/cmd/claude-code/config.go b/modules/claude-code/cmd/claude-code/config.go index a2993df5d..194363d5f 100644 --- a/modules/claude-code/cmd/claude-code/config.go +++ b/modules/claude-code/cmd/claude-code/config.go @@ -637,6 +637,14 @@ func mergeSettings(a, b map[string]any) map[string]any { // registered for the mesh, then those for this node. func InstructionSections(c Config) string { var b strings.Builder + // The approved sections that replace or remove a registered one, by its name and the digest of its text, and the + // proposals already placed where the registered section stood. + over, placed := map[string]ApprovedSection{}, map[string]bool{} + for _, s := range c.Approved { + if s.Over != "" { + over[s.Name+"|"+s.Over] = s + } + } registered := "Registered through the `claude-code` module before that moved to the controller's terminal; change them there, in the module's `instructions` setting." for _, part := range []struct { title, note string @@ -654,14 +662,31 @@ func InstructionSections(c Config) string { } fmt.Fprintf(&b, "\n## %s\n\n%s\n", part.title, part.note) for _, it := range sections { - fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"])) + text := it.Files[it.Name+".md"] + if s, ok := over[it.Name+"|"+textDigest(text)]; ok { + // What the operator approved over this registered section stands in its place, or leaves it out. + placed[s.Proposal] = true + if s.Removes { + continue + } + fmt.Fprintf(&b, "\n### %s\n\n*Replaced on the operator's approval (proposed from %s; approved %s).*\n\n%s\n", it.Name, s.From, + s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text)) + continue + } + fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(text)) } } - if len(c.Approved) > 0 { + var approved []ApprovedSection + for _, s := range c.Approved { + if !s.Removes && !placed[s.Proposal] { + approved = append(approved, s) + } + } + if len(approved) > 0 { b.WriteString("\n## Instructions the operator approved\n\nProposed by an agent through the `claude-code` module and " + "approved by the operator on a channel that proves who answered (novox/hq ADR 0274). Changed or removed only the " + "same way: `claude_code_instructions_propose`.\n") - for _, s := range c.Approved { + for _, s := range approved { fmt.Fprintf(&b, "\n### %s\n\n*Proposed from %s; approved %s.*\n\n%s\n", s.Name, s.From, s.Approved.UTC().Format("2006-01-02 15:04 UTC"), strings.TrimSpace(s.Text)) } diff --git a/modules/claude-code/cmd/claude-code/proposal_tools.go b/modules/claude-code/cmd/claude-code/proposal_tools.go index a9be932fb..1887455c5 100644 --- a/modules/claude-code/cmd/claude-code/proposal_tools.go +++ b/modules/claude-code/cmd/claude-code/proposal_tools.go @@ -116,14 +116,19 @@ func proposalTools(p Paths) []stdio.Tool { "(Telegram), shown the text (whole when it is short, else its first lines and its fingerprint), and the text " + "reaches those sessions only on Approve. Decline, or no answer within a day, discards it. Refused for text " + "that would run or read in something by itself (a shell line, front matter, an @ file reference), anything " + - "shaped like a secret, more than 16 KiB, a name set at the controller's terminal, or when no such channel is " + - "there (novox/hq ADR 0274, issue 340). claude_code_proposals shows where it stands.", + "shaped like a secret, more than 16 KiB, or when no such channel is there (novox/hq ADR 0274, issue 340). A name " + + "registered before for the mesh or the node, or set at the controller's terminal, is proposed too: as a " + + "replacement (the text) or with remove a removal of that registered section. The operator's ask says plainly " + + "that it replaces or removes the registered section, and shows the text the same way; on Approve every node " + + "renders the new text in the place of the old, on Decline or at expiry nothing changes. A replacement of a " + + "registered section may keep that section's own heading form (it replaces a mesh section); the heading rule " + + "stays for every new shared section. claude_code_proposals and claude_code_registrations show where it stands.", Input: map[string]any{ "scope": str("mesh (every node) or node (the nodes given, or this one)"), "nodes": str("for scope node: a comma-separated list of nodes; absent is this node"), "name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-g14"), "text": str("the section's text, markdown, as every session will read it"), - "remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"}, + "remove": map[string]any{"type": "boolean", "description": "propose removing the approved or registered section of this name and scope instead"}, }, Run: func(a map[string]any) (any, error) { if proposing == nil { diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index 426581094..643405990 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -113,6 +113,10 @@ type Proposal struct { Form int `json:"form,omitempty"` // Asked is empty once the ask was published, or says why it was not; nothing composes from it. Asked string `json:"asked,omitempty"` + // Over is the digest of the text of a section registered before (for the mesh or the node, or set at the + // controller's terminal) that this proposal replaces or removes (novox/hq issue 340): the ask says so, and an + // approval is bound to it. Empty for a proposal that touches no registered section. + Over string `json:"over,omitempty"` } // textDigest is the digest a proposal binds: SHA-256 over the text's bytes. @@ -166,9 +170,14 @@ func joinAnd(list []string) string { // Act is what an answer binds: this proposal, whole, and the answer. The router's warrant names the ask's digest, // and the ask names this act's digest in the option, so an approval authorises this text and nothing else. func (p Proposal) Act(option string) occ.Act { - return occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action, + act := occ.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action, "scope": p.Scope, "nodes": strings.Join(p.Nodes, ","), "name": p.Name, "digest": p.Digest, "from": p.From, "at": p.At.UTC().Format(time.RFC3339Nano)} + if p.Over != "" { + // Only when set, so the act of a proposal made before keeps the digest its warrant names. + act["over"] = p.Over + } + return act } // AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639). @@ -199,8 +208,12 @@ func (p Proposal) glanceAsk() oc.Ask { } var d strings.Builder if p.Action == ActionRemove { - fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.", - p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint()) + removes := "The approved text it removes" + if p.Over != "" { + removes = "The registered text it removes" + } + fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. %s has the fingerprint %s.", + p.From, p.At.UTC().Format("15:04 on 2 Jan"), removes, p.Fingerprint()) } else { if whole { d.WriteString("The text:\n" + shown + "\n") @@ -209,6 +222,9 @@ func (p Proposal) glanceAsk() oc.Ask { } fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.", p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint()) + if p.Over != "" { + fmt.Fprintf(&d, " It replaces the registered instructions of this name, whose text has the fingerprint %s.", fingerprintOf(p.Over)) + } } fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID) does := "the text becomes part of those sessions' instructions" @@ -220,6 +236,16 @@ func (p Proposal) glanceAsk() oc.Ask { // summary is what the proposal does, at a glance: its proposer's words, or the module's. func (p Proposal) summary() string { + // A proposal over a registered section says so in the module's words, never the proposer's: the operator is + // told plainly which section of the mesh's own it replaces or removes. + if p.Over != "" { + where := strings.ToUpper(p.where()[:1]) + p.where()[1:] + if p.Action == ActionRemove { + return fmt.Sprintf("%s stops reading the instructions '%s' registered before, from its next start.", where, p.Name) + } + return fmt.Sprintf("%s reads these %d lines in place of the instructions '%s' registered before, from its next start.", + where, p.Lines(), p.Name) + } if s := strings.TrimSpace(p.Summary); s != "" { return s } @@ -461,7 +487,7 @@ func ProposalProblem(p Proposal) string { } switch p.Action { case ActionAdd, ActionReplace: - if problem := ProposedTextProblem(p.Text); problem != "" { + if problem := proposedTextProblem(p.Text, p.Over != ""); problem != "" { return problem } if textDigest(p.Text) != p.Digest { @@ -474,6 +500,9 @@ func ProposalProblem(p Proposal) string { default: return "its action is not add, replace or remove" } + if p.Over != "" && !strings.HasPrefix(p.Over, "sha256:") { + return "the digest of the registered text it replaces is not one" + } return "" } @@ -489,7 +518,12 @@ var secretShaped = []*regexp.Regexp{ // session would run or read in by itself (issue 340's refusals, kept): a shell line, front matter, a hook, a tool // server, a permission mode or pre-approved tools, or an `@` reference, which the instruction file reads in as a // file; or anything shaped like a secret. -func ProposedTextProblem(text string) string { +func ProposedTextProblem(text string) string { return proposedTextProblem(text, false) } + +// proposedTextProblem is ProposedTextProblem; ownHeadings says the text replaces a section registered before, which +// may keep that section's own heading form (a replacement of a mesh section is a mesh section): the heading rule +// stays for every new shared section. +func proposedTextProblem(text string, ownHeadings bool) string { switch { case strings.TrimSpace(text) == "": return "the text is empty" @@ -515,6 +549,9 @@ func ProposedTextProblem(text string) string { return fmt.Sprintf("the text holds %q: an @ after anything but a letter or a digit may be read in as a file, "+ "and a shared section refers to no file (novox/hq issue 340)", strings.TrimSpace(m)) } + if ownHeadings { + return secretProblem(text) + } if m := setextHeading.FindString(text); m != "" { return "the text underlines a line with = or -, which makes it a heading that would read as a section of the mesh's own" } @@ -525,6 +562,10 @@ func ProposedTextProblem(text string) string { return fmt.Sprintf("the text holds the heading %q, which would read as a section of the mesh's own; a shared "+ "section uses headings of level four or deeper", strings.TrimSpace(m)) } + return secretProblem(text) +} + +func secretProblem(text string) string { for _, re := range secretShaped { if re.MatchString(text) { return "the text holds something shaped like a secret, which a shared section never carries" @@ -546,6 +587,11 @@ type ApprovedSection struct { From string `json:"from"` Approved time.Time `json:"approved"` By string `json:"by"` + // Over is the digest of the registered section's text this one replaced or removed; the registered section of + // that name and text is left out of what renders. Removes marks a removal of a registered section: it holds no + // text, and its Digest is the fingerprint of the text removed. + Over string `json:"over,omitempty"` + Removes bool `json:"removes,omitempty"` } // ProposalDecision is what this node made of one proposal: once decided, never again. @@ -652,8 +698,11 @@ func ApprovedHere(a Approved, node string) []ApprovedSection { var out []ApprovedSection for _, s := range a.Sections { // Judged again as it renders: a record changed on disk renders nothing a proposal could not hold. - if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || ProposedTextProblem(s.Text) != "" || - textDigest(s.Text) != s.Digest || (s.Scope != ScopeMesh && s.Scope != ScopeNode) || + textOK := proposedTextProblem(s.Text, s.Over != "") == "" && textDigest(s.Text) == s.Digest + if s.Removes { + textOK = s.Text == "" && s.Over != "" && strings.HasPrefix(s.Digest, "sha256:") + } + if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || !textOK || (s.Scope != ScopeMesh && s.Scope != ScopeNode) || (s.Scope == ScopeNode && (len(s.Nodes) == 0 || nodesProblem(s.Nodes) != "")) { continue } @@ -810,30 +859,42 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time } prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(), Summary: strings.TrimSpace(in.Summary), Form: AskGlance} + registered, err := registeredSection(p, in.Scope, nodes, in.Name) + if err != nil { + return refuse(err.Error()) + } switch { case in.Remove: - if !isActive { - return refuse(fmt.Sprintf("no approved section %q at the %s scope is held on this node to remove; one set at the "+ - "controller's terminal is removed there", in.Name, in.Scope)) + switch { + case isActive && active.Removes: + return refuse(fmt.Sprintf("the section %q was removed already", in.Name)) + case isActive: + // An approved section that replaced a registered one is removed with it: nothing of the name is left. + prop.Action, prop.Digest, prop.Over = ActionRemove, active.Digest, active.Over + case registered != "": + prop.Action, prop.Digest, prop.Over = ActionRemove, registered, registered + default: + return refuse(fmt.Sprintf("no approved or registered section %q at the %s scope is held on this node to remove", in.Name, in.Scope)) } - prop.Action, prop.Digest = ActionRemove, active.Digest default: - if problem := ProposedTextProblem(in.Text); problem != "" { + prop.Over = registered + if prop.Over == "" && isActive { + prop.Over = active.Over + } + if problem := proposedTextProblem(in.Text, prop.Over != ""); problem != "" { return refuse(problem) } prop.Action, prop.Text, prop.Digest = ActionAdd, in.Text, textDigest(in.Text) - if isActive { + if isActive && !active.Removes { if active.Digest == prop.Digest { return refuse(fmt.Sprintf("the section %q already holds exactly this text", in.Name)) } prop.Action = ActionReplace - } - } - if terminal, _ := terminalText(p); len(terminal) > 0 { - for _, it := range terminal { - if it.Kind == KindInstructions && it.Name == in.Name { - return refuse(fmt.Sprintf("a section %q is set at the controller's terminal; it is changed there", in.Name)) + } else if registered != "" { + if registered == prop.Digest { + return refuse(fmt.Sprintf("the registered section %q already holds exactly this text", in.Name)) } + prop.Action = ActionReplace } } if why := approverReady(bus); why != "" { @@ -1168,15 +1229,22 @@ func decide(p Paths, approved *Approved, prop Proposal, w oc.Warrant, now time.T switch prop.Action { case ActionRemove: held, ok := approved.Sections[key] - if !ok || held.Digest != prop.Digest { + switch { + case prop.Over != "" && (!ok || (!held.Removes && held.Digest == prop.Digest)): + // A registered section is removed by leaving it out of what renders: the record of the removal says + // which text, and a node holding no such section renders nothing of it either way. + approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Digest: prop.Digest, + Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w), Over: prop.Over, Removes: true} + case !ok || held.Removes || held.Digest != prop.Digest: d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint" approved.Changed[key] = at.UTC() return d, false, "proposal " + prop.ID + ": " + d.Outcome, true + default: + delete(approved.Sections, key) } - delete(approved.Sections, key) default: approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Text: prop.Text, - Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w)} + Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: at.UTC(), By: occ.Says(w), Over: prop.Over} } approved.Changed[key] = at.UTC() say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), occ.Says(w)) @@ -1211,6 +1279,13 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time row := map[string]any{"id": prop.ID, "action": prop.Action, "name": prop.Name, "reaches": prop.where(), "from": prop.From, "at": prop.At.Format(time.RFC3339), "fingerprint": prop.Fingerprint(), "lines": prop.Lines(), "bytes": len(prop.Text)} + if prop.Over != "" { + verb := "replaces" + if prop.Action == ActionRemove { + verb = "removes" + } + row["registered section"] = verb + " the registered section whose text has the fingerprint " + fingerprintOf(prop.Over) + } if d, ok := approved.Decided[prop.ID]; ok { row["here"] = d } else if why, ok := approved.Refused[prop.ID]; ok { @@ -1252,11 +1327,50 @@ func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time } var here []map[string]any for _, s := range ApprovedHere(approved, p.Node) { - here = append(here, map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal, + row := map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal, "from": s.From, "approved": s.Approved.Format(time.RFC3339), "by": s.By, - "fingerprint": fingerprintOf(s.Digest)}) + "fingerprint": fingerprintOf(s.Digest)} + if s.Over != "" { + row["registered section"] = "replaces the registered section whose text has the fingerprint " + fingerprintOf(s.Over) + if s.Removes { + row["registered section"] = "removes the registered section whose text has the fingerprint " + fingerprintOf(s.Over) + } + } + here = append(here, row) } return map[string]any{"proposals": rows, "approved here": here, "how": "a proposal becomes active on a node only when that node has held the operator's warrant, read from the " + "router's own record, to the proposal's exact text (novox/hq ADR 0274)"}, nil } + +// registeredSection is the digest of the text of the instruction section of this name that was registered before +// (for the mesh, or for this node when the proposal names it) or is set at the controller's terminal, or "" when +// there is none; an error when the places hold different texts under the name, since an approval is bound to one. +func registeredSection(p Paths, scope string, nodes []string, name string) (string, error) { + var items map[string]Item + _ = readJSON(p.config(), &items) + c := ConfigOf(Taken(items)) + c.Terminal, _ = terminalText(p) + layers := [][]Item{c.Mesh, c.Terminal} + if scope == ScopeNode { + layers = [][]Item{c.Node, c.Terminal} + if !contains(nodes, p.Node) { + layers = [][]Item{c.Terminal} + } + } + digest := "" + for _, layer := range layers { + for _, it := range layer { + if it.Kind != KindInstructions || it.Name != name { + continue + } + d := textDigest(it.Files[name+".md"]) + if digest != "" && d != digest { + return "", fmt.Errorf("the section %q is registered with different texts (for the mesh and at the controller's terminal); "+ + "remove one of them first", name) + } + digest = d + } + } + return digest, nil +} diff --git a/modules/claude-code/cmd/claude-code/proposals_test.go b/modules/claude-code/cmd/claude-code/proposals_test.go index c2841e259..7e2a3f806 100644 --- a/modules/claude-code/cmd/claude-code/proposals_test.go +++ b/modules/claude-code/cmd/claude-code/proposals_test.go @@ -399,11 +399,10 @@ func TestWhatASessionWouldRunIsRefused(t *testing.T) { if _, err := Propose(p, ProposeInput{Scope: ScopeNode, Nodes: []string{"all"}, Name: "x", Text: "t\n"}, store, r, t0); err == nil { t.Error("all at the node scope was taken") } - // A name the terminal sets is changed there. + // A name the terminal sets is replaced by a proposal over it (TestARegisteredSectionIsReplacedOrRemovedOnTheOperatorsApproval). writeFile(t, p.Settings, `{"instructions": {"x": ["set at the terminal"]}}`) - if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "t\n"}, store, r, t0); err == nil || - !strings.Contains(err.Error(), "controller's terminal") { - t.Errorf("a terminal name was proposed: %v", err) + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "t\n"}, store, r, t0); err != nil { + t.Errorf("a terminal name was not proposed over: %v", err) } } diff --git a/modules/claude-code/cmd/claude-code/registrations.go b/modules/claude-code/cmd/claude-code/registrations.go index 0e275e2a0..6f3229894 100644 --- a/modules/claude-code/cmd/claude-code/registrations.go +++ b/modules/claude-code/cmd/claude-code/registrations.go @@ -96,7 +96,24 @@ func Registrations(p Paths, servers ServerState, config ConfigState) (map[string for _, it := range text { shared = append(shared, it.Kind+" "+it.Name) } + // The registered text an approved proposal replaced or removed on this node: what renders instead, or nothing. + replaced := []map[string]any{} + if approved, err := ReadApproved(p); err == nil { + for _, s := range ApprovedHere(approved, p.Node) { + if s.Over == "" { + continue + } + did := "replaced" + if s.Removes { + did = "removed" + } + replaced = append(replaced, map[string]any{"name": s.Name, "scope": s.Scope, "did": did, + "registered text": fingerprintOf(s.Over), "proposal": s.Proposal, "approved": s.Approved.Format("2006-01-02T15:04:05Z07:00"), + "by": s.By}) + } + } return map[string]any{ + "registered text replaced by an approved proposal, or removed by one": replaced, "set at the controller's terminal, as this node composes it": map[string]any{ "managed_settings": settings.ManagedSettings, "mcp_servers": settings.MCPServers, "shared text": shared, "shared text left out": leftOut}, diff --git a/modules/claude-code/cmd/claude-code/replace_registered_test.go b/modules/claude-code/cmd/claude-code/replace_registered_test.go new file mode 100644 index 000000000..8f423d358 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/replace_registered_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// A section registered for the mesh before the controller's terminal took that over, or set at the terminal, is +// replaced or removed by a proposal the operator approves (novox/hq ADR 0274, issue 340 note 14). + +const registeredOld = "# What this mesh is\n\n## Its purpose\n\nThe old words.\n" +const registeredNew = "# What this mesh is\n\n## Its purpose\n\nThe new words.\n" + +// register places the old text the way the node keeps it: in the registered items (mesh scope) or in the settings. +func register(t *testing.T, p Paths, where string) { + t.Helper() + switch where { + case "registered": + raw, _ := json.Marshal(map[string]Item{"mesh.instructions.what-this-mesh-is": {Kind: KindInstructions, + Name: "what-this-mesh-is", Scope: ScopeMesh, Files: one("what-this-mesh-is", registeredOld), By: "g14"}}) + writeFile(t, p.config(), string(raw)) + case "terminal": + raw, _ := json.Marshal(map[string]any{"instructions": map[string]string{"what-this-mesh-is": registeredOld}}) + writeFile(t, p.Settings, string(raw)) + } +} + +func TestARegisteredSectionIsReplacedOrRemovedOnTheOperatorsApproval(t *testing.T) { + for _, where := range []string{"registered", "terminal"} { + t.Run(where, func(t *testing.T) { + p, _ := node(t, "g14") + other, _ := node(t, "ace") + register(t, p, where) + register(t, other, where) + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + propose := func(in ProposeInput, at time.Time) string { + t.Helper() + in.Scope, in.Name = ScopeMesh, "what-this-mesh-is" + out, err := Propose(p, in, store, r, at) + if err != nil { + t.Fatal(err) + } + return out["proposed"].(string) + } + // The same text is no change; a new section with such headings is still refused. + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "what-this-mesh-is", Text: registeredOld}, store, r, t0); err == nil { + t.Error("the registered text was proposed over itself") + } + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "fresh", Text: registeredNew}, store, r, t0); err == nil { + t.Error("a new section may hold the mesh's own headings") + } + + id := propose(ProposeInput{Text: registeredNew}, t0) + a := r.published[id] + if a.Headline != "Replace Claude instructions 'what-this-mesh-is'?" || + !strings.Contains(a.Explanation, "in place of the instructions 'what-this-mesh-is' registered before") { + t.Errorf("the ask does not say it replaces the registered section: %q / %q", a.Headline, a.Explanation) + } + if !strings.Contains(a.Details, "It replaces the registered instructions of this name, whose text has the fingerprint "+ + fingerprintOf(textDigest(registeredOld))) || !strings.Contains(a.Details, "The new words.") { + t.Errorf("the ask does not show the text and what it replaces: %q", a.Details) + } + if got := renderedInstructions(t, p); !strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") { + t.Fatal("a proposal changed what renders") + } + // Declined, nothing changes. + r.close(r.chose(id, OptionDecline, t0.Add(time.Hour))) + if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); changed { + t.Error("a decline changed what renders") + } + // Approved, the registered section is replaced in place, on every node that proves the warrant. + id = propose(ProposeInput{Text: registeredNew}, t0.Add(2*time.Hour)) + r.close(r.chose(id, OptionApprove, t0.Add(3*time.Hour))) + for _, n := range []Paths{p, other} { + if changed, _, err := Reconcile(n, store, r, nil, t0.Add(3*time.Hour)); err != nil || !changed { + t.Fatalf("%s: the approval changed nothing (%v)", n.Node, err) + } + got := renderedInstructions(t, n) + if strings.Contains(got, "The old words.") || strings.Count(got, "The new words.") != 1 || + strings.Count(got, "### what-this-mesh-is") != 1 { + t.Fatalf("%s: the section was not replaced once, in place:\n%s", n.Node, got) + } + if strings.Contains(got, "## Instructions the operator approved") { + t.Errorf("%s: a replacement is listed apart from the section it replaces", n.Node) + } + } + // claude_code_registrations and claude_code_proposals show it. + reg, err := Registrations(p, &bus{kept: map[string]map[string]any{}}, memConfig{}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(reg) + if !strings.Contains(string(raw), "registered text replaced by an approved proposal") { + t.Errorf("claude_code_registrations does not show the replacement: %s", raw) + } + list, _ := ListProposals(p, store, r, "", t0.Add(4*time.Hour)) + raw, _ = json.Marshal(list) + if !strings.Contains(string(raw), "replaces the registered section whose text") { + t.Errorf("claude_code_proposals does not show the replacement: %s", raw) + } + + // A removal of the registered section is asked the same way and leaves nothing. + rid := propose(ProposeInput{Remove: true}, t0.Add(5*time.Hour)) + if got := r.published[rid]; got.Headline != "Remove Claude instructions 'what-this-mesh-is'?" { + t.Errorf("removal ask: %q", got.Headline) + } + r.close(r.chose(rid, OptionApprove, t0.Add(6*time.Hour))) + if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(6*time.Hour)); !changed { + t.Fatal("the approved removal changed nothing") + } + if got := renderedInstructions(t, p); strings.Contains(got, "what-this-mesh-is") { + t.Errorf("the removal left the section:\n%s", got) + } + }) + } +} + +func TestARegisteredSectionIsRemovedByAProposalToo(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "what-this-mesh-is", Remove: true}, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + if !strings.Contains(r.published[id].Explanation, "registered") || !strings.Contains(r.published[id].Details, "registered text it removes") { + t.Errorf("the ask does not say it removes a registered section: %+v", r.published[id]) + } + r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if got := renderedInstructions(t, p); strings.Contains(got, "The old words") { + t.Errorf("the registered section still renders:\n%s", got) + } + // Nothing registered and nothing approved: still refused. + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "nothing-here", Remove: true}, store, r, t0); err == nil { + t.Error("a section nobody holds was removed") + } +} + +func TestAnApprovalIsBoundToTheRegisteredTextItReplaces(t *testing.T) { + prop := Proposal{ID: "instr-0123456789abcdef", Action: ActionReplace, Scope: ScopeMesh, Name: "x", Text: "t\n", Digest: textDigest("t\n"), + From: "g14", At: t0} + plain := prop.Act(OptionApprove) + if _, ok := plain["over"]; ok { + t.Error("a proposal over nothing binds an over: its older warrants would no longer hold") + } + prop.Over = textDigest("old\n") + if prop.Act(OptionApprove)["over"] != prop.Over { + t.Error("the act does not bind the registered text it replaces") + } + prop.Over = "not a digest" + if ProposalProblem(prop) == "" { + t.Error("a proposal over a text with no digest is one this module makes") + } +} + +func TestANodeSectionRegisteredForThisNodeIsReplacedOnlyThere(t *testing.T) { + p, _ := node(t, "g14") + raw, _ := json.Marshal(map[string]Item{"node.g14.instructions.local": {Kind: KindInstructions, Name: "local", Scope: ScopeNode, + Files: one("local", "## Local\n\nold\n"), By: "g14"}}) + writeFile(t, p.config(), string(raw)) + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + out, err := Propose(p, ProposeInput{Scope: ScopeNode, Nodes: []string{"g14"}, Name: "local", Text: "## Local\n\nnew\n"}, store, r, t0) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + got := renderedInstructions(t, p) + if strings.Contains(got, "\nold\n") || !strings.Contains(got, "\nnew\n") || strings.Count(got, "### local") != 1 { + t.Errorf("the node's section was not replaced in place:\n%s", got) + } +}