Ask an acknowledgement apart from an approval, change every kept ask by compare-and-set, and rehearse rather than drill (hq ADR 0259, review M1/L2/L3/L7)

- M1: a condition offering both kinds of answer is asked twice: its authorising answers about the
  condition, its acknowledging ones (Silence) apart, so an answer from a channel that only acknowledges
  never ends an approval.
- L2: the asked store creates once and changes only over the revision it read, deciding again on what it
  reads; a stale cancel no longer writes over an act.
- L3: every ask is kept before it is published, one whose publishing failed is marked unsent and asked
  again, and a cancel is kept before it is said. The terminal's test question is now `rehearse`, so it is
  not called what the glossary calls a drill; its two answers are both approve-level.
- L7: two deliveries of one warrant to two controllers at once act exactly once, on a real bus.
- Re-vendored onto mesh-sdk 76902998 (canonical digests): an option binds an asks.Act with each argument
  as arg.<name>.
- The lab's bus fixture composes verified-sender only where the lab says its machine is root-free
  (MESH_LAB_ASKS_ROOT_FREE=true).
This commit is contained in:
2026-10-09 16:22:34 +02:00
parent ad406e81b8
commit 799eec0a5a
14 changed files with 736 additions and 321 deletions
+55 -12
View File
@@ -8,10 +8,11 @@ package asks
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
@@ -107,25 +108,67 @@ type Option struct {
Binds string `json:"binds,omitempty"`
}
// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's JSON (Go's encoding sorts a
// map's keys, so the same act gives the same digest), written "sha256:<hex>".
func ActDigest(act any) (string, error) {
raw, err := json.Marshal(act)
if err != nil {
return "", fmt.Errorf("the act cannot be digested: %w", err)
// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each
// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and
// values alone, never over how a language happens to encode a struct.
type Act map[string]string
// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical),
// written "sha256:<hex>". The same names and values give the same digest in any language, whatever order
// they were set in; a field renamed, added or emptied gives another.
func ActDigest(act Act) (string, error) {
if len(act) == 0 {
return "", fmt.Errorf("the act cannot be digested: it names nothing")
}
sum := sha256.Sum256(raw)
keys := make([]string, 0, len(act))
for k := range act {
if k == "" {
return "", fmt.Errorf("the act cannot be digested: a field has no name")
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("novox.act.v1\n")
for _, k := range keys {
canonical(&b, k)
canonical(&b, act[k])
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read
// as another's end or start, so two different sequences of values never encode the same.
func canonical(b *strings.Builder, v string) {
b.WriteString(strconv.Itoa(len(v)))
b.WriteByte(':')
b.WriteString(v)
b.WriteByte('\n')
}
// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each
// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker
// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the
// person was shown it, and nothing published under the same id before or after.
//
// 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.
func (a Ask) Digest() string {
a.Expires = a.Expires.UTC()
raw, _ := json.Marshal(a)
sum := sha256.Sum256(raw)
var b strings.Builder
b.WriteString("novox.ask.v1\n")
for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who,
a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent),
strconv.Itoa(len(a.Options))} {
canonical(&b, v)
}
for _, o := range a.Options {
for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} {
canonical(&b, v)
}
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:])
}
@@ -341,7 +384,7 @@ func (w Warrant) For(asker string, a Ask) (Option, error) {
// Performs checks that the act an asker is about to perform is the one the chosen option bound when it
// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes.
func (o Option) Performs(act any) error {
func (o Option) Performs(act Act) error {
if o.Binds == "" && o.Level == Acknowledge {
return nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8
# git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39
## explicit; go 1.22
git.novox.be/novox/mesh-sdk/go/asks
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op