From 9588871d6629e160bb0667f3d97566a0f8f7a4a6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:15:20 +0200 Subject: [PATCH 1/3] go/asks: a message carries its note apart, and an explanation is held to a glance (hq issues 639 and 383) The operator will not read a Telegram ask of thirty plain lines. The secondary words (who asked, what each answer does, the expiry, the fingerprint, how to read it whole) travel as Message.Note, so a channel can show them small or collapsed; an ask's Details go there. Glance names the bound of an explanation: a summary of at most 300 characters, then at most three points of at most 160. --- go/asks/asks.go | 56 ++++++++++++++++++++++++++++++++++++++++---- go/asks/asks_test.go | 38 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/go/asks/asks.go b/go/asks/asks.go index 008e69a..0f6f3aa 100644 --- a/go/asks/asks.go +++ b/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/go/asks/asks_test.go b/go/asks/asks_test.go index c143c74..c64d49c 100644 --- a/go/asks/asks_test.go +++ b/go/asks/asks_test.go @@ -279,3 +279,41 @@ func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) { t.Error("a warrant for an ask with no expiry was taken") } } + +// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639). +func TestAnExplanationReadsAtAGlance(t *testing.T) { + if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil { + t.Fatalf("a summary and two points: %v", err) + } + var wall strings.Builder + wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.") + for i := 1; i <= 13; i++ { + wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3)) + } + err := Glance(wall.String()) + if err == nil || !strings.Contains(err.Error(), "13 points") { + t.Fatalf("thirteen clause lines pass as a glance: %v", err) + } + if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") { + t.Fatalf("a long summary passes: %v", err) + } + if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") { + t.Fatalf("a long point passes: %v", err) + } + if err := Glance(" \n"); err == nil { + t.Fatal("an empty explanation passes") + } +} + +// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older +// than it reads every message it read before. +func TestAMessageCarriesItsNoteApart(t *testing.T) { + raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."}) + if !strings.Contains(string(raw), `"note":"Asked by x."`) { + t.Fatalf("the note is not encoded: %s", raw) + } + raw, _ = json.Marshal(Message{Handle: "h", Title: "t"}) + if strings.Contains(string(raw), "note") { + t.Fatalf("an empty note is encoded: %s", raw) + } +} From f4745447ba6d0c4fd7dc6b1071e26ce012806233 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:56:22 +0200 Subject: [PATCH 2/3] go/asks: an ask may carry its Details whole, for a channel that proves who answers (hq issue 383) The review of mesh-controller #304: a settings proposal with more than three keys showed only the names of the rest, and its Details could carry no value under the content rule, so Approve bound values the operator never saw. WholeDetails carries every value whole for the note on a proving channel, held to the secret rule alone, as Whole is; it is digested only when given. --- go/asks/asks.go | 12 ++++++++++++ go/asks/asks_test.go | 24 ++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/go/asks/asks.go b/go/asks/asks.go index 0f6f3aa..8915af8 100644 --- a/go/asks/asks.go +++ b/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/go/asks/asks_test.go b/go/asks/asks_test.go index c64d49c..3ce66d6 100644 --- a/go/asks/asks_test.go +++ b/go/asks/asks_test.go @@ -253,8 +253,8 @@ func TestDigestsAreCanonicalOverNamedFields(t *testing.T) { t.Error("changing Whole beside Details left the digest the same") } // A field added to Ask or Option without being added to Digest fails here. - if n := reflect.TypeOf(Ask{}).NumField(); n != 11 { - t.Errorf("Ask has %d fields, and Digest covers 11: add the new one to Digest and to this test", n) + if n := reflect.TypeOf(Ask{}).NumField(); n != 12 { + t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n) } if n := reflect.TypeOf(Option{}).NumField(); n != 5 { t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n) @@ -317,3 +317,23 @@ func TestAMessageCarriesItsNoteApart(t *testing.T) { t.Fatalf("an empty note is encoded: %s", raw) } } + +// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker +// of different builds still agree on every such ask. +func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) { + a := Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"} + before := a.Digest() + a.WholeDetails = "+ shares: media=/storage/media" + if a.Digest() == before { + t.Fatal("the whole details are not in the digest") + } + b := a + b.WholeDetails = "+ shares: media=/storage/other" + if a.Digest() == b.Digest() { + t.Fatal("two whole details digest the same") + } + a.WholeDetails = "" + if a.Digest() != before { + t.Fatal("an ask without whole details digests otherwise than before") + } +} From 5933eb757d443293631be2bed27a96288c2348d8 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 20:29:24 +0200 Subject: [PATCH 3/3] go/asks: whole details never digest as another ask's Whole and Details (the review of #25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WholeDetails was written after a tag, so an ask with only whole details "+ x: 1" encoded as one whose Whole was "whole-details" and Details "+ x: 1". Whole and Details are now written whenever whole details are given, then the whole details, untagged: the number of trailing values tells the three shapes apart, and an ask without whole details digests as before. The note's comment says what ADR 0347 §5 holds of cuts. --- go/asks/asks.go | 14 +++++++++----- go/asks/asks_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/go/asks/asks.go b/go/asks/asks.go index 8915af8..596bb8f 100644 --- a/go/asks/asks.go +++ b/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/go/asks/asks_test.go b/go/asks/asks_test.go index 3ce66d6..b9a7fdc 100644 --- a/go/asks/asks_test.go +++ b/go/asks/asks_test.go @@ -337,3 +337,31 @@ func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) { t.Fatal("an ask without whole details digests otherwise than before") } } + +// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as +// a Whole and Details that happen to spell the same values, nor as any other split of the three fields. +func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) { + base := Ask{ID: "a", Headline: "h", Explanation: "e"} + shapes := []Ask{} + for _, f := range []struct{ whole, details, wholeDetails string }{ + {"", "", "+ x: 1"}, + {"whole-details", "+ x: 1", ""}, + {"", "+ x: 1", ""}, + {"+ x: 1", "", ""}, + {"", "", "whole-details"}, + {"", "+ x: 1", "+ x: 1"}, + {"+ x: 1", "+ x: 1", ""}, + {"", "", ""}, + } { + a := base + a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails + shapes = append(shapes, a) + } + seen := map[string]int{} + for i, a := range shapes { + if j, ok := seen[a.Digest()]; ok { + t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a) + } + seen[a.Digest()] = i + } +}