A3d item 1: operatorchannelclient re-exports mesh-sdk's act and its digest, one form

Builds: design 47 G §677, lead answers A-198, A-219

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 06:09:18 +01:00
parent 49e26f5d27
commit 33d9b28869
2 changed files with 11 additions and 29 deletions
@@ -13,12 +13,12 @@ import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/operatorask"
)
// The levels an option is answered at: how much proof its answer needs (novox/hq ADR 0234 §8, the glossary's
@@ -57,35 +57,11 @@ const (
OutcomeRefused = "refused" // it was never shown: Words says why
)
// 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
// An Act is what an option binds: mesh-sdk's operatorask.Act, re-exported (design 47 G lead answers A-198, A-219).
type Act = operatorask.Act
// ActDigest is the digest an asker puts in an option's 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")
}
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
}
// ActDigest is the digest an asker puts in an option's Binds: mesh-sdk's operatorask.ActDigest, re-exported.
func ActDigest(act Act) (string, error) { return operatorask.ActDigest(act) }
// 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.
@@ -10,6 +10,7 @@ import (
"time"
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/operatorask"
)
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
@@ -189,6 +190,11 @@ func TestDigestsAreCanonicalOverNamedFields(t *testing.T) {
Digest(gold) != "sha256:997af29a80663c5eb7d9dd38fc029c4a15fd726220b25e3c7cdc54d5a60bbbae" {
t.Errorf("the digests changed with the move: %s, %s", Digest(held()), Digest(gold))
}
// The act is mesh-sdk's, re-exported (A-198, A-219): one form, so the seat server's gate digests as an asker does.
var shared operatorask.Act = Act{"verb": "release", "arg.delivery": "hq"}
if d, _ := operatorask.ActDigest(shared); d == "" {
t.Error("mesh-sdk's digest of the act is empty")
}
a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"})
b := Act{}
b["arg.delivery"] = "hq"