Regenerate the domain contracts; hand-acts and conditions clients; operator-channel client moves; the seat-set package ends (ADR 0634, ADR 0635; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -1,393 +0,0 @@
|
||||
// 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 }
|
||||
@@ -1,388 +0,0 @@
|
||||
package operatorchannelclient
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
func held() seat.Ask {
|
||||
return seat.Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.",
|
||||
Who: Operator, Expires: FormatTime(now.Add(time.Hour)), OnExpiry: "the delivery stays held", About: "delivery.openrazer",
|
||||
Options: []seat.Option{
|
||||
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")},
|
||||
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")},
|
||||
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge},
|
||||
}}
|
||||
}
|
||||
|
||||
func bind(verb, what string) string {
|
||||
d, err := ActDigest(Act{"verb": verb, "delivery": what})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestASoundAskPasses(t *testing.T) {
|
||||
if err := Check(held(), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) {
|
||||
for want, change := range map[string]func(*seat.Ask){
|
||||
"is not letters": func(a *seat.Ask) { a.ID = "a.b" },
|
||||
"headline is empty": func(a *seat.Ask) { a.Headline = "" },
|
||||
"explains nothing": func(a *seat.Ask) { a.Explanation = " " },
|
||||
"only the operator answers": func(a *seat.Ask) { a.Who = "agent" },
|
||||
"offers 0 options": func(a *seat.Ask) { a.Options = nil },
|
||||
"offers 5 options": func(a *seat.Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) },
|
||||
"is offered twice": func(a *seat.Ask) { a.Options[1].ID = "release" },
|
||||
"does not say what it does": func(a *seat.Ask) { a.Options[0].Does = "" },
|
||||
"none of acknowledge": func(a *seat.Ask) { a.Options[0].Level = "admin" },
|
||||
"expires before it is asked": func(a *seat.Ask) { a.Expires = FormatTime(now) },
|
||||
"approves lasts at most": func(a *seat.Ask) { a.Expires = FormatTime(now.Add(25 * time.Hour)) },
|
||||
"destroys lasts at most": func(a *seat.Ask) { a.Options[0].Level = Destroy },
|
||||
"never defaults": func(a *seat.Ask) { a.OnExpiry = "" },
|
||||
"is a key, without spaces": func(a *seat.Ask) { a.About = "a b" },
|
||||
"does not bind what it": func(a *seat.Ask) { a.Options[0].Binds = "" },
|
||||
} {
|
||||
a := held()
|
||||
change(&a)
|
||||
if err := Check(a, now); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("want a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
ack := held()
|
||||
ack.Options = ack.Options[2:]
|
||||
ack.OnExpiry = ""
|
||||
ack.Expires = FormatTime(now.Add(7 * 24 * time.Hour))
|
||||
if err := Check(ack, now); err != nil {
|
||||
t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err)
|
||||
}
|
||||
// Carried: an ask read back from a keeper's value is its own id's, and offers an option.
|
||||
raw, _ := json.Marshal(held())
|
||||
if a, err := Carried(raw, "d-1"); err != nil || !reflect.DeepEqual(a, held()) {
|
||||
t.Errorf("a carried sound ask reads back whole: %v %+v", err, a)
|
||||
}
|
||||
none := held()
|
||||
none.Options = nil
|
||||
noOptions, _ := json.Marshal(none)
|
||||
for want, c := range map[string]struct {
|
||||
raw []byte
|
||||
id string
|
||||
}{
|
||||
"carries no operator-channel ask": {[]byte("[1]"), "d-1"},
|
||||
`is not "d-2"`: {raw, "d-2"},
|
||||
"offers no option": {noOptions, "d-1"},
|
||||
} {
|
||||
if _, err := Carried(c.raw, c.id); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("Carried: want a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) {
|
||||
w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
||||
Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
AskDigest: Digest(held()), At: FormatTime(now.Add(time.Minute))}
|
||||
if o, err := For(w, "an-asker", held()); err != nil || o.ID != "release" {
|
||||
t.Fatalf("a sound warrant was refused: %v", err)
|
||||
}
|
||||
if got := Says(w); got != "the operator, via telegram (user id verified), chose Release" {
|
||||
t.Errorf("the warrant says %q", got)
|
||||
}
|
||||
for why, change := range map[string]func(*seat.Warrant){
|
||||
"another asker": func(w *seat.Warrant) { w.Asker = "mesh-controller" },
|
||||
"another ask": func(w *seat.Warrant) { w.Ask = "d-2" },
|
||||
"no choice": func(w *seat.Warrant) { w.Outcome = OutcomeExpired },
|
||||
"nobody": func(w *seat.Warrant) { w.By = nil },
|
||||
"not offered": func(w *seat.Warrant) { w.Option = "delete" },
|
||||
"a lower level": func(w *seat.Warrant) { w.Level = Acknowledge },
|
||||
"another role": func(w *seat.Warrant) { w.By = &seat.Person{Who: "agent"} },
|
||||
"no digest": func(w *seat.Warrant) { w.AskDigest = "" },
|
||||
"no time given": func(w *seat.Warrant) { w.At = "" },
|
||||
"after expiry": func(w *seat.Warrant) { w.At = FormatTime(now.Add(2 * time.Hour)) },
|
||||
"another ask's digest": func(w *seat.Warrant) {
|
||||
other := held()
|
||||
other.Options[0].Binds = bind("release", "another")
|
||||
w.AskDigest = Digest(other)
|
||||
},
|
||||
} {
|
||||
c := w
|
||||
by := *w.By
|
||||
c.By = &by
|
||||
change(&c)
|
||||
if _, err := For(c, "an-asker", held()); err == nil {
|
||||
t.Errorf("a warrant with %s was acted on", why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A warrant answers one ask as it was shown, and authorises the act its option bound and no other
|
||||
// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments).
|
||||
func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) {
|
||||
a := held()
|
||||
// The digest survives the trip through the bus: what the router decodes and digests is what was sent.
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded seat.Ask
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Digest(decoded) != Digest(a) {
|
||||
t.Fatalf("the ask's digest changed on the way: %s, %s", Digest(decoded), Digest(a))
|
||||
}
|
||||
local := a
|
||||
local.Expires = inZone(a.Expires, time.FixedZone("CEST", 2*3600))
|
||||
if Digest(local) != Digest(a) {
|
||||
t.Error("the same instant in another zone digests differently")
|
||||
}
|
||||
// Changing anything the person was shown, or what an option binds, changes the digest.
|
||||
for what, change := range map[string]func(*seat.Ask){
|
||||
"the headline": func(a *seat.Ask) { a.Headline += "!" },
|
||||
"the explanation": func(a *seat.Ask) { a.Explanation += " " },
|
||||
"a label": func(a *seat.Ask) { a.Options[0].Label = "Go" },
|
||||
"what it binds": func(a *seat.Ask) { a.Options[0].Binds = bind("release", "another") },
|
||||
"the expiry": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Second) },
|
||||
"what it is about": func(a *seat.Ask) { a.About = "delivery.other" },
|
||||
} {
|
||||
c := held()
|
||||
change(&c)
|
||||
if Digest(c) == Digest(a) {
|
||||
t.Errorf("changing %s leaves the digest", what)
|
||||
}
|
||||
}
|
||||
o, _ := OptionOf(a, "release")
|
||||
if err := Performs(o, Act{"verb": "release", "delivery": "openrazer"}); err != nil {
|
||||
t.Errorf("the bound act was refused: %v", err)
|
||||
}
|
||||
if err := Performs(o, Act{"verb": "release", "delivery": "another"}); err == nil {
|
||||
t.Error("an act the option did not bind was let through")
|
||||
}
|
||||
ack, _ := OptionOf(a, "silence")
|
||||
if err := Performs(ack, Act{"anything": "x"}); err != nil {
|
||||
t.Errorf("an acknowledgement that bound nothing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's
|
||||
// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another
|
||||
// order changes nothing it should not — and every field that is shown or bound is in it.
|
||||
func TestDigestsAreCanonicalOverNamedFields(t *testing.T) {
|
||||
// The digests are those mesh-sdk's asks gave before the move to the contract's types (ADR 0627 §1): a router and
|
||||
// an asker of either build agree on every ask.
|
||||
gold := held()
|
||||
gold.Whole, gold.Details, gold.WholeDetails = "w", "d", "wd"
|
||||
if Digest(held()) != "sha256:088e7e69e1783b78c1b7e8310e0a6d0504a88a37982b293bc1e667da4565bb0f" ||
|
||||
Digest(gold) != "sha256:997af29a80663c5eb7d9dd38fc029c4a15fd726220b25e3c7cdc54d5a60bbbae" {
|
||||
t.Errorf("the digests changed with the move: %s, %s", Digest(held()), Digest(gold))
|
||||
}
|
||||
a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"})
|
||||
b := Act{}
|
||||
b["arg.delivery"] = "hq"
|
||||
b["verb"] = "release"
|
||||
a2, _ := ActDigest(b)
|
||||
if a1 != a2 {
|
||||
t.Error("the order an act was built in changed its digest")
|
||||
}
|
||||
// A value cannot be read as two: "a"+"b=c" is not "a=b"+"c".
|
||||
x, _ := ActDigest(Act{"a": "b\n1:c"})
|
||||
y, _ := ActDigest(Act{"a": "b", "c": ""})
|
||||
if x == y {
|
||||
t.Error("two different acts encode the same")
|
||||
}
|
||||
if _, err := ActDigest(Act{}); err == nil {
|
||||
t.Error("an empty act was digested")
|
||||
}
|
||||
// The encoding, by hand: any language reproduces it.
|
||||
sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n"))
|
||||
if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) {
|
||||
t.Errorf("the act's encoding is not the one written down: %s", got)
|
||||
}
|
||||
|
||||
// Every exported field of Ask and Option is in the digest: changing any one changes it.
|
||||
base := held()
|
||||
mutations := map[string]func(*seat.Ask){
|
||||
"ID": func(a *seat.Ask) { a.ID = "other" },
|
||||
"Headline": func(a *seat.Ask) { a.Headline += "!" },
|
||||
"Explanation": func(a *seat.Ask) { a.Explanation += "!" },
|
||||
"Who": func(a *seat.Ask) { a.Who = "someone" },
|
||||
"Expires": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Nanosecond) },
|
||||
"OnExpiry": func(a *seat.Ask) { a.OnExpiry += "!" },
|
||||
"About": func(a *seat.Ask) { a.About += "!" },
|
||||
"Urgent": func(a *seat.Ask) { a.Urgent = !a.Urgent },
|
||||
"Options": func(a *seat.Ask) { a.Options = a.Options[:1] },
|
||||
"Option.ID": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].ID = "x" },
|
||||
"Option.Label": func(a *seat.Ask) {
|
||||
a.Options = append([]seat.Option(nil), a.Options...)
|
||||
a.Options[0].Label += "!"
|
||||
},
|
||||
"Option.Does": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].Does += "!" },
|
||||
"Option.Level": func(a *seat.Ask) {
|
||||
a.Options = append([]seat.Option(nil), a.Options...)
|
||||
a.Options[0].Level = Destroy
|
||||
},
|
||||
"Option.Binds": func(a *seat.Ask) {
|
||||
a.Options = append([]seat.Option(nil), a.Options...)
|
||||
a.Options[0].Binds = "sha256:00"
|
||||
},
|
||||
}
|
||||
for name, mutate := range mutations {
|
||||
c := base
|
||||
mutate(&c)
|
||||
if Digest(c) == Digest(base) {
|
||||
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 := seat.Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: FormatTime(now), OnExpiry: "o", About: "a",
|
||||
Options: []seat.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 Digest(plain) != "sha256:"+hex.EncodeToString(byHand[:]) {
|
||||
t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", Digest(plain))
|
||||
}
|
||||
withWhole, withDetails := plain, plain
|
||||
withWhole.Whole = "e, whole"
|
||||
withDetails.Details = "fingerprint 1234"
|
||||
if Digest(withWhole) == Digest(plain) || Digest(withDetails) == Digest(plain) || Digest(withWhole) == Digest(withDetails) {
|
||||
t.Error("Whole or Details left the digest the same")
|
||||
}
|
||||
both := withWhole
|
||||
both.Details = "fingerprint 1234"
|
||||
changed := both
|
||||
changed.Whole += "!"
|
||||
if Digest(changed) == Digest(both) {
|
||||
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(seat.Ask{}).NumField(); n != 12 {
|
||||
t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n)
|
||||
}
|
||||
if n := reflect.TypeOf(seat.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)
|
||||
}
|
||||
// The same instant in another zone is the same ask.
|
||||
z := base
|
||||
z.Expires = inZone(z.Expires, time.FixedZone("x", 3600))
|
||||
if Digest(z) != Digest(base) {
|
||||
t.Error("the expiry's zone changed the digest")
|
||||
}
|
||||
}
|
||||
|
||||
// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could
|
||||
// show the answer was in time.
|
||||
func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) {
|
||||
a := held()
|
||||
a.Expires = ""
|
||||
w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
||||
Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: Digest(a),
|
||||
At: FormatTime(now.Add(time.Minute))}
|
||||
if _, err := For(w, "an-asker", a); err == nil {
|
||||
t.Error("a warrant for an ask with no expiry was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639).
|
||||
func TestAnExplanationReadsAtAGlance(t *testing.T) {
|
||||
if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil {
|
||||
t.Fatalf("a summary and two points: %v", err)
|
||||
}
|
||||
var wall strings.Builder
|
||||
wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.")
|
||||
for i := 1; i <= 13; i++ {
|
||||
wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3))
|
||||
}
|
||||
err := Glance(wall.String())
|
||||
if err == nil || !strings.Contains(err.Error(), "13 points") {
|
||||
t.Fatalf("thirteen clause lines pass as a glance: %v", err)
|
||||
}
|
||||
if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") {
|
||||
t.Fatalf("a long summary passes: %v", err)
|
||||
}
|
||||
if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") {
|
||||
t.Fatalf("a long point passes: %v", err)
|
||||
}
|
||||
if err := Glance(" \n"); err == nil {
|
||||
t.Fatal("an empty explanation passes")
|
||||
}
|
||||
}
|
||||
|
||||
// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker
|
||||
// of different builds still agree on every such ask.
|
||||
func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) {
|
||||
a := seat.Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"}
|
||||
before := Digest(a)
|
||||
a.WholeDetails = "+ shares: media=/storage/media"
|
||||
if Digest(a) == before {
|
||||
t.Fatal("the whole details are not in the digest")
|
||||
}
|
||||
b := a
|
||||
b.WholeDetails = "+ shares: media=/storage/other"
|
||||
if Digest(a) == Digest(b) {
|
||||
t.Fatal("two whole details digest the same")
|
||||
}
|
||||
a.WholeDetails = ""
|
||||
if Digest(a) != before {
|
||||
t.Fatal("an ask without whole details digests otherwise than before")
|
||||
}
|
||||
}
|
||||
|
||||
// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as
|
||||
// a Whole and Details that happen to spell the same values, nor as any other split of the three fields.
|
||||
func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) {
|
||||
base := seat.Ask{ID: "a", Headline: "h", Explanation: "e"}
|
||||
shapes := []seat.Ask{}
|
||||
for _, f := range []struct{ whole, details, wholeDetails string }{
|
||||
{"", "", "+ x: 1"},
|
||||
{"whole-details", "+ x: 1", ""},
|
||||
{"", "+ x: 1", ""},
|
||||
{"+ x: 1", "", ""},
|
||||
{"", "", "whole-details"},
|
||||
{"", "+ x: 1", "+ x: 1"},
|
||||
{"+ x: 1", "+ x: 1", ""},
|
||||
{"", "", ""},
|
||||
} {
|
||||
a := base
|
||||
a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails
|
||||
shapes = append(shapes, a)
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for i, a := range shapes {
|
||||
if j, ok := seen[Digest(a)]; ok {
|
||||
t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a)
|
||||
}
|
||||
seen[Digest(a)] = i
|
||||
}
|
||||
}
|
||||
|
||||
// shift is an RFC 3339 time of the contract moved by d.
|
||||
func shift(s string, d time.Duration) string {
|
||||
t, err := Time(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t.Add(d).Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// inZone is an RFC 3339 time of the contract written in another zone: the same instant.
|
||||
func inZone(s string, z *time.Location) string {
|
||||
t, err := Time(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t.In(z).Format(time.RFC3339Nano)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package operatorchannelclient
|
||||
|
||||
// Client is a module's asking of a person (novox/hq ADR 0259 §10): it publishes an ask under the module's own name,
|
||||
// cancels it, reads its state from the router's record, and hands the module each warrant once — only one for an ask
|
||||
// it made, for an option it offered, at that option's level, before it expired. The module then acts with its own
|
||||
// grants.
|
||||
//
|
||||
// It keeps what it asked, and which warrants it acted on, in a state of the module's own (declared in its manifest,
|
||||
// by default `asked`): the ask is written before it is published, and a warrant is marked acted on by compare-and-set
|
||||
// before the module's handler runs, so a redelivery, or a second instance, acts once.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Client asks as one module.
|
||||
type Client struct {
|
||||
// Module is the asking module's name: the one the bus grants its asks under.
|
||||
Module string
|
||||
// State is the module's own state the asks are kept in.
|
||||
State Store
|
||||
// Now is the clock; nil is time.Now.
|
||||
Now func() time.Time
|
||||
// Publish, Record and Subscribe reach the bus; nil is the stdio SDK's, through the module's runtime.
|
||||
Publish func(subject string, body any, id string) error
|
||||
Record func(key string) (*stdio.StateEntry, error)
|
||||
Subscribe func(pattern string, handle func(stdio.Envelope) error) error
|
||||
}
|
||||
|
||||
// Store is where the asks are kept: stdio.State(name) is one.
|
||||
type Store interface {
|
||||
Get(key string) (*stdio.StateEntry, error)
|
||||
Create(key string, value any) (uint64, error)
|
||||
Update(key string, value any, revision uint64) (uint64, error)
|
||||
}
|
||||
|
||||
// New is a client for the module the runtime serves, keeping its asks in the state named.
|
||||
func New(state string) *Client {
|
||||
return &Client{Module: os.Getenv("MESH_MODULE"), State: stdio.State(state)}
|
||||
}
|
||||
|
||||
func (c *Client) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Kept is one ask as the module keeps it.
|
||||
type Kept struct {
|
||||
Ask seat.Ask `json:"ask"`
|
||||
Asked time.Time `json:"asked"`
|
||||
Acted bool `json:"acted,omitempty"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Answer *seat.Warrant `json:"answer,omitempty"`
|
||||
}
|
||||
|
||||
func key(id string) string { return "ask." + id }
|
||||
|
||||
// Ask checks an ask, keeps it, and publishes it under the module's own name. An ask with an id already kept
|
||||
// is refused: a new question is a new id.
|
||||
func (c *Client) Ask(a seat.Ask) error {
|
||||
if err := Check(a, c.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.State.Create(key(a.ID), Kept{Ask: a, Asked: c.now()}); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return fmt.Errorf("an ask %s was already made: a new question is a new id", a.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return c.publish(seat.AcceptSubjectAsk(c.Module), a, a.ID)
|
||||
}
|
||||
|
||||
// Cancel takes an ask back: the router ends it and edits every copy.
|
||||
func (c *Client) Cancel(id string) error {
|
||||
return c.publish(seat.AcceptSubjectCancel(c.Module), map[string]string{"id": id}, "cancel."+id)
|
||||
}
|
||||
|
||||
func (c *Client) publish(subject string, body any, id string) error {
|
||||
if c.Publish != nil {
|
||||
return c.Publish(subject, body, id)
|
||||
}
|
||||
return stdio.SeatPublish(subject, body, id)
|
||||
}
|
||||
|
||||
// Where is an ask's state as the router keeps it: its state and its warrant, if any — read under the
|
||||
// module's own name and no other's.
|
||||
func (c *Client) Where(id string) (state string, w *seat.Warrant, err error) {
|
||||
read := c.Record
|
||||
if read == nil {
|
||||
read = func(k string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", k) }
|
||||
}
|
||||
e, err := read(id)
|
||||
if err != nil || e == nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var rec struct {
|
||||
State string `json:"state"`
|
||||
Warrant *seat.Warrant `json:"warrant"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
||||
return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err)
|
||||
}
|
||||
return rec.State, rec.Warrant, nil
|
||||
}
|
||||
|
||||
// Handled is what came of a warrant: acted on, refused (and why), or already done.
|
||||
type Handled string
|
||||
|
||||
const (
|
||||
Acted Handled = "acted"
|
||||
Refused Handled = "refused"
|
||||
Done Handled = "already acted on"
|
||||
Recorded Handled = "ended without a choice"
|
||||
)
|
||||
|
||||
// Take hands act a warrant once, after holding it to the ask it answers. act performs the option with the
|
||||
// module's own grants; its error is said and the warrant stays acted on (a person's decision is not
|
||||
// performed twice; the module says the failure in its own record).
|
||||
func (c *Client) Take(w seat.Warrant, act func(seat.Option, seat.Warrant) error) (Handled, error) {
|
||||
if w.Asker != c.Module {
|
||||
return Refused, fmt.Errorf("a warrant for %s reached %s", w.Asker, c.Module)
|
||||
}
|
||||
e, err := c.State.Get(key(w.Ask))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if e == nil {
|
||||
return Refused, fmt.Errorf("%s holds no ask %s", c.Module, w.Ask)
|
||||
}
|
||||
var k Kept
|
||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if k.Acted || k.Ended != "" {
|
||||
return Done, nil
|
||||
}
|
||||
if w.Outcome != OutcomeChosen {
|
||||
k.Ended = w.Outcome
|
||||
k.Answer = &w
|
||||
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil && !errors.Is(err, stdio.ErrChanged) {
|
||||
return "", err
|
||||
}
|
||||
return Recorded, nil
|
||||
}
|
||||
o, err := For(w, c.Module, k.Ask)
|
||||
if err != nil {
|
||||
return Refused, err
|
||||
}
|
||||
// Marked before acting: of two deliveries, or two instances, only the one whose write stands acts.
|
||||
k.Acted, k.Ended, k.Answer = true, OutcomeChosen, &w
|
||||
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return Done, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return Acted, act(o, w)
|
||||
}
|
||||
|
||||
// Listen hands every warrant for this module to Take, with act. It asks the runtime for the module's
|
||||
// events matching the router's `decided` subject for this module.
|
||||
func (c *Client) Listen(act func(seat.Option, seat.Warrant) error, said func(Handled, seat.Warrant, error)) error {
|
||||
subscribe := c.Subscribe
|
||||
if subscribe == nil {
|
||||
subscribe = stdio.Subscribe
|
||||
}
|
||||
return subscribe(seat.Seat+"."+seat.EventDecided+"."+c.Module, func(e stdio.Envelope) error {
|
||||
if !strings.HasPrefix(e.Key, seat.Seat+"."+seat.EventDecided+".") {
|
||||
return nil
|
||||
}
|
||||
var w seat.Warrant
|
||||
if err := json.Unmarshal(e.Body, &w); err != nil {
|
||||
if said != nil {
|
||||
said(Refused, w, fmt.Errorf("a warrant could not be read: %w", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
h, err := c.Take(w, act)
|
||||
if h == "" && err != nil {
|
||||
return err // the state could not be reached: offered again
|
||||
}
|
||||
if said != nil {
|
||||
said(h, w, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package operatorchannelclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
|
||||
type mem struct {
|
||||
vals map[string]json.RawMessage
|
||||
revs map[string]uint64
|
||||
n uint64
|
||||
}
|
||||
|
||||
func (m *mem) Get(k string) (*stdio.StateEntry, error) {
|
||||
v, ok := m.vals[k]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
|
||||
}
|
||||
func (m *mem) Create(k string, v any) (uint64, error) {
|
||||
if _, ok := m.vals[k]; ok {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return m.put(k, v)
|
||||
}
|
||||
func (m *mem) Update(k string, v any, rev uint64) (uint64, error) {
|
||||
if rev == 0 || m.revs[k] != rev {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return m.put(k, v)
|
||||
}
|
||||
func (m *mem) put(k string, v any) (uint64, error) {
|
||||
raw, _ := json.Marshal(v)
|
||||
m.n++
|
||||
m.vals[k], m.revs[k] = raw, m.n
|
||||
return m.n, nil
|
||||
}
|
||||
|
||||
func client() (*Client, map[string][]string) {
|
||||
sent := map[string][]string{}
|
||||
c := &Client{Module: "an-asker", State: &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}},
|
||||
Now: func() time.Time { return now },
|
||||
Publish: func(subject string, body any, id string) error {
|
||||
sent[subject] = append(sent[subject], id)
|
||||
return nil
|
||||
}}
|
||||
return c, sent
|
||||
}
|
||||
|
||||
// releaseHQ is the act the ask's one option binds.
|
||||
var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"}
|
||||
|
||||
func askedHeld() seat.Ask {
|
||||
binds, _ := ActDigest(releaseHQ)
|
||||
return seat.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.",
|
||||
Who: Operator, Expires: FormatTime(now.Add(time.Hour)), OnExpiry: "the delivery stays held",
|
||||
Options: []seat.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: Approve, Binds: binds}}}
|
||||
}
|
||||
|
||||
func askedWarrant() seat.Warrant {
|
||||
return seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release",
|
||||
Label: "Release", Level: Approve, At: FormatTime(now.Add(time.Minute)), AskDigest: Digest(askedHeld()),
|
||||
By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
}
|
||||
|
||||
func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) {
|
||||
var _ Store = process.State{} // a process connection's state keeps the asks (issue 1188 AC2)
|
||||
c, sent := client()
|
||||
if err := c.Ask(askedHeld()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sent[seat.AcceptSubjectAsk("an-asker")]; len(got) != 1 || got[0] != "d-1" {
|
||||
t.Fatalf("%v", sent)
|
||||
}
|
||||
if err := c.Ask(askedHeld()); err == nil || !strings.Contains(err.Error(), "already made") {
|
||||
t.Errorf("the same id was asked twice: %v", err)
|
||||
}
|
||||
bad := askedHeld()
|
||||
bad.Who = "agent"
|
||||
if err := c.Ask(bad); err == nil {
|
||||
t.Error("an ask the contract refuses was published")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) {
|
||||
c, _ := client()
|
||||
_ = c.Ask(askedHeld())
|
||||
acted := 0
|
||||
act := func(o seat.Option, _ seat.Warrant) error {
|
||||
if err := Performs(o, releaseHQ); err != nil {
|
||||
t.Errorf("the bound act: %v", err)
|
||||
}
|
||||
acted++
|
||||
return nil
|
||||
}
|
||||
if h, err := c.Take(askedWarrant(), act); h != Acted || err != nil || acted != 1 {
|
||||
t.Fatalf("%s %v %d", h, err, acted)
|
||||
}
|
||||
if h, _ := c.Take(askedWarrant(), act); h != Done || acted != 1 {
|
||||
t.Errorf("a redelivery was acted on: %s", h)
|
||||
}
|
||||
for why, change := range map[string]func(*seat.Warrant){
|
||||
"another asker": func(w *seat.Warrant) { w.Asker = "mesh-controller" },
|
||||
"an ask not made": func(w *seat.Warrant) { w.Ask = "d-9" },
|
||||
"another level": func(w *seat.Warrant) { w.Level = Acknowledge },
|
||||
"an option not offered": func(w *seat.Warrant) { w.Option = "delete" },
|
||||
"after its expiry": func(w *seat.Warrant) { w.At = FormatTime(now.Add(2 * time.Hour)) },
|
||||
"another ask's digest": func(w *seat.Warrant) { w.AskDigest = "sha256:00" },
|
||||
} {
|
||||
c2, _ := client()
|
||||
_ = c2.Ask(askedHeld())
|
||||
w := askedWarrant()
|
||||
change(&w)
|
||||
if h, _ := c2.Take(w, act); h == Acted {
|
||||
t.Errorf("a warrant from %s was acted on", why)
|
||||
}
|
||||
}
|
||||
if acted != 1 {
|
||||
t.Errorf("acted %d times", acted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskEndedWithoutAChoiceIsRecordedAndNothingDone(t *testing.T) {
|
||||
c, _ := client()
|
||||
_ = c.Ask(askedHeld())
|
||||
w := askedWarrant()
|
||||
w.Outcome, w.Option, w.By = OutcomeExpired, "", nil
|
||||
if h, err := c.Take(w, func(seat.Option, seat.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil {
|
||||
t.Fatalf("%s %v", h, err)
|
||||
}
|
||||
if h, _ := c.Take(askedWarrant(), func(seat.Option, seat.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done {
|
||||
t.Errorf("%s", h)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package operatorchannelclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// ConditionSubject is where a module says a condition to the operator: the seat's by-caller condition accept
|
||||
// (novox/hq ADR 0437 §9). The router reads the verb at the fifth part and the caller at the sixth, so the publisher is
|
||||
// one token, the module's own name; it is refused when empty or when it holds a dot, a wildcard or whitespace.
|
||||
func ConditionSubject(publisher string) (string, error) {
|
||||
if publisher == "" {
|
||||
return "", errors.New("operator-channel: the condition's publisher is empty")
|
||||
}
|
||||
if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) {
|
||||
return "", fmt.Errorf("operator-channel: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher)
|
||||
}
|
||||
return seat.AcceptSubjectCondition(publisher), nil
|
||||
}
|
||||
|
||||
// ConditionVersion is the version of the condition value this package writes and the only one it reads.
|
||||
const ConditionVersion = 1
|
||||
|
||||
// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not
|
||||
// ConditionVersion. Test it with errors.Is.
|
||||
var ErrUnknownConditionVersion = errors.New("operator-channel: unknown condition version")
|
||||
|
||||
// The words a condition's explanation opens with (novox/hq ADR 0253 §1).
|
||||
const (
|
||||
NothingToDo = "Nothing for you to do."
|
||||
NeedsYou = "Needs you: "
|
||||
)
|
||||
|
||||
// The severities and states of a condition.
|
||||
const (
|
||||
SeverityUrgent = "urgent"
|
||||
SeverityWarning = "warning"
|
||||
StateRaised = "raised"
|
||||
StateCleared = "cleared"
|
||||
)
|
||||
|
||||
func checkCondition(c seat.Condition) error {
|
||||
if c.Key == "" {
|
||||
return errors.New("operator-channel: a condition has no key")
|
||||
}
|
||||
if c.Subject.Scope == "" || c.Subject.ID == "" {
|
||||
return errors.New("operator-channel: a condition's subject needs its scope and id")
|
||||
}
|
||||
switch c.State {
|
||||
case StateCleared:
|
||||
return nil
|
||||
case StateRaised:
|
||||
default:
|
||||
return fmt.Errorf("operator-channel: the condition's state %q is not raised or cleared", c.State)
|
||||
}
|
||||
switch c.Severity {
|
||||
case SeverityUrgent, SeverityWarning:
|
||||
default:
|
||||
return fmt.Errorf("operator-channel: the condition's severity %q is not urgent or warning", c.Severity)
|
||||
}
|
||||
if c.Headline == "" {
|
||||
return errors.New("operator-channel: a raised condition has no headline")
|
||||
}
|
||||
if n := len([]rune(c.Headline)); n > HeadlineLength {
|
||||
return fmt.Errorf("operator-channel: the condition's headline is %d characters, more than %d", n, HeadlineLength)
|
||||
}
|
||||
verdict := NothingToDo
|
||||
if c.Needs != "" {
|
||||
verdict = NeedsYou + c.Needs
|
||||
}
|
||||
if !strings.HasPrefix(c.Explanation, verdict) {
|
||||
return fmt.Errorf("operator-channel: the condition's explanation must open with %q", verdict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse.
|
||||
func EncodeCondition(c seat.Condition) ([]byte, error) {
|
||||
if err := checkCondition(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.V = ConditionVersion
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what
|
||||
// EncodeCondition refuses.
|
||||
func DecodeCondition(data []byte) (seat.Condition, error) {
|
||||
var probe struct {
|
||||
V *int `json:"v"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return seat.Condition{}, fmt.Errorf("operator-channel: the condition is not JSON: %w", err)
|
||||
}
|
||||
if probe.V == nil {
|
||||
return seat.Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion)
|
||||
}
|
||||
if *probe.V != ConditionVersion {
|
||||
return seat.Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion)
|
||||
}
|
||||
var c seat.Condition
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
return seat.Condition{}, fmt.Errorf("operator-channel: the condition does not fit its shape: %w", err)
|
||||
}
|
||||
if err := checkCondition(c); err != nil {
|
||||
return seat.Condition{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SameCondition says whether two conditions are the same value.
|
||||
func SameCondition(a, b seat.Condition) bool { return reflect.DeepEqual(a, b) }
|
||||
@@ -1,145 +0,0 @@
|
||||
package operatorchannelclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
func TestConditionSubject(t *testing.T) { // 1202/AC1
|
||||
s, err := ConditionSubject("an-asker-teller")
|
||||
if err != nil || s != "mesh.seat.operator-channel.accept.condition.an-asker-teller" {
|
||||
t.Fatalf("subject = %q, %v", s, err)
|
||||
}
|
||||
// The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5.
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 6 || parts[4] != "condition" || parts[5] != "an-asker-teller" {
|
||||
t.Errorf("subject %q does not keep the router's shape: %v", s, parts)
|
||||
}
|
||||
for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} {
|
||||
if _, err := ConditionSubject(bad); err == nil {
|
||||
t.Errorf("publisher %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func raisedCondition() seat.Condition {
|
||||
return seat.Condition{
|
||||
Key: "delivery.stuck.mesh-mail", Severity: "warning",
|
||||
Subject: seat.ConditionSubject{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised",
|
||||
Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck",
|
||||
Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.",
|
||||
Resolved: "The delivery of mesh-mail moved on.",
|
||||
Actions: []seat.ConditionActionsItem{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace",
|
||||
Arguments: map[string]string{"why": ""}, Level: Approve}},
|
||||
}
|
||||
}
|
||||
|
||||
func clearedCondition() seat.Condition {
|
||||
return seat.Condition{Key: "delivery.stuck.mesh-mail", Subject: seat.ConditionSubject{Scope: "module", ID: "mesh-mail"}, State: "cleared"}
|
||||
}
|
||||
|
||||
func TestConditionRoundTrip(t *testing.T) { // 1202/AC2
|
||||
for _, c := range []seat.Condition{raisedCondition(), clearedCondition()} {
|
||||
data, err := EncodeCondition(c)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.State, err)
|
||||
}
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
|
||||
t.Fatalf("%s: %s does not carry v:1", c.State, data)
|
||||
}
|
||||
c.V = ConditionVersion
|
||||
got, err := DecodeCondition(data)
|
||||
if err != nil || !SameCondition(got, c) {
|
||||
t.Errorf("%s round trip: %+v %v", c.State, got, err)
|
||||
}
|
||||
}
|
||||
data, _ := EncodeCondition(raisedCondition())
|
||||
var probe map[string]any
|
||||
_ = json.Unmarshal(data, &probe)
|
||||
for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} {
|
||||
if _, ok := probe[name]; !ok {
|
||||
t.Errorf("the JSON has no %q: %s", name, data)
|
||||
}
|
||||
}
|
||||
subject := probe["subject"].(map[string]any)
|
||||
for _, name := range []string{"scope", "id", "machine"} {
|
||||
if _, ok := subject[name]; !ok {
|
||||
t.Errorf("the subject has no %q: %s", name, data)
|
||||
}
|
||||
}
|
||||
action := probe["actions"].([]any)[0].(map[string]any)
|
||||
for _, name := range []string{"label", "verb", "machine", "arguments", "level"} {
|
||||
if _, ok := action[name]; !ok {
|
||||
t.Errorf("the action has no %q: %s", name, data)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} {
|
||||
if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) {
|
||||
t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionVerdict(t *testing.T) { // 1202/AC3
|
||||
c := raisedCondition()
|
||||
c.Needs, c.Explanation = "", "The plan waits."
|
||||
_, err := EncodeCondition(c)
|
||||
if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") {
|
||||
t.Errorf("empty needs without its verdict: %v", err)
|
||||
}
|
||||
c.Explanation = "Nothing for you to do. The plan waits."
|
||||
if _, err := EncodeCondition(c); err != nil {
|
||||
t.Errorf("empty needs with its verdict refused: %v", err)
|
||||
}
|
||||
c = raisedCondition()
|
||||
c.Explanation = "Nothing for you to do. The plan waits."
|
||||
_, err = EncodeCondition(c)
|
||||
if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") {
|
||||
t.Errorf("needs set with the wrong verdict: %v", err)
|
||||
}
|
||||
c.Explanation = "Needs you: something else. The plan waits."
|
||||
if _, err = EncodeCondition(c); err == nil {
|
||||
t.Error("needs set with other needs in the verdict accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionRefused(t *testing.T) { // 1202/AC4
|
||||
mutate := func(f func(*seat.Condition)) error {
|
||||
c := raisedCondition()
|
||||
f(&c)
|
||||
_, err := EncodeCondition(c)
|
||||
return err
|
||||
}
|
||||
for name, f := range map[string]func(*seat.Condition){
|
||||
"severity": func(c *seat.Condition) { c.Severity = "critical" },
|
||||
"state": func(c *seat.Condition) { c.State = "open" },
|
||||
"key": func(c *seat.Condition) { c.Key = "" },
|
||||
"headline": func(c *seat.Condition) { c.Headline = "" },
|
||||
"long headline": func(c *seat.Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) },
|
||||
} {
|
||||
if err := mutate(f); err == nil {
|
||||
t.Errorf("a condition with a bad %s accepted", name)
|
||||
}
|
||||
}
|
||||
if err := mutate(func(c *seat.Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil {
|
||||
t.Errorf("a headline of the greatest length refused: %v", err)
|
||||
}
|
||||
if _, err := EncodeCondition(clearedCondition()); err != nil {
|
||||
t.Errorf("a cleared condition with only key, subject and state refused: %v", err)
|
||||
}
|
||||
c := clearedCondition()
|
||||
c.Key = ""
|
||||
if _, err := EncodeCondition(c); err == nil {
|
||||
t.Error("a cleared condition without its key accepted")
|
||||
}
|
||||
c = clearedCondition()
|
||||
c.State = "gone"
|
||||
if _, err := EncodeCondition(c); err == nil {
|
||||
t.Error("a condition with an unknown state accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
module git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a h1:E9ERPqR4uZXypudHGxv2/wNIOYcder9torVqJwJq/DI=
|
||||
git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a h1:UzJfgbyGlZJzsOFevu/c7stv1ccejrS4nAMfYytSd8s=
|
||||
git.novox.be/novox/mesh-contracts/go/seats/operatorchannel v0.0.0-20261104172218-347a3053580a/go.mod h1:osh0jQyNqYDM27tbxQo6HeUEXgLq9XUewKzNhUXadog=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef h1:/Jic5ho0xz32Z9DH2NSUqVxCF1KyfIOWuRTrX1aLjus=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174118-f94abb95aaef/go.mod h1:2wHG7ntLCG8Tpm/SCfgp524a5emUJm1gAC4KrRYsU9s=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
@@ -3,6 +3,13 @@
|
||||
// Package meshconfig is the contract of the seat "mesh-config", defined whole (seats/mesh-config/seat.json): The mesh's config values: read and set one, and the renames still open. mesh-config's own events stay under its own subjects (ADR 0620 §2).
|
||||
package meshconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Seat is the seat's name.
|
||||
const Seat = "mesh-config"
|
||||
|
||||
@@ -38,6 +45,53 @@ func HolderSubjectRetiring(holder string) string { return "mesh.stream." + holde
|
||||
// HolderSubjectRetired is where the holder publishes retired, kept in config.events.
|
||||
func HolderSubjectRetired(holder string) string { return "mesh.stream." + holder + ".event.retired" }
|
||||
|
||||
const (
|
||||
StateResolved = "resolved"
|
||||
)
|
||||
|
||||
// ResolvedByNodeModuleKeyKey is the key of one value of the state resolved under the form "<node>.<module>.<key>".
|
||||
func ResolvedByNodeModuleKeyKey(node, module, key string) string {
|
||||
return node + "." + module + "." + key
|
||||
}
|
||||
|
||||
// ParseResolvedByNodeModuleKeyKey reads the parts of a key of the form "<node>.<module>.<key>", in the order of the form; the last result is false for a key of another form.
|
||||
func ParseResolvedByNodeModuleKeyKey(key string) (string, string, string, bool) {
|
||||
rest := key
|
||||
v0, after0, found0 := strings.Cut(rest, ".")
|
||||
if !found0 || v0 == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
rest = after0
|
||||
v2, after2, found2 := strings.Cut(rest, ".")
|
||||
if !found2 || v2 == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
rest = after2
|
||||
if rest == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
v4 := rest
|
||||
rest = ""
|
||||
if rest != "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
return v0, v2, v4, true
|
||||
}
|
||||
|
||||
// DecodeResolvedByNodeModuleKeyValue reads one value of the state resolved under the form "<node>.<module>.<key>", refusing a field ResolvedByNodeModuleKeyValue does not know and any text after the value.
|
||||
func DecodeResolvedByNodeModuleKeyValue(raw []byte) (ResolvedByNodeModuleKeyValue, error) {
|
||||
var v ResolvedByNodeModuleKeyValue
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if dec.More() {
|
||||
return v, errors.New("text after the value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ConfigInput is what config takes.
|
||||
type ConfigInput struct {
|
||||
// list (when not given), set, clear or history
|
||||
@@ -129,3 +183,17 @@ type RetiredHolderEvent struct {
|
||||
// the node
|
||||
Node string `json:"node,omitempty"`
|
||||
}
|
||||
|
||||
// ResolvedByNodeModuleKeyValue is one value of the state resolved under the key <node>.<module>.<key>.
|
||||
type ResolvedByNodeModuleKeyValue struct {
|
||||
Key string `json:"key"`
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node"`
|
||||
// the predefined key the value falls back to
|
||||
Predefined string `json:"predefined,omitempty"`
|
||||
// an unset value is the operator's own to set
|
||||
Required bool `json:"required,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
Value any `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
// Package nodeengine is the contract of the seat "node-engine", defined whole (seats/node-engine/seat.json): The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.<verb>.<node>, and each event is published on mesh.seat.node-engine.event.<event>.<node>, the node's name its last token.
|
||||
package nodeengine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Seat is the seat's name.
|
||||
const Seat = "node-engine"
|
||||
|
||||
@@ -57,6 +64,222 @@ func SubjectManifestReadsChanged(caller string) string {
|
||||
return "mesh.seat.node-engine.event.manifest-reads-changed." + caller
|
||||
}
|
||||
|
||||
const (
|
||||
StateHave = "have"
|
||||
StateComposed = "composed"
|
||||
StateData = "data"
|
||||
StatePackages = "packages"
|
||||
StateHealth = "health"
|
||||
)
|
||||
|
||||
// HaveByNodeModuleKey is the key of one value of the state have under the form "<node>.<module>".
|
||||
func HaveByNodeModuleKey(node, module string) string { return node + "." + module }
|
||||
|
||||
// ParseHaveByNodeModuleKey reads the parts of a key of the form "<node>.<module>", in the order of the form; the last result is false for a key of another form.
|
||||
func ParseHaveByNodeModuleKey(key string) (string, string, bool) {
|
||||
rest := key
|
||||
v0, after0, found0 := strings.Cut(rest, ".")
|
||||
if !found0 || v0 == "" {
|
||||
return "", "", false
|
||||
}
|
||||
rest = after0
|
||||
if rest == "" {
|
||||
return "", "", false
|
||||
}
|
||||
v2 := rest
|
||||
rest = ""
|
||||
if rest != "" {
|
||||
return "", "", false
|
||||
}
|
||||
return v0, v2, true
|
||||
}
|
||||
|
||||
// DecodeHaveByNodeModuleValue reads one value of the state have under the form "<node>.<module>", refusing a field HaveByNodeModuleValue does not know and any text after the value.
|
||||
func DecodeHaveByNodeModuleValue(raw []byte) (HaveByNodeModuleValue, error) {
|
||||
var v HaveByNodeModuleValue
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if dec.More() {
|
||||
return v, errors.New("text after the value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// HaveByNodeNodeKey is the key of one value of the state have under the form "<node>.node".
|
||||
func HaveByNodeNodeKey(node string) string { return node + ".node" }
|
||||
|
||||
// ParseHaveByNodeNodeKey reads the parts of a key of the form "<node>.node", in the order of the form; the last result is false for a key of another form.
|
||||
func ParseHaveByNodeNodeKey(key string) (string, bool) {
|
||||
rest := key
|
||||
v0, after0, found0 := strings.Cut(rest, ".node")
|
||||
if !found0 || v0 == "" {
|
||||
return "", false
|
||||
}
|
||||
rest = after0
|
||||
if rest != "" {
|
||||
return "", false
|
||||
}
|
||||
return v0, true
|
||||
}
|
||||
|
||||
// DecodeHaveByNodeNodeValue reads one value of the state have under the form "<node>.node", refusing a field HaveByNodeNodeValue does not know and any text after the value.
|
||||
func DecodeHaveByNodeNodeValue(raw []byte) (HaveByNodeNodeValue, error) {
|
||||
var v HaveByNodeNodeValue
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if dec.More() {
|
||||
return v, errors.New("text after the value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ComposedByNodeModuleFingerprintKey is the key of one value of the state composed under the form "<node>.<module>.<fingerprint>".
|
||||
func ComposedByNodeModuleFingerprintKey(node, module, fingerprint string) string {
|
||||
return node + "." + module + "." + fingerprint
|
||||
}
|
||||
|
||||
// ParseComposedByNodeModuleFingerprintKey reads the parts of a key of the form "<node>.<module>.<fingerprint>", in the order of the form; the last result is false for a key of another form.
|
||||
func ParseComposedByNodeModuleFingerprintKey(key string) (string, string, string, bool) {
|
||||
rest := key
|
||||
v0, after0, found0 := strings.Cut(rest, ".")
|
||||
if !found0 || v0 == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
rest = after0
|
||||
v2, after2, found2 := strings.Cut(rest, ".")
|
||||
if !found2 || v2 == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
rest = after2
|
||||
if rest == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
v4 := rest
|
||||
rest = ""
|
||||
if rest != "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
return v0, v2, v4, true
|
||||
}
|
||||
|
||||
// DecodeComposedByNodeModuleFingerprintValue reads one value of the state composed under the form "<node>.<module>.<fingerprint>", refusing a field ComposedByNodeModuleFingerprintValue does not know and any text after the value.
|
||||
func DecodeComposedByNodeModuleFingerprintValue(raw []byte) (ComposedByNodeModuleFingerprintValue, error) {
|
||||
var v ComposedByNodeModuleFingerprintValue
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if dec.More() {
|
||||
return v, errors.New("text after the value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// DataByNodeModuleKey is the key of one value of the state data under the form "<node>.<module>".
|
||||
func DataByNodeModuleKey(node, module string) string { return node + "." + module }
|
||||
|
||||
// ParseDataByNodeModuleKey reads the parts of a key of the form "<node>.<module>", in the order of the form; the last result is false for a key of another form.
|
||||
func ParseDataByNodeModuleKey(key string) (string, string, bool) {
|
||||
rest := key
|
||||
v0, after0, found0 := strings.Cut(rest, ".")
|
||||
if !found0 || v0 == "" {
|
||||
return "", "", false
|
||||
}
|
||||
rest = after0
|
||||
if rest == "" {
|
||||
return "", "", false
|
||||
}
|
||||
v2 := rest
|
||||
rest = ""
|
||||
if rest != "" {
|
||||
return "", "", false
|
||||
}
|
||||
return v0, v2, true
|
||||
}
|
||||
|
||||
// DecodeDataByNodeModuleValue reads one value of the state data under the form "<node>.<module>", refusing a field DataByNodeModuleValue does not know and any text after the value.
|
||||
func DecodeDataByNodeModuleValue(raw []byte) (DataByNodeModuleValue, error) {
|
||||
var v DataByNodeModuleValue
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if dec.More() {
|
||||
return v, errors.New("text after the value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// PackagesByNodeKey is the key of one value of the state packages under the form "<node>".
|
||||
func PackagesByNodeKey(node string) string { return node }
|
||||
|
||||
// ParsePackagesByNodeKey reads the parts of a key of the form "<node>", in the order of the form; the last result is false for a key of another form.
|
||||
func ParsePackagesByNodeKey(key string) (string, bool) {
|
||||
rest := key
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
v0 := rest
|
||||
rest = ""
|
||||
if rest != "" {
|
||||
return "", false
|
||||
}
|
||||
return v0, true
|
||||
}
|
||||
|
||||
// DecodePackagesByNodeValue reads one value of the state packages under the form "<node>", refusing a field PackagesByNodeValue does not know and any text after the value.
|
||||
func DecodePackagesByNodeValue(raw []byte) (PackagesByNodeValue, error) {
|
||||
var v PackagesByNodeValue
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if dec.More() {
|
||||
return v, errors.New("text after the value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// HealthByNodeKey is the key of one value of the state health under the form "<node>".
|
||||
func HealthByNodeKey(node string) string { return node }
|
||||
|
||||
// ParseHealthByNodeKey reads the parts of a key of the form "<node>", in the order of the form; the last result is false for a key of another form.
|
||||
func ParseHealthByNodeKey(key string) (string, bool) {
|
||||
rest := key
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
v0 := rest
|
||||
rest = ""
|
||||
if rest != "" {
|
||||
return "", false
|
||||
}
|
||||
return v0, true
|
||||
}
|
||||
|
||||
// DecodeHealthByNodeValue reads one value of the state health under the form "<node>", refusing a field HealthByNodeValue does not know and any text after the value.
|
||||
func DecodeHealthByNodeValue(raw []byte) (HealthByNodeValue, error) {
|
||||
var v HealthByNodeValue
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if dec.More() {
|
||||
return v, errors.New("text after the value")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Act is one act asked through the seat, as acts reads it (ADR 0563 §9).
|
||||
type Act struct {
|
||||
// the act: hand-over, access-act, judge-directories or setuid-search
|
||||
@@ -561,3 +784,104 @@ type RefusedEvent = ActSaid
|
||||
|
||||
// ManifestReadsChangedEvent is the body of the event manifest-reads-changed.
|
||||
type ManifestReadsChangedEvent = ManifestReadsChanged
|
||||
|
||||
// HaveByNodeModuleValue is one value of the state have under the key <node>.<module>.
|
||||
type HaveByNodeModuleValue struct {
|
||||
Build string `json:"build,omitempty"`
|
||||
BuildAlone bool `json:"build-alone,omitempty"`
|
||||
// the composition's digest
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// the fingerprint held
|
||||
Fp string `json:"fp,omitempty"`
|
||||
// the accounts handed
|
||||
Handed map[string]any `json:"handed,omitempty"`
|
||||
// how it is
|
||||
Health string `json:"health"`
|
||||
Judgement string `json:"judgement,omitempty"`
|
||||
Kept []string `json:"kept,omitempty"`
|
||||
// how far it has come
|
||||
Phase string `json:"phase"`
|
||||
// a pull the engine waits for
|
||||
PullWait map[string]any `json:"pull-wait,omitempty"`
|
||||
Sealed []string `json:"sealed,omitempty"`
|
||||
SealedAt map[string]any `json:"sealed-at,omitempty"`
|
||||
// the user list written
|
||||
UserList map[string]any `json:"user-list,omitempty"`
|
||||
V int64 `json:"v"`
|
||||
// what it waits for
|
||||
Waits string `json:"waits,omitempty"`
|
||||
}
|
||||
|
||||
// HaveByNodeNodeValue is one value of the state have under the key <node>.node.
|
||||
type HaveByNodeNodeValue struct {
|
||||
Busy map[string]any `json:"busy,omitempty"`
|
||||
Candidates map[string]map[string]any `json:"candidates,omitempty"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
// own, or the controller's
|
||||
Composes string `json:"composes,omitempty"`
|
||||
ComposesWhy string `json:"composes-why,omitempty"`
|
||||
Filters map[string]any `json:"filters,omitempty"`
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
// the node's record: its tunnel and certificate
|
||||
Record map[string]any `json:"record,omitempty"`
|
||||
Sealed []string `json:"sealed,omitempty"`
|
||||
SealedAt map[string]any `json:"sealed-at,omitempty"`
|
||||
Strays map[string]any `json:"strays,omitempty"`
|
||||
V int64 `json:"v"`
|
||||
Windows map[string]any `json:"windows,omitempty"`
|
||||
}
|
||||
|
||||
// ComposedByNodeModuleFingerprintValue is one value of the state composed under the key <node>.<module>.<fingerprint>.
|
||||
type ComposedByNodeModuleFingerprintValue struct {
|
||||
Data bool `json:"Data,omitempty"`
|
||||
FP string `json:"FP"`
|
||||
Filled []string `json:"Filled,omitempty"`
|
||||
Module string `json:"Module"`
|
||||
Node string `json:"Node"`
|
||||
// the node's own adoption, identities, groups, libraries and provisioner account
|
||||
Own map[string]any `json:"Own,omitempty"`
|
||||
// the module's plan
|
||||
Plan map[string]any `json:"Plan,omitempty"`
|
||||
// why the composition was refused
|
||||
Refused string `json:"Refused,omitempty"`
|
||||
Resources []map[string]any `json:"Resources,omitempty"`
|
||||
V int64 `json:"V,omitempty"`
|
||||
}
|
||||
|
||||
// DataByNodeModuleValue is one value of the state data under the key <node>.<module>.
|
||||
type DataByNodeModuleValue struct {
|
||||
Digest string `json:"digest"`
|
||||
Items []map[string]any `json:"items"`
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node"`
|
||||
V int64 `json:"v,omitempty"`
|
||||
}
|
||||
|
||||
// PackagesByNodeValuePackagesItem is a type of the contract.
|
||||
type PackagesByNodeValuePackagesItem struct {
|
||||
Absent bool `json:"absent,omitempty"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
}
|
||||
|
||||
// PackagesByNodeValue is one value of the state packages under the key <node>.
|
||||
type PackagesByNodeValue struct {
|
||||
Node string `json:"node"`
|
||||
Packages []PackagesByNodeValuePackagesItem `json:"packages"`
|
||||
}
|
||||
|
||||
// HealthByNodeValue is one value of the state health under the key <node>.
|
||||
type HealthByNodeValue struct {
|
||||
At string `json:"at"`
|
||||
Contract int64 `json:"contract,omitempty"`
|
||||
Fallback map[string]any `json:"fallback,omitempty"`
|
||||
Found []map[string]any `json:"found,omitempty"`
|
||||
Network map[string]any `json:"network,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Peers []map[string]any `json:"peers,omitempty"`
|
||||
Relogin []map[string]any `json:"relogin,omitempty"`
|
||||
Root []map[string]any `json:"root,omitempty"`
|
||||
Units map[string]any `json:"units,omitempty"`
|
||||
V int64 `json:"v"`
|
||||
}
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
|
||||
|
||||
// Package operatorchannel is the contract of the seat "operator-channel", defined whole (seats/operator-channel/seat.json): Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).
|
||||
package operatorchannel
|
||||
|
||||
// Seat is the seat's name.
|
||||
const Seat = "operator-channel"
|
||||
|
||||
const (
|
||||
VerbOpen = "open"
|
||||
VerbHistory = "history"
|
||||
VerbNotify = "notify"
|
||||
VerbTell = "tell"
|
||||
VerbAsks = "asks"
|
||||
)
|
||||
|
||||
const (
|
||||
AcceptAsk = "ask"
|
||||
AcceptCancel = "cancel"
|
||||
AcceptCondition = "condition"
|
||||
)
|
||||
|
||||
const (
|
||||
EventDecided = "decided"
|
||||
)
|
||||
|
||||
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
|
||||
func Verbs() []string {
|
||||
return []string{VerbOpen, VerbHistory, VerbNotify, VerbTell, VerbAsks}
|
||||
}
|
||||
|
||||
// Events is every event published on the seat's subject: a new copy each call.
|
||||
func Events() []string {
|
||||
return []string{EventDecided}
|
||||
}
|
||||
|
||||
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
|
||||
const (
|
||||
Family = "operator-channel.decided"
|
||||
FamilyMaxAgeDays = 7
|
||||
)
|
||||
|
||||
// SubjectDecided is where decided is published for one caller.
|
||||
func SubjectDecided(caller string) string {
|
||||
return "mesh.seat.operator-channel.event.decided." + caller
|
||||
}
|
||||
|
||||
// AcceptSubjectAsk is where one caller sends ask.
|
||||
func AcceptSubjectAsk(caller string) string { return "mesh.seat.operator-channel.accept.ask." + caller }
|
||||
|
||||
// AcceptSubjectCancel is where one caller sends cancel.
|
||||
func AcceptSubjectCancel(caller string) string {
|
||||
return "mesh.seat.operator-channel.accept.cancel." + caller
|
||||
}
|
||||
|
||||
// AcceptSubjectCondition is where one caller sends condition.
|
||||
func AcceptSubjectCondition(caller string) string {
|
||||
return "mesh.seat.operator-channel.accept.condition." + caller
|
||||
}
|
||||
|
||||
// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2).
|
||||
const (
|
||||
HolderEventRefused = "refused"
|
||||
HolderEventOffered = "offered"
|
||||
HolderEventClosed = "closed"
|
||||
)
|
||||
|
||||
// HolderSubjectRefused is where the holder publishes refused.
|
||||
func HolderSubjectRefused(holder string) string { return "mesh.mod." + holder + ".event.refused" }
|
||||
|
||||
// HolderSubjectOffered is where the holder publishes offered, kept in router.asks.
|
||||
func HolderSubjectOffered(holder string) string { return "mesh.stream." + holder + ".event.offered" }
|
||||
|
||||
// HolderSubjectClosed is where the holder publishes closed, kept in router.asks.
|
||||
func HolderSubjectClosed(holder string) string { return "mesh.stream." + holder + ".event.closed" }
|
||||
|
||||
const (
|
||||
RecordAsks = "asks"
|
||||
)
|
||||
|
||||
// Ask is a type of the contract.
|
||||
type Ask struct {
|
||||
// what it is about: a newer ask about it replaces the older
|
||||
About string `json:"about,omitempty"`
|
||||
// its secondary words
|
||||
Details string `json:"details,omitempty"`
|
||||
// when it ends unanswered, RFC 3339
|
||||
Expires string `json:"expires"`
|
||||
// what happened and what it means
|
||||
Explanation string `json:"explanation"`
|
||||
// a few plain words
|
||||
Headline string `json:"headline"`
|
||||
// the asker's own id
|
||||
ID string `json:"id"`
|
||||
// what the asker then does
|
||||
OnExpiry string `json:"on-expiry"`
|
||||
// the answers it offers
|
||||
Options []Option `json:"options"`
|
||||
// urgent
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
// who may answer: operator
|
||||
Who string `json:"who"`
|
||||
// the explanation with its values whole
|
||||
Whole string `json:"whole,omitempty"`
|
||||
// the details whole
|
||||
WholeDetails string `json:"whole-details,omitempty"`
|
||||
}
|
||||
|
||||
// ConditionActionsItem is a type of the contract.
|
||||
type ConditionActionsItem struct {
|
||||
Arguments map[string]string `json:"arguments,omitempty"`
|
||||
// label
|
||||
Label string `json:"label"`
|
||||
// level
|
||||
Level string `json:"level,omitempty"`
|
||||
// machine
|
||||
Machine string `json:"machine,omitempty"`
|
||||
// verb
|
||||
Verb string `json:"verb"`
|
||||
}
|
||||
|
||||
// ConditionSubject is a type of the contract.
|
||||
type ConditionSubject struct {
|
||||
// id
|
||||
ID string `json:"id"`
|
||||
// machine
|
||||
Machine string `json:"machine,omitempty"`
|
||||
// scope
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// Condition is a type of the contract.
|
||||
type Condition struct {
|
||||
// the answers it offers
|
||||
Actions []ConditionActionsItem `json:"actions,omitempty"`
|
||||
// plain words
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
// plain words
|
||||
Headline string `json:"headline,omitempty"`
|
||||
// the condition's key
|
||||
Key string `json:"key"`
|
||||
// what it needs
|
||||
Needs string `json:"needs,omitempty"`
|
||||
// the line said when it is over
|
||||
Resolved string `json:"resolved,omitempty"`
|
||||
// urgent or warning
|
||||
Severity string `json:"severity,omitempty"`
|
||||
// raised or cleared
|
||||
State string `json:"state"`
|
||||
Subject ConditionSubject `json:"subject"`
|
||||
// one line
|
||||
Summary string `json:"summary,omitempty"`
|
||||
// the form's version, 1
|
||||
V int64 `json:"v"`
|
||||
}
|
||||
|
||||
// Option is a type of the contract.
|
||||
type Option struct {
|
||||
// the digest of exactly what the asker performs when it is chosen
|
||||
Binds string `json:"binds,omitempty"`
|
||||
// what it does, in plain words
|
||||
Does string `json:"does"`
|
||||
// the option's id
|
||||
ID string `json:"id"`
|
||||
// the button's label
|
||||
Label string `json:"label"`
|
||||
// the proof its answer needs
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
// Person is a type of the contract.
|
||||
type Person struct {
|
||||
// the name shown
|
||||
Display string `json:"display,omitempty"`
|
||||
// the account
|
||||
Identity string `json:"identity"`
|
||||
// the channel kind
|
||||
Kind string `json:"kind"`
|
||||
// how the router knew
|
||||
Verified string `json:"verified"`
|
||||
// the role
|
||||
Who string `json:"who"`
|
||||
}
|
||||
|
||||
// Warrant is a type of the contract.
|
||||
type Warrant struct {
|
||||
// what it was about
|
||||
About string `json:"about,omitempty"`
|
||||
// the ask
|
||||
Ask string `json:"ask"`
|
||||
// the digest of the ask as the router took it
|
||||
AskDigest string `json:"ask-digest,omitempty"`
|
||||
// its asker
|
||||
Asker string `json:"asker"`
|
||||
// when, RFC 3339
|
||||
At string `json:"at"`
|
||||
By *Person `json:"by,omitempty"`
|
||||
// the module it came through
|
||||
Channel string `json:"channel,omitempty"`
|
||||
// its label
|
||||
Label string `json:"label,omitempty"`
|
||||
// its level
|
||||
Level string `json:"level,omitempty"`
|
||||
// the option chosen
|
||||
Option string `json:"option,omitempty"`
|
||||
Outcome string `json:"outcome"`
|
||||
// the proofs present
|
||||
Proofs []string `json:"proofs,omitempty"`
|
||||
// why it ended without a choice, or what refused it
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
// The values of Warrant's outcome.
|
||||
const (
|
||||
WarrantOutcomeChosen = "chosen"
|
||||
WarrantOutcomeExpired = "expired"
|
||||
WarrantOutcomeCancelled = "cancelled"
|
||||
WarrantOutcomeReplaced = "replaced"
|
||||
WarrantOutcomeRefused = "refused"
|
||||
)
|
||||
|
||||
// OpenInput is what open takes.
|
||||
type OpenInput struct {
|
||||
}
|
||||
|
||||
// OpenAnswer is what open answers.
|
||||
type OpenAnswer = map[string]any
|
||||
|
||||
// HistoryInput is what history takes.
|
||||
type HistoryInput struct {
|
||||
// at most this many of each (default 50)
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// HistoryAnswer is what history answers.
|
||||
type HistoryAnswer = map[string]any
|
||||
|
||||
// NotifyInput is what notify takes.
|
||||
type NotifyInput struct {
|
||||
// it is over
|
||||
Clear bool `json:"clear,omitempty"`
|
||||
// one or two plain sentences
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
// plain words
|
||||
Headline string `json:"headline,omitempty"`
|
||||
// the message's key
|
||||
Key string `json:"key,omitempty"`
|
||||
// the line said when it is over
|
||||
Resolved string `json:"resolved,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
// what it is about
|
||||
Subject string `json:"subject,omitempty"`
|
||||
// one line
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// The values of NotifyInput's severity.
|
||||
const (
|
||||
NotifyInputSeverityUrgent = "urgent"
|
||||
NotifyInputSeverityWarning = "warning"
|
||||
)
|
||||
|
||||
// NotifyAnswer is what notify answers.
|
||||
type NotifyAnswer = map[string]any
|
||||
|
||||
// TellInput is what tell takes.
|
||||
type TellInput struct {
|
||||
// plain words
|
||||
Headline string `json:"headline,omitempty"`
|
||||
// one or two plain lines
|
||||
Lines []string `json:"lines,omitempty"`
|
||||
// what it is about
|
||||
Subject string `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// TellAnswer is what tell answers.
|
||||
type TellAnswer = map[string]any
|
||||
|
||||
// AsksInput is what asks takes.
|
||||
type AsksInput struct {
|
||||
}
|
||||
|
||||
// AsksAnswer is what asks answers.
|
||||
type AsksAnswer = map[string]any
|
||||
|
||||
// AskAccept is the body of ask.
|
||||
type AskAccept = Ask
|
||||
|
||||
// CancelAccept is the body of cancel.
|
||||
type CancelAccept struct {
|
||||
// the ask's id
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// ConditionAccept is the body of condition.
|
||||
type ConditionAccept = Condition
|
||||
|
||||
// DecidedEvent is the body of the event decided.
|
||||
type DecidedEvent = Warrant
|
||||
|
||||
// OfferedHolderEvent is the body of the holder's event offered.
|
||||
type OfferedHolderEvent struct {
|
||||
// its asker
|
||||
Asker string `json:"asker"`
|
||||
// the channels it was offered on
|
||||
Channels []string `json:"channels,omitempty"`
|
||||
// its headline
|
||||
Headline string `json:"headline,omitempty"`
|
||||
// the ask's id
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// ClosedHolderEvent is the body of the holder's event closed.
|
||||
type ClosedHolderEvent struct {
|
||||
// its asker
|
||||
Asker string `json:"asker"`
|
||||
How string `json:"how"`
|
||||
// the ask's id
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// The values of ClosedHolderEvent's how.
|
||||
const (
|
||||
ClosedHolderEventHowAnswered = "answered"
|
||||
ClosedHolderEventHowExpired = "expired"
|
||||
ClosedHolderEventHowCancelled = "cancelled"
|
||||
ClosedHolderEventHowReplaced = "replaced"
|
||||
ClosedHolderEventHowUnlinked = "unlinked"
|
||||
)
|
||||
|
||||
// AsksRecord is one value of the record asks.
|
||||
type AsksRecord = map[string]any
|
||||
@@ -1,60 +0,0 @@
|
||||
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
|
||||
|
||||
package operatorchannel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFixtures reads every fixture of seats/operator-channel/fixtures: each decodes into its type, a field the type does not
|
||||
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
|
||||
func TestFixtures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, subject, given, wire string
|
||||
decode func([]byte) (any, error)
|
||||
subjectIs *regexp.Regexp
|
||||
}{
|
||||
{name: "decided for its asker", subject: "mesh.seat.operator-channel.event.decided.mesh-delivery", given: "{\"ask\":\"a1\",\"ask-digest\":\"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"asker\":\"mesh-delivery\",\"at\":\"2026-11-04T12:00:00Z\",\"by\":{\"identity\":\"1\",\"kind\":\"telegram\",\"verified\":\"user id\",\"who\":\"operator\"},\"channel\":\"telegram\",\"label\":\"Release\",\"level\":\"approve\",\"option\":\"ok\",\"outcome\":\"chosen\"}", wire: "{\"ask\":\"a1\",\"ask-digest\":\"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"asker\":\"mesh-delivery\",\"at\":\"2026-11-04T12:00:00Z\",\"by\":{\"identity\":\"1\",\"kind\":\"telegram\",\"verified\":\"user id\",\"who\":\"operator\"},\"channel\":\"telegram\",\"label\":\"Release\",\"level\":\"approve\",\"option\":\"ok\",\"outcome\":\"chosen\"}",
|
||||
decode: func(b []byte) (any, error) { var v DecidedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.event\\.decided\\.[A-Za-z0-9_-]+$")},
|
||||
{name: "refused under the holder's own subjects", subject: "mesh.mod.messenger.event.refused", given: "{}", wire: "{}",
|
||||
decode: func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.mod\\.[a-z0-9-]+\\.event\\.refused$")},
|
||||
{name: "offered on the router's restricted stream", subject: "mesh.stream.messenger.event.offered", given: "{\"asker\":\"mesh-delivery\",\"channels\":[\"telegram\"],\"headline\":\"release held\",\"id\":\"a1\"}", wire: "{\"asker\":\"mesh-delivery\",\"channels\":[\"telegram\"],\"headline\":\"release held\",\"id\":\"a1\"}",
|
||||
decode: func(b []byte) (any, error) { var v OfferedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.offered$")},
|
||||
{name: "closed once per end", subject: "mesh.stream.messenger.event.closed", given: "{\"asker\":\"mesh-delivery\",\"how\":\"answered\",\"id\":\"a1\"}", wire: "{\"asker\":\"mesh-delivery\",\"how\":\"answered\",\"id\":\"a1\"}",
|
||||
decode: func(b []byte) (any, error) { var v ClosedHolderEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.stream\\.[a-z0-9-]+\\.event\\.closed$")},
|
||||
{name: "an ask by its asker", subject: "mesh.seat.operator-channel.accept.ask.mesh-delivery", given: "{\"expires\":\"2026-11-05T12:00:00Z\",\"explanation\":\"A delivery waits on your word.\",\"headline\":\"release held\",\"id\":\"a1\",\"on-expiry\":\"the delivery stays held\",\"options\":[{\"binds\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"does\":\"releases it\",\"id\":\"ok\",\"label\":\"Release\",\"level\":\"approve\"}],\"who\":\"operator\"}", wire: "{\"expires\":\"2026-11-05T12:00:00Z\",\"explanation\":\"A delivery waits on your word.\",\"headline\":\"release held\",\"id\":\"a1\",\"on-expiry\":\"the delivery stays held\",\"options\":[{\"binds\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"does\":\"releases it\",\"id\":\"ok\",\"label\":\"Release\",\"level\":\"approve\"}],\"who\":\"operator\"}",
|
||||
decode: func(b []byte) (any, error) { var v AskAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.accept\\.ask\\.[A-Za-z0-9_-]+$")},
|
||||
{name: "a cancel by its asker", subject: "mesh.seat.operator-channel.accept.cancel.mesh-delivery", given: "{\"id\":\"a1\"}", wire: "{\"id\":\"a1\"}",
|
||||
decode: func(b []byte) (any, error) { var v CancelAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.accept\\.cancel\\.[A-Za-z0-9_-]+$")},
|
||||
{name: "a condition by its publisher", subject: "mesh.seat.operator-channel.accept.condition.mesh-watcher", given: "{\"key\":\"backup.ace.failed\",\"severity\":\"warning\",\"state\":\"raised\",\"subject\":{\"id\":\"ace\",\"scope\":\"node\"},\"v\":1}", wire: "{\"key\":\"backup.ace.failed\",\"severity\":\"warning\",\"state\":\"raised\",\"subject\":{\"id\":\"ace\",\"scope\":\"node\"},\"v\":1}",
|
||||
decode: func(b []byte) (any, error) { var v ConditionAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.operator-channel\\.accept\\.condition\\.[A-Za-z0-9_-]+$")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
v, err := c.decode([]byte(c.given))
|
||||
if err != nil {
|
||||
t.Errorf("%s: does not decode: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
back, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Errorf("%s: does not encode: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
var got, want any
|
||||
if err := json.Unmarshal(back, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
|
||||
}
|
||||
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
|
||||
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module git.novox.be/novox/mesh-contracts/go/seats/operatorchannel
|
||||
|
||||
go 1.26.0
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user