Merge pull request 'go/asks: an ask says what a channel that proves who answers shows whole, and what Details shows (issue 383)' (#17) from fix/383-an-ask-says-what-a-proving-channel-shows-whole into main

This commit was merged in pull request #17.
This commit is contained in:
2026-10-10 13:50:56 +00:00
3 changed files with 42 additions and 4 deletions
+2 -1
View File
@@ -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
+16 -1
View File
@@ -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).
+24 -2
View File
@@ -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)