diff --git a/README.md b/README.md index f4c1918..bdb0b69 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ an event under the module's own name or kind, `stdio.SeatTake` takes a held seat and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept), `stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the -contract on top: the `Ask` an asker publishes and the checks it is held to, the `Warrant` it acts on +contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves +who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on (`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the subjects each travels on. `go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then diff --git a/go/asks/asks.go b/go/asks/asks.go index 81737ac..008e69a 100644 --- a/go/asks/asks.go +++ b/go/asks/asks.go @@ -154,7 +154,9 @@ func canonical(b *strings.Builder, v string) { // // It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC // RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask -// later changes no digest until it is added here, on purpose. +// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow +// the options only when the ask gives either: an ask without them digests as it did before they existed, so +// a router and an asker of different builds still agree on every such ask. func (a Ask) Digest() string { var b strings.Builder b.WriteString("novox.ask.v1\n") @@ -168,6 +170,10 @@ func (a Ask) Digest() string { canonical(&b, v) } } + if a.Whole != "" || a.Details != "" { + canonical(&b, a.Whole) + canonical(&b, a.Details) + } sum := sha256.Sum256([]byte(b.String())) return "sha256:" + hex.EncodeToString(sum[:]) } @@ -190,6 +196,15 @@ type Ask struct { // About is what the ask is about (a condition's key): a newer ask about it replaces the older. About string `json:"about,omitempty"` Urgent bool `json:"urgent,omitempty"` + // Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind + // that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the + // person approves — a mount point, a share, a private address — must be readable where they approve it. + // 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 string `json:"details,omitempty"` } // The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). diff --git a/go/asks/asks_test.go b/go/asks/asks_test.go index 999b418..c143c74 100644 --- a/go/asks/asks_test.go +++ b/go/asks/asks_test.go @@ -230,9 +230,31 @@ func TestDigestsAreCanonicalOverNamedFields(t *testing.T) { t.Errorf("changing %s left the digest the same", name) } } + // Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests + // exactly as before they existed — the encoding by hand, so no build can drift from it unseen. + plain := Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: now, OnExpiry: "o", About: "a", + Options: []Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}} + byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" + + "2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n")) + if plain.Digest() != "sha256:"+hex.EncodeToString(byHand[:]) { + t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", plain.Digest()) + } + withWhole, withDetails := plain, plain + withWhole.Whole = "e, whole" + withDetails.Details = "fingerprint 1234" + if withWhole.Digest() == plain.Digest() || withDetails.Digest() == plain.Digest() || withWhole.Digest() == withDetails.Digest() { + t.Error("Whole or Details left the digest the same") + } + both := withWhole + both.Details = "fingerprint 1234" + changed := both + changed.Whole += "!" + if changed.Digest() == both.Digest() { + 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 != 9 { - t.Errorf("Ask has %d fields, and Digest covers 9: add the new one to Digest and to this test", n) + 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(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)