From 9588871d6629e160bb0667f3d97566a0f8f7a4a6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 16:15:20 +0200 Subject: [PATCH] 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) + } +}