From b3fc5b458025cd5de93233bd7db07c20ac0f9025 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 23:16:23 +0100 Subject: [PATCH] A removal of an approved replacement names the registered text's fingerprint; the cases ADR 0642 cites are tests Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../claude-code/cmd/claude-code/proposals.go | 7 +- .../claude-code/replace_registered_test.go | 74 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index 643405990..c6869619d 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -208,12 +208,13 @@ func (p Proposal) glanceAsk() oc.Ask { } var d strings.Builder if p.Action == ActionRemove { - removes := "The approved text it removes" + removes, print := "The approved text it removes", p.Fingerprint() if p.Over != "" { - removes = "The registered text it removes" + // The registered text, never the replacement's: a removal of a replacement removes both. + removes, print = "The registered text it removes", fingerprintOf(p.Over) } 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()) + p.From, p.At.UTC().Format("15:04 on 2 Jan"), removes, print) } else { if whole { d.WriteString("The text:\n" + shown + "\n") diff --git a/modules/claude-code/cmd/claude-code/replace_registered_test.go b/modules/claude-code/cmd/claude-code/replace_registered_test.go index 8f423d358..fb25e05dd 100644 --- a/modules/claude-code/cmd/claude-code/replace_registered_test.go +++ b/modules/claude-code/cmd/claude-code/replace_registered_test.go @@ -179,3 +179,77 @@ func TestANodeSectionRegisteredForThisNodeIsReplacedOnlyThere(t *testing.T) { t.Errorf("the node's section was not replaced in place:\n%s", got) } } + +// The cases ADR 0642 cites, by name. +func TestRegisteredSectionEdgeCases(t *testing.T) { + const name = "what-this-mesh-is" + t.Run("TestANameRegisteredWithTwoTextsIsRefusedUntilOneIsUnregistered", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + raw, _ := json.Marshal(map[string]any{"instructions": map[string]string{name: registeredNew}}) + writeFile(t, p.Settings, string(raw)) + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + in := ProposeInput{Scope: ScopeMesh, Name: name, Text: "A third text.\n"} + if _, err := Propose(p, in, store, r, t0); err == nil || !strings.Contains(err.Error(), "different texts") { + t.Fatalf("a name registered with two texts was proposed over: %v", err) + } + if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: name, Remove: true}, store, r, t0); err == nil { + t.Error("a name registered with two texts was removed") + } + writeFile(t, p.config(), "{}") // one of the two is unregistered + if _, err := Propose(p, in, store, r, t0); err != nil { + t.Errorf("refused after one was unregistered: %v", err) + } + }) + t.Run("TestRemovingAReplacementLeavesNeitherText", func(t *testing.T) { + p, _ := node(t, "g14") + register(t, p, "registered") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + approve := func(in ProposeInput, at time.Time) string { + in.Scope, in.Name = ScopeMesh, name + out, err := Propose(p, in, store, r, at) + if err != nil { + t.Fatal(err) + } + id := out["proposed"].(string) + r.close(r.chose(id, OptionApprove, at.Add(time.Minute))) + if _, _, err := Reconcile(p, store, r, nil, at.Add(time.Minute)); err != nil { + t.Fatal(err) + } + return id + } + approve(ProposeInput{Text: registeredNew}, t0) + rid := approve(ProposeInput{Remove: true}, t0.Add(time.Hour)) + if got := renderedInstructions(t, p); strings.Contains(got, "The old words.") || strings.Contains(got, "The new words.") { + t.Errorf("a text is left:\n%s", got) + } + // The ask names the registered text's fingerprint, not the replacement's. + d := r.published[rid].Details + if !strings.Contains(d, "The registered text it removes has the fingerprint "+fingerprintOf(textDigest(registeredOld))) || + strings.Contains(d, fingerprintOf(textDigest(registeredNew))) { + t.Errorf("the removal names the wrong fingerprint: %q", d) + } + }) + t.Run("TestAChangedRegisteredTextOutlivesItsApproval", func(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: name, Text: registeredNew}, 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) + } + // The registered text changes after the approval: the approval was bound to the old text, so it is not + // taken for the new one, which renders as registered. + raw, _ := json.Marshal(map[string]Item{"mesh.instructions." + name: {Kind: KindInstructions, Name: name, + Scope: ScopeMesh, Files: one(name, "A later registered text.\n"), By: "g14"}}) + writeFile(t, p.config(), string(raw)) + if got := renderedInstructions(t, p); !strings.Contains(got, "A later registered text.") { + t.Errorf("the changed registered text was swallowed by the old approval:\n%s", got) + } + }) +}