Files
mesh-contracts/go/clients/operatorchannelclient/ask.go
T
jschoubben b5db9466c2 ADR 0627 §1: operator-channel's client holds the asking and the warrant's checks over the contract's types
mesh-sdk's asks logic (an ask's check and digest, the act's digest, a warrant's For and Says, the condition's
encoding) and its asker client move here, over operatorchannel's generated Ask, Option, Warrant and Condition; the
digests are those asks gave (a row holds two of them), so a router and an asker of either build agree.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 13:13:59 +01:00

375 lines
15 KiB
Go

// Package operatorchannelclient is the operator-channel seat's client (novox/hq ADR 0627 §1; ADR 0259): the logic
// every asker and the router share over the seat's contract. An asker checks its ask (Check, Glance), binds what each
// option performs (ActDigest), publishes it under its own name and acts on the warrant it hears only when the
// warrant answers that ask, as the person was shown it (Digest, For, Performs). A module says a condition to the
// operator by the seat's condition accept (EncodeCondition). The asker's keeping of its asks and warrants is Client.
// The shapes are the seat's contract's (operatorchannel); this package holds no type of its own for them.
package operatorchannelclient
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
)
// The levels an option is answered at: how much proof its answer needs (novox/hq ADR 0234 §8, the glossary's
// assurance level).
const (
// Acknowledge performs only what any granted principal may already do: silencing, details. No proof.
Acknowledge = "acknowledge"
// Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code.
Approve = "approve"
// Destroy needs two proofs, one of them a code.
Destroy = "destroy"
)
// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less.
func Rank(level string) int {
switch level {
case Acknowledge:
return 0
case Approve:
return 1
case Destroy:
return 2
}
return 3
}
// Operator is the one role an ask may be answered by today.
const Operator = "operator"
// How an ask ended (a warrant's outcome).
const (
OutcomeChosen = "chosen" // a person chose an option: a warrant
OutcomeExpired = "expired" // nobody answered in time
OutcomeCancelled = "cancelled" // its asker took it back
OutcomeReplaced = "replaced" // a newer ask about the same thing replaced it
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
// 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
}
// 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')
}
// Time reads an RFC 3339 time of the contract (an ask's expiry, a warrant's time); empty is the zero time.
func Time(s string) (time.Time, error) {
if s == "" {
return time.Time{}, nil
}
return time.Parse(time.RFC3339Nano, s)
}
// FormatTime is a time as the contract carries it: UTC, RFC 3339 to the nanosecond; the zero time is empty.
func FormatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339Nano)
}
// 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 (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 the ask 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. An expiry that is no RFC 3339 time is digested as written.
func Digest(a seat.Ask) string {
expires := a.Expires
if t, err := Time(a.Expires); err == nil {
expires = t.UTC().Format(time.RFC3339Nano)
}
var b strings.Builder
b.WriteString("novox.ask.v1\n")
for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who, expires, 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, o.Level, o.Binds} {
canonical(&b, v)
}
}
// Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever
// it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes —
// none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks
// encode the same. An ask with none of them digests as before they existed, and one without WholeDetails as
// before it existed.
if a.Whole != "" || a.Details != "" || a.WholeDetails != "" {
canonical(&b, a.Whole)
canonical(&b, a.Details)
}
if a.WholeDetails != "" {
canonical(&b, a.WholeDetails)
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:])
}
// 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
MostOpen = 3
ApproveLasts = 24 * time.Hour
DestroyLasts = 10 * time.Minute
HeadlineLength = 60
LabelLength = 24
)
var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both.
func UsableID(id string) bool { return usableID.MatchString(id) }
// Highest is the highest level among the ask's options.
func Highest(a seat.Ask) string {
high := Acknowledge
for _, o := range a.Options {
if Rank(o.Level) > Rank(high) {
high = o.Level
}
}
return high
}
// OptionOf is the ask's option of this id, or false.
func OptionOf(a seat.Ask, id string) (seat.Option, bool) {
for _, o := range a.Options {
if o.ID == id {
return o, true
}
}
return seat.Option{}, false
}
// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on.
func Check(a seat.Ask, now time.Time) error {
var problems []string
say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
if !UsableID(a.ID) {
say("its id %q is not letters, digits, - and _, at most 64", a.ID)
}
if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength {
say("its headline is empty or longer than %d characters", HeadlineLength)
}
if strings.TrimSpace(a.Explanation) == "" {
say("it explains nothing")
}
if a.Who != Operator {
say("it is answered by %q, and only the operator answers today", a.Who)
}
if len(a.Options) == 0 || len(a.Options) > MostOptions {
say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions)
}
seen := map[string]bool{}
for _, o := range a.Options {
switch {
case !UsableID(o.ID):
say("an option's id %q is not letters, digits, - and _", o.ID)
case seen[o.ID]:
say("the option %s is offered twice", o.ID)
}
seen[o.ID] = true
if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength {
say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength)
}
if strings.TrimSpace(o.Does) == "" {
say("the option %s does not say what it does", o.ID)
}
if Rank(o.Level) > Rank(Destroy) {
say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level)
}
if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") {
say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID)
}
}
expires, err := Time(a.Expires)
if err != nil {
say("its expiry %q is not an RFC 3339 time", a.Expires)
}
if !expires.After(now) {
say("it expires before it is asked")
}
switch Highest(a) {
case Approve:
if expires.After(now.Add(ApproveLasts)) {
say("an ask that approves lasts at most %s", ApproveLasts)
}
case Destroy:
if expires.After(now.Add(DestroyLasts)) {
say("an ask that destroys lasts at most %s", DestroyLasts)
}
}
if Highest(a) != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" {
say("it does not say what happens when nobody answers, and an ask that authorises never defaults")
}
if a.About != "" && strings.ContainsAny(a.About, " \n") {
say("what it is about is a key, without spaces")
}
if len(problems) > 0 {
return errors.New("the ask is refused: " + strings.Join(problems, "; "))
}
return nil
}
// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id
// verified), chose Release".
func Says(w seat.Warrant) string {
if w.Outcome != OutcomeChosen || w.By == nil {
return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome)
}
via := w.By.Kind
if w.By.Verified != "" {
via += " (" + w.By.Verified + ")"
}
return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label)
}
// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the
// same words, options and binds), a choice, an option the ask offered, at that option's level, before the
// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names.
func For(w seat.Warrant, asker string, a seat.Ask) (seat.Option, error) {
if w.Asker != asker || w.Ask != a.ID {
return seat.Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID)
}
if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who {
return seat.Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome)
}
if d := Digest(a); w.AskDigest != d {
return seat.Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+
"it was not the ask the person was shown", w.AskDigest, a.ID, d)
}
o, offered := OptionOf(a, w.Option)
if !offered {
return seat.Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option)
}
if w.Level != o.Level {
return seat.Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level)
}
// A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no
// expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time.
expires, err := Time(a.Expires)
if err != nil || expires.IsZero() {
return seat.Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID)
}
at, err := Time(w.At)
if err != nil || at.IsZero() {
return seat.Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID)
}
if at.After(expires) {
return seat.Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s",
at.UTC().Format(time.RFC3339), a.ID, expires.UTC().Format(time.RFC3339))
}
return o, nil
}
// 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 Performs(o seat.Option, act Act) error {
if o.Binds == "" && o.Level == Acknowledge {
return nil
}
d, err := ActDigest(act)
if err != nil {
return err
}
if d != o.Binds {
return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d)
}
return nil
}
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR
// 0259 §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects
// granted to that node's engine account alone, so no engine acts on another node's warrant.
func EngineAsker(node string) string { return "node-engine-" + node }