Files
mesh-contracts/go/clients/operatorchannelclient/ask.go
T
jschoubben 5118c5f425 J7 (1): the operator-channel client reads an ask its keeper carries back, held to its id and an option
The assigner, the teller, the view and the gate each wrote the same two checks over the ask a value of theirs carries;
the fourth copy made it a kind, so it lives once in the seat's client (Carried) and the four call it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:38:46 +01:00

394 lines
16 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"
"encoding/json"
"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
}
// Carried reads an ask a module keeps or hands on as the seat's JSON (a value of its own state that carries the ask it
// publishes, novox/hq ADR 0627 §4) and holds it to what every keeper of an ask owes, whoever reads it back: its id is the
// one the keeper gave it (id), and it offers at least one option. It checks nothing else of the ask: Check is what an
// ask is held to before it is shown, and an ask kept long ago is read back even when the bounds of Check moved since.
func Carried(raw []byte, id string) (seat.Ask, error) {
var a seat.Ask
if err := json.Unmarshal(raw, &a); err != nil {
return seat.Ask{}, fmt.Errorf("the value carries no operator-channel ask: %w", err)
}
if a.ID != id {
return seat.Ask{}, fmt.Errorf("the ask's id %q is not %q, the id it was kept under", a.ID, id)
}
if len(a.Options) == 0 {
return seat.Ask{}, fmt.Errorf("the ask %q offers no option", a.ID)
}
return a, 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 }