From c58e0a571e7ddad9164b5b38f89664cde7e8ab70 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:33:52 +0200 Subject: [PATCH 1/5] A settings proposal reads at a glance, its values whole where the sender is proven (issues 383 and 639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator could not read the settings proposals on the phone: the change, the fingerprint and a how-to paragraph in one wall of plain text, and the values masked as ‹path› and ‹address›, so the approval was blind. The ask is now a summary of what changes where ("Changes 3 setting(s) of mounts on shanks; approved, the layer is set at once and shanks takes it at its next push."), then at most three points, one key each (more keys: the first two and the names of the rest). Who proposed it, the fingerprint and how to read it whole are its Details, which the router notes after the answers. The change is carried twice: under the content rule, and whole (asks.Ask.Whole), shown only on a channel that proves who answers, a value shaped like a secret withheld in both (from the earlier work on issue 383, #202). Vendors mesh-sdk at 9588871 (fix/639-an-ask-reads-at-a-glance: Message.Note, asks.Glance), and captures its conformance events at that commit. --- cmd/mesh-controller/proposals.go | 165 ++++++++++++------ cmd/mesh-controller/proposals_test.go | 138 +++++++++++++-- go.mod | 2 +- go.sum | 2 + internal/outward/outward.go | 67 +++++-- internal/outward/outward_test.go | 37 ++++ testdata/beside/CAPTURED | 2 +- .../novox/mesh-sdk/go/asks/asks.go | 56 +++++- vendor/modules.txt | 2 +- 9 files changed, 388 insertions(+), 83 deletions(-) diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index fc5db024..97d44fda 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -28,6 +28,13 @@ package main // ask says so, and the whole is read with `settings proposals ` — whose fingerprint must be the one on the // phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left // waiting for an answer that cannot come. +// +// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's +// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must +// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only +// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. The message is the +// headline, one line per key, who proposed it and when; the fingerprint and how to read the proposal whole are +// the Details answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove nothing. import ( "context" @@ -156,35 +163,30 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin if len([]rune(headline)) > asks.HeadlineLength { headline = verb + " settings?" } - shown, whole := p.change(machines) - var b strings.Builder - if p.Clear { - fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where()) - } else { - fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where()) + // 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) + summary := p.summary() + compose := func(points []string) string { + return strings.Join(append([]string{summary}, points...), "\n") } - fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan")) - switch { - case p.Clear && p.HadLayer: - b.WriteString("The layer it removes:\n\n") - case p.Clear: - b.WriteString("There is no layer to remove; approving changes nothing.") - case !p.HadLayer: - b.WriteString("There is no layer yet; this is the whole of it:\n\n") - default: - b.WriteString("The layer is replaced whole; what changes against it:\n\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") } - b.WriteString(shown) - fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest)) - if !whole { - b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " + - "this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " + - "mesh-cli settings proposals " + id + ".") - } - b.WriteString(" " + p.onApprove()) - q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator, + 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()} + About: p.about(), Details: d.String()} + 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 + } options := map[string]int{} for i, act := range p.actions(id) { binds, _ := asks.ActDigest(boundAct(act)) @@ -203,53 +205,87 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin return q, options } -// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`. -const shownMost = 1400 +// summary is the proposal's first line, what changes where: how many settings, how many stay, and that the machine +// takes the layer at its next push. +func (p settingsProposal) summary() string { + takes := p.Node + if p.Node == "" { + takes = "every machine" + } + if p.Clear { + if !p.HadLayer { + return "There is no layer to remove; approving changes nothing." + } + return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer goes at once and %s takes it at its next push.", + len(leaves(p.Before, "")), p.Module, p.where(), takes) + } + added, changed, removed := settingsChange(p.Before, p.Values) + n := len(added) + len(changed) + len(removed) + unchanged := len(leaves(p.Values, "")) - len(added) - len(changed) + if n == 0 { + return fmt.Sprintf("Nothing changes: the %d setting(s) of %s on %s are as they stand.", unchanged, p.Module, p.where()) + } + stay := "" + if unchanged > 0 { + stay = fmt.Sprintf(", and %d stay as they are", unchanged) + } + return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set at once and %s takes it at its next push.", + n, p.Module, p.where(), stay, takes) +} -// change is what changes, line by line, each value shown under the content rule, and whether every value was -// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the -// count of keys unchanged. -func (p settingsProposal) change(machines []string) (string, bool) { +// change is what changes, as at most asks.MostPoints points, and whether every value was shown whole: "+ 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) { whole := true say := func(v any) string { s, w := sayableValue(v, machines) + if exact { + s, w = wholeValue(v, machines) + } whole = whole && w return s } - var lines []string + type line struct{ key, text string } + var lines []line if p.Clear { was := leaves(p.Before, "") for _, k := range layerKeys(was) { - lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k]))) + lines = append(lines, line{k, fmt.Sprintf("- %s: %s", k, say(was[k]))}) } } else { added, changed, removed := settingsChange(p.Before, p.Values) was, now := leaves(p.Before, ""), leaves(p.Values, "") for _, k := range added { - lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k]))) + lines = append(lines, line{k, fmt.Sprintf("+ %s: %s", k, say(now[k]))}) } for _, k := range changed { - lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))) + lines = append(lines, line{k, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))}) } for _, k := range removed { - lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))) - } - unchanged := len(now) - len(added) - len(changed) - switch { - case len(lines) == 0 && unchanged > 0: - lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged)) - case unchanged > 0: - lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged)) + lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))}) } } - out := strings.Join(lines, "\n") - if len(out) > shownMost { - cut := shownMost - for cut > 0 && out[cut] != '\n' { - cut-- + var out []string + for i, l := range lines { + if len(lines) > asks.MostPoints && i == asks.MostPoints-1 { + var rest []string + 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 + break + } + out = append(out, l.text) + } + for i, l := range out { + if r := []rune(l); len(r) > asks.PointLength { + out[i] = string(r[:asks.PointLength-1]) + "…" + whole = false } - out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut) - whole = false } return out, whole } @@ -298,6 +334,27 @@ func sayableValue(v any, machines []string) (string, bool) { return out, out == text } +// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was +// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in +// part, so no half of a key reaches the phone. +func wholeValue(v any, machines []string) (string, bool) { + text, ok := v.(string) + if !ok { + raw, err := json.Marshal(v) + if err != nil { + return markWithheld, false + } + text = string(raw) + } + if text == "" { + return `""`, true + } + if _, ok := outward.Secret(text, machines...); !ok { + return markWithheld, false + } + return text, true +} + // sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make // pass is withheld whole. func sayable(text string, machines []string) string { @@ -430,6 +487,12 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+ "shown without it; %s", refusal, atTheTerminalInstead(in)) } + // The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds + // one, and the router would refuse the ask if one were left, so it is refused here first, in words. + if refusal, ok := outward.Secret(q.Whole, machines...); !ok { + return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s", + refusal, atTheTerminalInstead(in)) + } // Fail closed, before anything is kept: no router, no grant, no channel means no ask. if pr.routerHere != nil { here, err := pr.routerHere(ctx) diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index 2a405d2c..f2656ba3 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -116,24 +116,43 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) { if q.Options[0].Binds == q.Options[1].Binds { t.Error("Approve and Decline bind the same act") } - for _, line := range []string{ - "Set the settings of mounts on shanks to these values?", - "Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".", - "+ sources: recalbox=‹address›@‹path›:ro", - "~ shares: library=‹path› (was: none)", - "- old (was: x)", - "Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".", - "read it whole, with this fingerprint", - } { - if !strings.Contains(q.Explanation, line) { - t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation) - } + // The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else: + // who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers. + summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set at once and shanks takes it at its next push." + if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" { + t.Errorf("the explanation:\n%s", q.Explanation) + } + if err := asks.Glance(q.Explanation); err != nil { + t.Error(err) } for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} { if strings.Contains(q.Explanation, leak) { t.Errorf("the explanation carries %q, which may not leave the mesh", leak) } } + // Where the sender is proven, the values whole: the mount point and the share are what is approved. + if q.Whole != summary+"\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)" || + asks.Glance(q.Whole) != nil { + t.Errorf("the whole words:\n%s", q.Whole) + } + for _, line := range []string{ + "Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") + + ". Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".", + "Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.", + "Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal", + } { + if !strings.Contains(q.Details, line) { + t.Errorf("Details lack %q:\n%s", line, q.Details) + } + } + if refusal, ok := outward.Check(q.Details, "anchor", "laptop", "shanks"); !ok { + t.Errorf("the router would withhold a line of the details: %s", refusal) + } + for _, howTo := range []string{"ingerprint", "settings proposals", "settings verb", "not proven", "Proposed by"} { + if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) { + t.Errorf("the message carries the how-to %q", howTo) + } + } all := []string{q.Headline, q.Explanation, q.OnExpiry} for _, o := range q.Options { all = append(all, o.Label, o.Does) @@ -141,6 +160,9 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) { if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok { t.Errorf("the router would refuse the ask: %s", refusal) } + if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok { + t.Errorf("the router would refuse the whole words: %s", refusal) + } // Kept as the controller's own ask, about no condition, with the proposal whole and its digests. kept := r.theProposal(t) p := kept.Proposal @@ -173,11 +195,98 @@ func TestAMeshWideLayerIsProposed(t *testing.T) { } q := r.askSent(t) if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" || - !strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") { + q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set at once and every machine takes it at its next push.\n+ x: 1" || + q.Whole != "" { t.Errorf("%+v", q) } } +// kept is the one proposal the rig keeps. +func kept(r *proposerRig) asked { + for _, a := range r.store { + if a.Proposal != nil { + return a + } + } + return asked{} +} + +// The switch between the masked and the whole change (issue 383): the same change, under the content rule and +// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its +// key still named; and a change no value of which is masked carries no whole words of its own. +func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) { + machines := []string{"anchor", "laptop", "shanks"} + r := newProposerRig(t) + values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "font": "Inter 13"} + if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil { + t.Fatal(err) + } + q := r.askSent(t) + 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) + } + if want := []string{"+ font: Inter 13", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"}; strings.Join(masked, "\n") != strings.Join(want, "\n") { + t.Errorf("masked: %q", masked) + } + if want := []string{"+ font: Inter 13", "+ shares: media=/storage/media", "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"}; strings.Join(whole, "\n") != strings.Join(want, "\n") { + t.Errorf("whole: %q", whole) + } + if _, ok := outward.Secret(q.Whole, "shanks"); !ok { + t.Error("the router would refuse the whole words") + } + // Mutation: the masked and the whole change differ in exactly the lines whose value was masked. + differ := 0 + for i := range masked { + if masked[i] != whole[i] { + differ++ + if !strings.Contains(masked[i], "‹") || strings.Contains(whole[i], "‹") { + t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", masked[i], whole[i]) + } + } + } + if differ != 2 { + t.Errorf("%d lines differ, and the mask covered 2", differ) + } + // A value shaped like a secret is withheld in both, whole, with its key still named. + r1 := newProposerRig(t) + secrets := map[string]any{"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----"} + if _, err := r1.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: secrets}); err != nil { + t.Fatal(err) + } + q1 := r1.askSent(t) + for _, line := range []string{"+ cert: ‹withheld›", "+ github: ‹withheld›"} { + if !strings.Contains(q1.Explanation, line) { + t.Errorf("lacks %q:\n%s", line, q1.Explanation) + } + } + for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} { + if strings.Contains(q1.Explanation, secret) || strings.Contains(q1.Whole, secret) || strings.Contains(q1.Details, secret) { + t.Errorf("the secret %q reaches the phone", secret) + } + } + // More keys than points (issue 639): the first two, then the keys of the rest, read whole at the terminal. + r3 := newProposerRig(t) + five := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"} + 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 || + !strings.Contains(q3.Details, "settings proposals") { + t.Errorf("five keys:\n%s", q3.Explanation) + } + // No value masked: the message is the same everywhere, and the ask says no whole words. + r2 := newProposerRig(t) + if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil { + t.Fatal(err) + } + if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasSuffix(q2.Explanation, "\n+ font-size: 13\n+ width: 500") || + strings.Contains(q2.Details, "not proven") { + t.Errorf("%+v", q2) + } +} + // A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing. func TestAnExpiredProposalIsKeptExpired(t *testing.T) { r := newAskerRig(t) @@ -205,7 +314,8 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) { } q := r.askSent(t) if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") || - !strings.Contains(q.Explanation, "- places.data.path: ‹path›") { + !strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") || + !strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer goes at once") { t.Errorf("%+v", q) } if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 { diff --git a/go.mod b/go.mod index 60ad6d19..b1202b12 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.20261010191001-33917f91ac3c + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 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 96650c70..e8f550a7 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= 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= 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/internal/outward/outward.go b/internal/outward/outward.go index 6de9d9f6..6167b440 100644 --- a/internal/outward/outward.go +++ b/internal/outward/outward.go @@ -80,6 +80,57 @@ func Check(text string, machines ...string) (Refusal, bool) { return Refusal{"address", "a hardware address"}, false case reIPv6.MatchString(text): return Refusal{"address", "an IPv6 address"}, false + } + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + if reWinPath.MatchString(text) { + return Refusal{"path", "a drive path"}, false + } + if refusal, ok := randomRun(text); !ok { + return refusal, false + } + for _, word := range strings.FieldsFunc(text, isSeparator) { + w := strings.TrimRight(word, ".:!?") + if isPath(w) { + return Refusal{"path", "a file path"}, false + } + if isHostName(w) { + return Refusal{"address", "a host name"}, false + } + } + return Refusal{}, true +} + +// Secret says whether a text carries a secret's shape, the one class that leaves the mesh nowhere — the +// messenger's CheckSecret, one for one. It is what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) +// are held to: on a channel that proves who answers, a path or an address is what the operator approves and +// is shown; a secret never is. A path is read as one: a run with a slash in it is judged piece by piece +// between the slashes, so "/mnt/Photos_2024/Jochen" is a path and not a random string (the review of +// 2026-10-10); the named shapes hold whatever the run holds. +func Secret(text string, machines ...string) (Refusal, bool) { + text = withoutMachines(text, machines) + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + return randomRunOutsidePaths(text) +} + +// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes. +func randomRunOutsidePaths(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + for _, piece := range strings.Split(run, "/") { + if len(piece) >= 20 && looksRandom(piece) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + } + return Refusal{}, true +} + +// secretShape is the secret shapes a pattern names. +func secretShape(text string) (Refusal, bool) { + switch { case rePEM.MatchString(text): return Refusal{"secret", "a key block"}, false case reJWT.MatchString(text): @@ -92,23 +143,17 @@ func Check(text string, machines ...string) (Refusal, bool) { return Refusal{"secret", "a value given to a secret's name"}, false case reHex.MatchString(text): return Refusal{"secret", "a long hexadecimal string"}, false - case reWinPath.MatchString(text): - return Refusal{"path", "a drive path"}, false } + return Refusal{}, true +} + +// randomRun is a long unbroken run of characters spread as a random string's are. +func randomRun(text string) (Refusal, bool) { for _, run := range reRun.FindAllString(text, -1) { if looksRandom(run) { return Refusal{"secret", "a long random-looking string"}, false } } - for _, word := range strings.FieldsFunc(text, isSeparator) { - w := strings.TrimRight(word, ".:!?") - if isPath(w) { - return Refusal{"path", "a file path"}, false - } - if isHostName(w) { - return Refusal{"address", "a host name"}, false - } - } return Refusal{}, true } diff --git a/internal/outward/outward_test.go b/internal/outward/outward_test.go index fc1eed1c..0901cd70 100644 --- a/internal/outward/outward_test.go +++ b/internal/outward/outward_test.go @@ -86,3 +86,40 @@ func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) { t.Errorf("a text that passes was changed: %q", got) } } + +// Secret is the one class that leaves the mesh nowhere (novox/hq issue 383): it refuses every secret's shape +// Check refuses, and nothing Check refuses as an address or a path — those an ask's whole words may carry. +func TestSecretRefusesOnlyASecretsShape(t *testing.T) { + for _, text := range []string{ + "-----BEGIN RSA PRIVATE KEY-----", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc", + "123456789:ABCdefGHIjklMNOpqrSTUvwxYZ0123456789ab", + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "password=hunter2", + "0123456789abcdef0123456789abcdef", + "a key xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC", + } { + r, ok := Secret(text) + if ok || r.Class != "secret" { + t.Errorf("not refused as a secret: %q (%s)", text, r) + } + } + for _, text := range []string{ + "recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro", + "library=/mnt/library", + "photos=/mnt/Photos_2024/Jochen", + "games=smb://nas.lan/Recalbox_Games2024", + "/srv/media/Series_Archive/Season01", + "10.77.0.9:53", + "jochen@example.com", + "C:\\Users\\jo", + "anchor and the laptop", + } { + if r, ok := Secret(text, "anchor"); !ok { + t.Errorf("refused as %s: %q", r, text) + } + if _, ok := Check(text, "anchor"); ok && text != "anchor and the laptop" { + t.Errorf("Check lets %q leave, so Secret is not the narrower rule", text) + } + } +} diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 1cbaf6a8..fe654bd6 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 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json +mesh-sdk 9588871d6629e160bb0667f3d97566a0f8f7a4a6 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 008e69af..0f6f3aa3 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 @@ -202,11 +202,55 @@ type Ask struct { // The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left; // Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere. Whole string `json:"whole,omitempty"` - // Details is what the Details answer on the ask's message shows, line by line under the content rule: a - // fingerprint, how to read the proposal whole at the terminal. Empty, an ask's own message offers no Details. + // Details are the ask's secondary words, line by line under the content rule: who proposed it, a + // fingerprint, how to read it whole. The router shows them in the note of the ask's own message + // (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"` } +// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary +// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at +// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when, +// the fingerprint and how to read it whole are Details, never the explanation. +const ( + SummaryLength = 300 + MostPoints = 3 + PointLength = 160 +) + +// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than +// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not +// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that +// does not hold to it. +func Glance(explanation string) error { + var lines []string + for _, l := range strings.Split(explanation, "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, strings.TrimSpace(l)) + } + } + var problems []string + switch { + case len(lines) == 0: + problems = append(problems, "it has no summary") + case len([]rune(lines[0])) > SummaryLength: + problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength)) + } + if len(lines) > 1+MostPoints { + problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints)) + } + for i, l := range lines { + if i > 0 && len([]rune(l)) > PointLength { + problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength)) + } + } + if len(problems) > 0 { + return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; ")) + } + return nil +} + // The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). const ( MostOptions = 4 @@ -435,8 +479,12 @@ type Message struct { Title string `json:"title"` Body string `json:"body"` Buttons []Button `json:"buttons,omitempty"` - Urgent bool `json:"urgent,omitempty"` - Silent bool `json:"silent,omitempty"` + // Note is the message's secondary words, after its body and its buttons: who asked and when, what each + // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart + // where it can, and after the body where it cannot; it is never left out (novox/hq issue 639). + Note string `json:"note,omitempty"` + Urgent bool `json:"urgent,omitempty"` + Silent bool `json:"silent,omitempty"` // Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made. Reply string `json:"reply,omitempty"` // To is the account linked as the operator on this kind, for a channel that verifies its sender: where diff --git a/vendor/modules.txt b/vendor/modules.txt index 4a86d3fb..ced81f12 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op From 4644f25edd8677ce50bc52265b0c59cbb0f5ae8f Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 17:04:48 +0200 Subject: [PATCH 2/5] 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 From 3d94f87bb07260bf623b1ac7f32ed392f321cf36 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 17:30:04 +0200 Subject: [PATCH 3/5] The glance's cut says only what the note holds there: whole beside the whole details, else in the note (ADR 0347) --- cmd/mesh-controller/proposals.go | 13 ++++++++++--- cmd/mesh-controller/proposals_test.go | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index 53ea34f4..318feb16 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -283,6 +283,13 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str for i, l := range lines { all[i] = l.text } + // What the note holds of a value: whole where the glance is the whole one (shown only where the sender is + // proven, with the whole details beside it), under the content rule elsewhere — so the marker says no more than + // the note it points to (the review of ADR 0347). + inNote := "in the note" + if exact { + inNote = "whole in the note" + } var out []string for i, l := range lines { if len(lines) > asks.MostPoints && i == asks.MostPoints-1 { @@ -290,13 +297,13 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str for _, r := range lines[i:] { rest = append(rest, r.key) } - out = append(out, fmt.Sprintf("and %d more, each whole in the note: %s", len(rest), strings.Join(rest, ", "))) + out = append(out, fmt.Sprintf("and %d more, each %s: %s", len(rest), inNote, 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)" + // A point too long for a glance is cut, and says so: its value is the note's. + cut := "… (cut: " + inNote + ")" for i, l := range out { if r := []rune(l); len(r) > asks.PointLength { out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index 4fc03063..2ef2fde5 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -273,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, each whole in the note: 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 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) } @@ -885,7 +885,10 @@ func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) { 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") { + // The masked glance points to a note that holds the values masked, and says no more; the whole glance, shown only + // beside the whole details, says they are whole there. + if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each in the note: c, d, shares") || + strings.Contains(q.Explanation, "whole in the note") || !strings.HasSuffix(q.Whole, "\nand 3 more, each whole in the note: c, d, shares") { t.Errorf("the glance:\n%s", q.Explanation) } for k := range values { @@ -908,7 +911,11 @@ func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) { 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 { + // The cut point is 160 characters, its marker inside the bound. + if last := q2.Whole[strings.LastIndex(q2.Whole, "\n")+1:]; len([]rune(last)) != asks.PointLength { + t.Errorf("the cut point is %d characters, not %d: %q", len([]rune(last)), asks.PointLength, last) + } + if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.HasSuffix(q2.Explanation, "‹path›") && !strings.HasSuffix(q2.Explanation, "… (cut: 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) } } From 3c19e9ee93bfbe64426061472f5185919a5a6bf3 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 19:25:05 +0200 Subject: [PATCH 4/5] A settings proposal's summary says the layer is sent at once by a walk (ADR 0344 with ADR 0345) Rebased on #305: Approve now sends the layer by a walk, so the summary's "takes it at its next push" was false. The summary says it is set and sent at once by a walk to where it reaches, one machine at a time, each judged; the rest of what Approve does (onApprove) leads the Details, beside every key whole. --- cmd/mesh-controller/proposals.go | 18 ++++++++---------- cmd/mesh-controller/proposals_test.go | 6 +++--- cmd/mesh-controller/settings_walk_test.go | 9 ++++++--- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index 318feb16..c8aeefcc 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -177,6 +177,7 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin details := func(lines []string) string { var d strings.Builder d.WriteString(strings.Join(lines, "\n") + "\n") + d.WriteString(p.onApprove() + "\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 { @@ -214,19 +215,16 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin return q, options } -// summary is the proposal's first line, what changes where: how many settings, how many stay, and that the machine -// takes the layer at its next push. +// summary is the proposal's first line, what changes where: how many settings, how many stay, and that Approve sends +// the layer at once by a walk, one machine at a time (novox/hq ADR 0344); the rest of what Approve does +// (onApprove) is the Details'. func (p settingsProposal) summary() string { - takes := p.Node - if p.Node == "" { - takes = "every machine" - } if p.Clear { if !p.HadLayer { return "There is no layer to remove; approving changes nothing." } - return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer goes at once and %s takes it at its next push.", - len(leaves(p.Before, "")), p.Module, p.where(), takes) + return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer is removed and that is sent at once "+ + "by a walk to %s, one machine at a time, each judged.", len(leaves(p.Before, "")), p.Module, p.where(), p.reaches()) } added, changed, removed := settingsChange(p.Before, p.Values) n := len(added) + len(changed) + len(removed) @@ -238,8 +236,8 @@ func (p settingsProposal) summary() string { if unchanged > 0 { stay = fmt.Sprintf(", and %d stay as they are", unchanged) } - return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set at once and %s takes it at its next push.", - n, p.Module, p.where(), stay, takes) + return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set and sent at once by a walk to %s, "+ + "one machine at a time, each judged.", n, p.Module, p.where(), stay, p.reaches()) } // change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index 2ef2fde5..99a9bd7c 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -119,7 +119,7 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) { } // The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else: // who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers. - summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set at once and shanks takes it at its next push." + summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set and sent at once by a walk to shanks, one machine at a time, each judged." if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" { t.Errorf("the explanation:\n%s", q.Explanation) } @@ -196,7 +196,7 @@ func TestAMeshWideLayerIsProposed(t *testing.T) { } q := r.askSent(t) if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" || - q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set at once and every machine takes it at its next push.\n+ x: 1" || + q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set and sent at once by a walk to every machine running notes, one machine at a time, each judged.\n+ x: 1" || q.Whole != "" { t.Errorf("%+v", q) } @@ -316,7 +316,7 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) { q := r.askSent(t) if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") || !strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") || - !strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer goes at once") { + !strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer is removed and that is sent at once by a walk to laptop, one machine at a time") { t.Errorf("%+v", q) } if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 { diff --git a/cmd/mesh-controller/settings_walk_test.go b/cmd/mesh-controller/settings_walk_test.go index f8cca40a..48c34f2e 100644 --- a/cmd/mesh-controller/settings_walk_test.go +++ b/cmd/mesh-controller/settings_walk_test.go @@ -82,8 +82,11 @@ func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) { if err := q.Check(r.now); err != nil { t.Fatalf("the ask is refused: %v", err) } - if !strings.Contains(q.Explanation, "and sent at once to "+c.where+", one machine at a time, each judged, "+ - "every build there kept; its end is told as every walk's is") { + // At a glance (ADR 0345): the summary says it is sent at once by a walk, one machine at a time; the rest of + // what Approve does is the Details', which the router notes after the answers. + if !strings.Contains(q.Explanation, "sent at once by a walk to "+c.where+", one machine at a time, each judged") || + !strings.Contains(q.Details, "and sent at once to "+c.where+", one machine at a time, each judged, "+ + "every build there kept; its end is told as every walk's is") { t.Errorf("the ask does not say the layer is sent at once to %s:\n%s", c.where, q.Explanation) } approve := q.Options[0] @@ -93,7 +96,7 @@ func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) { if !strings.Contains(words, "a walk sends it to "+c.where+" at once") { t.Errorf("the proposer's answer:\n%s", words) } - for _, said := range []string{q.Explanation, approve.Does, words} { + for _, said := range []string{q.Explanation, q.Whole, approve.Does, words} { if strings.Contains(said, "push") { t.Errorf("a push is still named, as if a second act were needed:\n%s", said) } From deef9421a276348f6543b0bdbc089bbe11e3c2a9 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 20:30:55 +0200 Subject: [PATCH 5/5] A change's line the router would refuse is named with its value withheld, or refused at propose (the review of #304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {a:1, b:2, c:3, password:hunter2} was proposed with "+ password: hunter2" in its Details: the router withheld the line unnamed and Approve set a value the operator never saw (ADR 0347 §1, §3, §4). Each line is now judged as the router judges it, masked under the content rule and whole under the secret rule; a line refused is said "+ password (value withheld)", and any line the router would still refuse is refused at propose in the controller's words, never echoing a secret-shaped key. SDK at mesh-sdk 5933eb7 (no two asks digest the same). --- cmd/mesh-controller/proposals.go | 71 +++++++++++++++++++ cmd/mesh-controller/proposals_test.go | 58 +++++++++++++++ go.mod | 2 +- go.sum | 2 + testdata/beside/CAPTURED | 2 +- .../novox/mesh-sdk/go/asks/asks.go | 14 ++-- vendor/modules.txt | 2 +- 7 files changed, 143 insertions(+), 8 deletions(-) diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go index c8aeefcc..5bec29d7 100644 --- a/cmd/mesh-controller/proposals.go +++ b/cmd/mesh-controller/proposals.go @@ -277,6 +277,17 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))}) } } + // **Each line is judged as the router will judge it** (the review of #304, ADR 0347 §1 and §3): a line the + // content rule (masked) or the secret rule (whole) would refuse — a value given to a secret's name, say + // "+ password: hunter2" — is said with its key still named and its value withheld, never left for the + // router to withhold whole and unnamed. A line refused even so is the propose's refusal (judgeLines). + for i, l := range lines { + if lineLeaves(l.text, exact, machines) { + continue + } + lines[i].text = l.text[:2] + l.key + " (value withheld)" + whole = false + } all := make([]string, len(lines)) for i, l := range lines { all[i] = l.text @@ -310,6 +321,37 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str return out, all, whole } +// lineLeaves says whether a line may leave the mesh as the router judges it: under the content rule for the masked +// form, the secret rule alone for the whole one. +func lineLeaves(line string, exact bool, machines []string) bool { + if exact { + _, ok := outward.Secret(line, machines...) + return ok + } + _, ok := outward.Check(line, machines...) + return ok +} + +// judgeLines is the first line of an ask's details the router would refuse, or withhold, and why: masked under +// the content rule line by line, whole under the secret rule. Empty when every line may leave. +func judgeLines(details string, exact bool, machines []string) (string, string) { + for _, line := range strings.Split(details, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if exact { + if refusal, ok := outward.Secret(line, machines...); !ok { + return line, refusal.String() + } + continue + } + if refusal, ok := outward.Check(line, machines...); !ok { + return line, refusal.String() + } + } + return "", "" +} + func layerKeys(m map[string]any) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -513,6 +555,17 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s", refusal, atTheTerminalInstead(in)) } + // So are the details (ADR 0347 §1, §3): every line the router would withhold from the note, or refuse the ask + // for, is refused here, in the controller's own words, naming the line's key — Approve never sets a value the + // operator was shown nothing of. + if line, why := judgeLines(q.Details, false, machines); line != "" { + return refuse("the change's line %q would carry %s, which the operator's note may not show; %s", + keyOfLine(line), why, atTheTerminalInstead(in)) + } + if line, why := judgeLines(q.WholeDetails, true, machines); line != "" { + return refuse("the change's line %q shown whole would carry %s, which may not leave the mesh on any channel; %s", + keyOfLine(line), why, atTheTerminalInstead(in)) + } // Fail closed, before anything is kept: no router, no grant, no channel means no ask. if pr.routerHere != nil { here, err := pr.routerHere(ctx) @@ -902,3 +955,21 @@ func pushTo(node string) string { } return node } + +// keyOfLine is what a refusal names of a line: the key of a change's line ("+ key: …"), or the line's first words. +func keyOfLine(line string) string { + if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' { + rest := line[2:] + if i := strings.IndexAny(rest, ": "); i > 0 { + rest = rest[:i] + } + if _, ok := outward.Secret(rest); !ok { + return "whose key is shaped like a secret" + } + return rest + } + if r := []rune(line); len(r) > 40 { + return string(r[:40]) + "…" + } + return line +} diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go index 99a9bd7c..5a6f759f 100644 --- a/cmd/mesh-controller/proposals_test.go +++ b/cmd/mesh-controller/proposals_test.go @@ -919,3 +919,61 @@ func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) { t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails) } } + +// The review of #304: a value given to a secret's name, past the second key, is said with its key named and its +// value withheld, in the note on every channel; the router has nothing left to withhold unnamed or refuse. A line +// the router would refuse even so is refused at propose, in the controller's words. +func TestASecretNamedKeyIsNamedAndItsValueWithheld(t *testing.T) { + r := newProposerRig(t) + values := map[string]any{"a": "1", "b": "2", "c": "3", "password": "hunter2"} + if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: values}); err != nil { + t.Fatal(err) + } + q := r.askSent(t) + for _, form := range []string{q.Details, q.WholeDetails, q.Explanation, q.Whole} { + if strings.Contains(form, "hunter2") { + t.Errorf("the value reaches the phone:\n%s", form) + } + } + if !strings.Contains(q.Details, "+ password (value withheld)") { + t.Errorf("the key is not named with its value withheld:\n%s", q.Details) + } + if q.WholeDetails != "" && !strings.Contains(q.WholeDetails, "+ password (value withheld)") { + t.Errorf("the whole details do not name the key:\n%s", q.WholeDetails) + } + if line, why := judgeLines(q.Details, false, nil); line != "" { + t.Errorf("the router would withhold %q: %s", line, why) + } + if line, why := judgeLines(q.WholeDetails, true, nil); line != "" { + t.Errorf("the router would refuse %q: %s", line, why) + } + if !strings.HasSuffix(q.Explanation, "\nand 2 more, each in the note: c, password") { + t.Errorf("the glance:\n%s", q.Explanation) + } + // A key shaped like a secret cannot be named on any channel: refused at propose, in the controller's words, and + // the key itself not echoed. + r2 := newProposerRig(t) + secretKey := "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + _, err := r2.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", + values: map[string]any{"a": "1", "b": "2", secretKey: "3"}}) + if err == nil || !strings.Contains(err.Error(), "Nothing was proposed") || strings.Contains(err.Error(), secretKey) { + t.Errorf("a secret-shaped key: %v", err) + } + if len(r2.sent) != 0 { + t.Errorf("the refused proposal was asked") + } +} + +// keyOfLine names a change's key, and never one shaped like a secret. +func TestARefusalNamesTheKeyAndNeverASecret(t *testing.T) { + for line, want := range map[string]string{ + "+ password (value withheld)": "password", + "~ shares: ‹path› (was: none)": "shares", + "+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "whose key is shaped like a secret", + "Proposed by g14 at 15:04.": "Proposed by g14 at 15:04.", + } { + if got := keyOfLine(line); got != want { + t.Errorf("%q: %q, want %q", line, got, want) + } + } +} diff --git a/go.mod b/go.mod index 4353423c..a25c10d7 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.20261013145622-f4745447ba6d + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 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 564a85a0..75618b31 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6 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= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/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 e3ded499..5e6e1f87 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 f4745447ba6d0c4fd7dc6b1071e26ce012806233 conformance/events/module-event.json +mesh-sdk 5933eb757d443293631be2bed27a96288c2348d8 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 8915af85..596bb8f8 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 @@ -170,14 +170,16 @@ func (a Ask) Digest() string { canonical(&b, v) } } - if a.Whole != "" || a.Details != "" { + // Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever + // it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes — + // none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks + // encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and + // one without WholeDetails as before it existed. + if a.Whole != "" || a.Details != "" || a.WholeDetails != "" { 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())) @@ -493,7 +495,9 @@ type Message struct { Buttons []Button `json:"buttons,omitempty"` // Note is the message's secondary words, after its body and its buttons: who asked and when, what each // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart - // where it can, and after the body where it cannot; it is never left out (novox/hq issue 639). + // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's + // length cuts the body first and the note only when the title and the note alone do not fit, saying each + // cut (novox/hq ADR 0347 §5). Note string `json:"note,omitempty"` Urgent bool `json:"urgent,omitempty"` Silent bool `json:"silent,omitempty"` diff --git a/vendor/modules.txt b/vendor/modules.txt index 9b441225..7607b731 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 ## explicit; go 1.22 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op