Compare commits

..
2 Commits
Author SHA1 Message Date
jochen 52bcef159d A settings proposal carries every key with its whole value in its Details (issue 383)
mesh/merge-gate head 52bcef159d95 merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head 52bcef159d95 merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery ready: it delivers once merged
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance ready: every member ready, and composed together they pass
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.
2026-10-13 17:04:48 +02:00
jochen 35232c8534 A settings proposal reads at a glance, its values whole where the sender is proven (issues 383 and 639)
mesh/merge-gate head 35232c853483 merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head 35232c853483 merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance checking: composed check out of date: novox/mesh-catalog@ea1dfdce5bc7 was judged against trunk 5f491…
mesh/delivery superseded: a newer head of the same pull request
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.
2026-10-13 16:33:52 +02:00
9 changed files with 477 additions and 89 deletions
+131 -57
View File
@@ -27,6 +27,13 @@ package main
// ask says so, and the whole is read with `settings proposals <id>` — 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"
@@ -154,39 +161,39 @@ 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; 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")
}
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")
// 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()
}
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 + ".")
}
if p.Clear {
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
} else {
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
}
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
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: 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 {
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))
@@ -205,55 +212,95 @@ 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 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 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)
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--
}
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut)
whole = false
all := make([]string, len(lines))
for i, l := range lines {
all[i] = l.text
}
return out, whole
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, 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-len([]rune(cut))]) + cut
}
}
return out, all, whole
}
func layerKeys(m map[string]any) []string {
@@ -300,6 +347,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 {
@@ -432,6 +500,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)
+182 -14
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"slices"
@@ -116,24 +117,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 +161,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 +196,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, 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)
}
// 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 +315,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 {
@@ -744,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)
}
}
+1 -1
View File
@@ -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.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
+4
View File
@@ -2,6 +2,10 @@ 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=
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=
+56 -11
View File
@@ -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
}
+37
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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 f4745447ba6d0c4fd7dc6b1071e26ce012806233 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
+64 -4
View File
@@ -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[:])
}
@@ -202,9 +208,59 @@ 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"`
// 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
// 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).
@@ -435,8 +491,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
+1 -1
View File
@@ -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.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