From deafd810b19c1bdb211a149401f0ccb9e35d0363 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 17:04:48 +0200 Subject: [PATCH] A settings proposal carries every key with its whole value in its Details (issue 383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review of #304: with more than three keys the ask showed only the names of the rest, a long value was cut without a word, and the Details held no key, so Approve bound values the operator never saw. The Details now list every key uncut — masked, and whole in WholeDetails for a channel that proves who answers — before who proposed it and the fingerprint; the glance says what it cut. Vendors mesh-sdk at f474544 and captures its conformance events there. --- cmd/mesh-controller/proposals.go | 57 +++++++++++------ cmd/mesh-controller/proposals_test.go | 64 ++++++++++++++++++- go.mod | 2 +- go.sum | 2 + testdata/beside/CAPTURED | 2 +- .../novox/mesh-sdk/go/asks/asks.go | 12 ++++ vendor/modules.txt | 2 +- 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index 97d44fda..53ea34f4 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -164,29 +164,38 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin headline = verb + " settings?" } // The message reads at a glance (issues 383 and 639): a summary of what changes where, then at most three - // points, a key each; who proposed it, the fingerprint and how to read it whole are its Details, which the - // router notes after the answers. - shown, shownWhole := p.change(machines, false) - whole, _ := p.change(machines, true) + // points, a key each; every key with its whole value, who proposed it, the fingerprint and how to read it at the + // terminal are its Details, which the router notes after the answers. + shown, shownAll, shownWhole := p.change(machines, false) + whole, wholeAll, _ := p.change(machines, true) summary := p.summary() compose := func(points []string) string { return strings.Join(append([]string{summary}, points...), "\n") } - var d strings.Builder - fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"), - fingerprint(p.Digest)) - if !shownWhole { - d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n") + // The Details: every setting it sets, one line each, uncut — whole where the sender is proven (WholeDetails), + // masked elsewhere — then who proposed it, the fingerprint and how to read it at the terminal. + details := func(lines []string) string { + var d strings.Builder + d.WriteString(strings.Join(lines, "\n") + "\n") + fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"), + fingerprint(p.Digest)) + if !shownWhole { + d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n") + } + fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+ + "controller's settings verb with proposal %s through the mesh MCP server.", id, id) + return d.String() } - fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+ - "controller's settings verb with proposal %s through the mesh MCP server.", id, id) q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator, Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes", - About: p.about(), Details: d.String()} + About: p.about(), Details: details(shownAll)} + // Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere. if w := compose(whole); w != q.Explanation { - // Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere. q.Whole = w } + if w := details(wholeAll); w != q.Details { + q.WholeDetails = w + } options := map[string]int{} for i, act := range p.actions(id) { binds, _ := asks.ActDigest(boundAct(act)) @@ -233,12 +242,14 @@ func (p settingsProposal) summary() string { n, p.Module, p.where(), stay, takes) } -// change is what changes, as at most asks.MostPoints points, and whether every value was shown whole: "+ key: value" +// change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and +// whether every value was shown exactly: "+ key: value" // added, "~ key: value (was: old)" changed, "- key (was: old)" removed. Each value is shown under the content rule // (sayableValue), or — exact, for a channel that proves who answers — as it is, withheld only when shaped like a // secret (wholeValue). More changes than points: the first two, and the keys of the rest; a point longer than a -// phone shows at a glance is cut. Either is read whole with `settings proposals`. -func (p settingsProposal) change(machines []string, exact bool) ([]string, bool) { +// phone shows at a glance is cut and says so. The note carries every line uncut (the review of #304: Approve sets +// exactly what the operator can read). +func (p settingsProposal) change(machines []string, exact bool) ([]string, []string, bool) { whole := true say := func(v any) string { s, w := sayableValue(v, machines) @@ -268,6 +279,10 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, bool) lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))}) } } + all := make([]string, len(lines)) + for i, l := range lines { + all[i] = l.text + } var out []string for i, l := range lines { if len(lines) > asks.MostPoints && i == asks.MostPoints-1 { @@ -275,19 +290,19 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, bool) for _, r := range lines[i:] { rest = append(rest, r.key) } - out = append(out, fmt.Sprintf("and %d more: %s", len(rest), strings.Join(rest, ", "))) - whole = false + out = append(out, fmt.Sprintf("and %d more, each whole in the note: %s", len(rest), strings.Join(rest, ", "))) break } out = append(out, l.text) } + // A point too long for a glance is cut, and says so: its whole value is the note's. + const cut = "… (cut: whole in the note)" for i, l := range out { if r := []rune(l); len(r) > asks.PointLength { - out[i] = string(r[:asks.PointLength-1]) + "…" - whole = false + out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut } } - return out, whole + return out, all, whole } func layerKeys(m map[string]any) []string { diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index f2656ba3..4fc03063 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "os" "slices" @@ -222,8 +223,8 @@ func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t t.Fatal(err) } q := r.askSent(t) - masked, _ := kept(r).Proposal.change(machines, false) - whole, _ := kept(r).Proposal.change(machines, true) + masked, _, _ := kept(r).Proposal.change(machines, false) + whole, _, _ := kept(r).Proposal.change(machines, true) if !strings.HasSuffix(q.Explanation, "\n"+strings.Join(masked, "\n")) || !strings.HasSuffix(q.Whole, "\n"+strings.Join(whole, "\n")) { t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole) } @@ -272,7 +273,7 @@ func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t if _, err := r3.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: five}); err != nil { t.Fatal(err) } - if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more: c, d, e") || asks.Glance(q3.Explanation) != nil || + if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each whole in the note: c, d, e") || asks.Glance(q3.Explanation) != nil || !strings.Contains(q3.Details, "settings proposals") { t.Errorf("five keys:\n%s", q3.Explanation) } @@ -854,3 +855,60 @@ func captureStdout(t *testing.T, f func()) string { _ = w.Close() return <-done } + +// The review of #304: Approve sets exactly what the operator can read. A proposal of five keys, one value 300 +// characters long, shows two keys at a glance, says what it cut, and carries every key with its whole value in its +// Details for a channel that proves who answers (WholeDetails), and every key masked for one that does not. +func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) { + var long strings.Builder + long.WriteString("library=/storage") + for i := 0; long.Len() < 300; i++ { + long.WriteString(fmt.Sprintf("/album-%02d", i)) + } + value := long.String()[:300] + values := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value} + r := newProposerRig(t) + if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil { + t.Fatal(err) + } + q := r.askSent(t) + if err := q.Check(r.now); err != nil { + t.Fatal(err) + } + // What the operator reads on a proving channel: the whole words, then the note's whole details. + visible := q.Whole + "\n" + q.WholeDetails + if q.Whole == "" { + visible = q.Explanation + "\n" + q.WholeDetails + } + for k, v := range values { + if !strings.Contains(visible, "+ "+k+": "+v.(string)) { + t.Errorf("the operator never reads %s whole:\n%s", k, visible) + } + } + if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each whole in the note: c, d, shares") { + t.Errorf("the glance:\n%s", q.Explanation) + } + for k := range values { + if !strings.Contains(q.Details, "+ "+k+": ") { + t.Errorf("the masked details lack %s:\n%s", k, q.Details) + } + } + if strings.Contains(q.Details, "/storage") { + t.Errorf("the masked details show a path:\n%s", q.Details) + } + if refusal, ok := outward.Check(q.Details, "shanks"); !ok { + t.Errorf("the router would withhold the masked details: %s", refusal) + } + if _, ok := outward.Secret(q.WholeDetails, "shanks"); !ok { + t.Error("the router would refuse the whole details") + } + // A value too long for a glance is cut there, and says so. + r2 := newProposerRig(t) + if _, err := r2.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: map[string]any{"shares": value}}); err != nil { + t.Fatal(err) + } + q2 := r2.askSent(t) + if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.Contains(q2.WholeDetails, "+ shares: "+value) || asks.Glance(q2.Whole) != nil { + t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails) + } +} diff --git a/go.mod b/go.mod index b1202b12..4353423c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/go.sum b/go.sum index e8f550a7..564a85a0 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoI git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index fe654bd6..e3ded499 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock -mesh-sdk 9588871d6629e160bb0667f3d97566a0f8f7a4a6 conformance/events/module-event.json +mesh-sdk f4745447ba6d0c4fd7dc6b1071e26ce012806233 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go index 0f6f3aa3..8915af85 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go @@ -174,6 +174,12 @@ func (a Ask) Digest() string { canonical(&b, a.Whole) canonical(&b, a.Details) } + // WholeDetails (novox/hq issue 383, the review of mesh-controller #304) follows only when given, so every + // ask without it digests as it did before it existed. + if a.WholeDetails != "" { + canonical(&b, "whole-details") + canonical(&b, a.WholeDetails) + } sum := sha256.Sum256([]byte(b.String())) return "sha256:" + hex.EncodeToString(sum[:]) } @@ -207,6 +213,12 @@ type Ask struct { // (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq // issue 639). Details string `json:"details,omitempty"` + // WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown + // in place of the explanation (a kind that proves who answers, given the ask's answers): every value an + // approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like + // Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty, + // Details are shown everywhere. + WholeDetails string `json:"whole-details,omitempty"` } // What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary diff --git a/vendor/modules.txt b/vendor/modules.txt index ced81f12..9b441225 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op