510 lines
20 KiB
Go
510 lines
20 KiB
Go
// Package asks is the contract of asking a person and answering on a channel (novox/hq ADR 0259): the
|
|
// shapes an asker, the router and a channel exchange on the bus, and the subjects they travel on. No
|
|
// transport and no channel's service: an asker publishes an Ask under its own name and acts on the Warrant
|
|
// it hears; the router holds the ask, sends channels a Message, and judges the Choice a channel says; a
|
|
// channel shows a Message and says what was chosen and by whom, as its service authenticated it.
|
|
package asks
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The seats (novox/hq ADR 0259 §3).
|
|
const (
|
|
// Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by
|
|
// the asker.
|
|
Seat = "operator-channel"
|
|
// ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind.
|
|
ChannelSeat = "channel"
|
|
// IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry
|
|
// the kind.
|
|
IntakeSeat = "intake"
|
|
)
|
|
|
|
// AskSubject is where an asker publishes an ask, CancelSubject its cancel, and DecidedSubject where it
|
|
// hears the warrant, or the ask's end without one.
|
|
func AskSubject(asker string) string { return "mesh.seat." + Seat + ".accept.ask." + asker }
|
|
func CancelSubject(asker string) string { return "mesh.seat." + Seat + ".accept.cancel." + asker }
|
|
func DecidedSubject(asker string) string { return "mesh.seat." + Seat + ".event.decided." + asker }
|
|
|
|
// The work a channel takes, on ChannelSubject.
|
|
const (
|
|
Show = "show" // a message offering answers
|
|
Edit = "edit" // a message shown before, replaced
|
|
Send = "send" // a message offering nothing
|
|
)
|
|
|
|
// ChannelSubject is where the router sends a channel of a kind its work.
|
|
func ChannelSubject(verb, kind string) string {
|
|
return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind
|
|
}
|
|
|
|
// What a channel says, on IntakeSubject.
|
|
const (
|
|
Chosen = "choice" // a button tapped
|
|
Link = "link" // somebody asked to be linked as the operator
|
|
)
|
|
|
|
// IntakeSubject is where a channel of a kind says what arrived.
|
|
func IntakeSubject(what, kind string) string {
|
|
return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind
|
|
}
|
|
|
|
// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept.
|
|
const CodeProof = "code"
|
|
|
|
// ProofSubject is where a channel of a kind asks a proof.
|
|
func ProofSubject(verb, kind string) string {
|
|
return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind
|
|
}
|
|
|
|
// A Level is how much proof an option's answer needs (novox/hq ADR 0234 §8, the glossary's assurance level).
|
|
type Level string
|
|
|
|
const (
|
|
// Acknowledge performs only what any granted principal may already do: silencing, details. No proof.
|
|
Acknowledge Level = "acknowledge"
|
|
// Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code.
|
|
Approve Level = "approve"
|
|
// Destroy needs two proofs, one of them a code.
|
|
Destroy Level = "destroy"
|
|
)
|
|
|
|
// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less.
|
|
func (l Level) Rank() int {
|
|
switch l {
|
|
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"
|
|
|
|
// Option is one answer an ask offers: a label for the button, what it does in plain words, its level.
|
|
type Option struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Does string `json:"does"`
|
|
Level Level `json:"level"`
|
|
// Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the
|
|
// machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant,
|
|
// which names the ask's digest, names it too: a warrant then authorises that act and no other, and an
|
|
// asker whose record of the act changed after it asked finds the digests differ and does nothing.
|
|
// Required on an option above acknowledge.
|
|
Binds string `json:"binds,omitempty"`
|
|
}
|
|
|
|
// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each
|
|
// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and
|
|
// values alone, never over how a language happens to encode a struct.
|
|
type Act map[string]string
|
|
|
|
// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical),
|
|
// written "sha256:<hex>". The same names and values give the same digest in any language, whatever order
|
|
// they were set in; a field renamed, added or emptied gives another.
|
|
func ActDigest(act Act) (string, error) {
|
|
if len(act) == 0 {
|
|
return "", fmt.Errorf("the act cannot be digested: it names nothing")
|
|
}
|
|
keys := make([]string, 0, len(act))
|
|
for k := range act {
|
|
if k == "" {
|
|
return "", fmt.Errorf("the act cannot be digested: a field has no name")
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
b.WriteString("novox.act.v1\n")
|
|
for _, k := range keys {
|
|
canonical(&b, k)
|
|
canonical(&b, act[k])
|
|
}
|
|
sum := sha256.Sum256([]byte(b.String()))
|
|
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read
|
|
// as another's end or start, so two different sequences of values never encode the same.
|
|
func canonical(b *strings.Builder, v string) {
|
|
b.WriteString(strconv.Itoa(len(v)))
|
|
b.WriteByte(':')
|
|
b.WriteString(v)
|
|
b.WriteByte('\n')
|
|
}
|
|
|
|
// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each
|
|
// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker
|
|
// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the
|
|
// person was shown it, and nothing published under the same id before or after.
|
|
//
|
|
// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC
|
|
// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask
|
|
// later changes no digest until it is added here, on purpose. 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.
|
|
func (a Ask) Digest() string {
|
|
var b strings.Builder
|
|
b.WriteString("novox.ask.v1\n")
|
|
for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who,
|
|
a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent),
|
|
strconv.Itoa(len(a.Options))} {
|
|
canonical(&b, v)
|
|
}
|
|
for _, o := range a.Options {
|
|
for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} {
|
|
canonical(&b, v)
|
|
}
|
|
}
|
|
if a.Whole != "" || a.Details != "" {
|
|
canonical(&b, a.Whole)
|
|
canonical(&b, a.Details)
|
|
}
|
|
sum := sha256.Sum256([]byte(b.String()))
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Ask is a request for a person's word (novox/hq ADR 0259 §4).
|
|
type Ask struct {
|
|
// ID is the asker's own, unique to it.
|
|
ID string `json:"id"`
|
|
// Headline names the thing and what is wrong, in a few plain words; Explanation is what happened and
|
|
// what it means. Both are held to the plain rule and the content rule by the router.
|
|
Headline string `json:"headline"`
|
|
Explanation string `json:"explanation"`
|
|
Options []Option `json:"options"`
|
|
// Who may answer: Operator.
|
|
Who string `json:"who"`
|
|
// Expires is when the ask ends unanswered; OnExpiry is what the asker then does, in words the person
|
|
// is shown ("the delivery stays held"). An ask that authorises never defaults.
|
|
Expires time.Time `json:"expires"`
|
|
OnExpiry string `json:"on-expiry"`
|
|
// About is what the ask is about (a condition's key): a newer ask about it replaces the older.
|
|
About string `json:"about,omitempty"`
|
|
Urgent bool `json:"urgent,omitempty"`
|
|
// Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind
|
|
// that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the
|
|
// person approves — a mount point, a share, a private address — must be readable where they approve it.
|
|
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
|
|
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
|
|
Whole string `json:"whole,omitempty"`
|
|
// Details is what the Details answer on the ask's message shows, line by line under the content rule: a
|
|
// fingerprint, how to read the proposal whole at the terminal. Empty, an ask's own message offers no Details.
|
|
Details string `json:"details,omitempty"`
|
|
}
|
|
|
|
// 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 (a Ask) Highest() Level {
|
|
high := Acknowledge
|
|
for _, o := range a.Options {
|
|
if o.Level.Rank() > high.Rank() {
|
|
high = o.Level
|
|
}
|
|
}
|
|
return high
|
|
}
|
|
|
|
// Option is the option of this id, or false.
|
|
func (a Ask) Option(id string) (Option, bool) {
|
|
for _, o := range a.Options {
|
|
if o.ID == id {
|
|
return o, true
|
|
}
|
|
}
|
|
return Option{}, false
|
|
}
|
|
|
|
// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on.
|
|
func (a Ask) Check(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 o.Level.Rank() > Destroy.Rank() {
|
|
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)
|
|
}
|
|
}
|
|
if !a.Expires.After(now) {
|
|
say("it expires before it is asked")
|
|
}
|
|
switch a.Highest() {
|
|
case Approve:
|
|
if a.Expires.After(now.Add(ApproveLasts)) {
|
|
say("an ask that approves lasts at most %s", ApproveLasts)
|
|
}
|
|
case Destroy:
|
|
if a.Expires.After(now.Add(DestroyLasts)) {
|
|
say("an ask that destroys lasts at most %s", DestroyLasts)
|
|
}
|
|
}
|
|
if a.Highest() != 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
|
|
}
|
|
|
|
// Outcome is how an ask ended.
|
|
type Outcome string
|
|
|
|
const (
|
|
OutcomeChosen Outcome = "chosen" // a person chose an option: a warrant
|
|
OutcomeExpired Outcome = "expired" // nobody answered in time
|
|
OutcomeCancelled Outcome = "cancelled" // its asker took it back
|
|
OutcomeReplaced Outcome = "replaced" // a newer ask about the same thing replaced it
|
|
OutcomeRefused Outcome = "refused" // it was never shown: Words says why
|
|
)
|
|
|
|
// Person is who chose, as the router verified them.
|
|
type Person struct {
|
|
// Who is the role: Operator.
|
|
Who string `json:"who"`
|
|
// Kind is the channel kind they answered on, Identity their account on that service, Display the name
|
|
// the service shows, and Verified how the router knew it was them.
|
|
Kind string `json:"kind"`
|
|
Identity string `json:"identity"`
|
|
Display string `json:"display,omitempty"`
|
|
Verified string `json:"verified"`
|
|
}
|
|
|
|
// Warrant is the router's record that a person chose one option of one ask, or the ask's end without one
|
|
// (novox/hq ADR 0259 §6). It carries no secret.
|
|
type Warrant struct {
|
|
Ask string `json:"ask"`
|
|
Asker string `json:"asker"`
|
|
About string `json:"about,omitempty"`
|
|
Outcome Outcome `json:"outcome"`
|
|
// Option, Label and Level are the option chosen; By who chose it, Channel the module it came through,
|
|
// Proofs which proofs were present (P1, P2, P3).
|
|
Option string `json:"option,omitempty"`
|
|
Label string `json:"label,omitempty"`
|
|
Level Level `json:"level,omitempty"`
|
|
By *Person `json:"by,omitempty"`
|
|
Channel string `json:"channel,omitempty"`
|
|
Proofs []string `json:"proofs,omitempty"`
|
|
At time.Time `json:"at"`
|
|
// AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask
|
|
// alone, with the options it bound.
|
|
AskDigest string `json:"ask-digest,omitempty"`
|
|
// Words are why an ask ended without a choice, or what refused it.
|
|
Words string `json:"words,omitempty"`
|
|
}
|
|
|
|
// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id
|
|
// verified), chose Release".
|
|
func (w Warrant) Says() 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 (w Warrant) For(asker string, a Ask) (Option, error) {
|
|
if w.Asker != asker || w.Ask != a.ID {
|
|
return 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 Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome)
|
|
}
|
|
if d := a.Digest(); w.AskDigest != d {
|
|
return 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 := a.Option(w.Option)
|
|
if !offered {
|
|
return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option)
|
|
}
|
|
if w.Level != o.Level {
|
|
return 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 (the
|
|
// confirmation review of 2026-10-09).
|
|
if a.Expires.IsZero() {
|
|
return Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID)
|
|
}
|
|
if w.At.IsZero() {
|
|
return 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 w.At.After(a.Expires) {
|
|
return Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s",
|
|
w.At.UTC().Format(time.RFC3339), a.ID, a.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 (o Option) Performs(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
|
|
}
|
|
|
|
// Button is one answer a channel offers: its label and the router's one-time ticket for it.
|
|
type Button struct {
|
|
Label string `json:"label"`
|
|
Ticket string `json:"ticket"`
|
|
}
|
|
|
|
// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said
|
|
// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps
|
|
// which of its own messages that is. The words are the router's, shown as given.
|
|
type Message struct {
|
|
Handle string `json:"handle"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Buttons []Button `json:"buttons,omitempty"`
|
|
Urgent bool `json:"urgent,omitempty"`
|
|
Silent bool `json:"silent,omitempty"`
|
|
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
|
|
Reply string `json:"reply,omitempty"`
|
|
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
|
|
// the channel sends what is not a reply. The router's word, from its list; empty when none is linked.
|
|
To string `json:"to,omitempty"`
|
|
// Secret says the words carry something shown once (a link's code): the channel shows it and keeps no
|
|
// copy of it — no state, no history of its own.
|
|
Secret bool `json:"secret,omitempty"`
|
|
}
|
|
|
|
// Sender is who a channel's service says sent something: the account, the name it shows, and whether the
|
|
// service authenticated it. The router alone judges whether that is the operator.
|
|
type Sender struct {
|
|
Identity string `json:"identity"`
|
|
Display string `json:"display,omitempty"`
|
|
Authenticated bool `json:"authenticated"`
|
|
}
|
|
|
|
// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help.
|
|
type Failed struct {
|
|
Handle string `json:"handle"`
|
|
Why string `json:"why"`
|
|
Permanent bool `json:"permanent,omitempty"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// Standing is what a channel says of itself, at least every five minutes and whenever it changes:
|
|
// whether it can send now, why not, and whether its edits notify nobody.
|
|
type Standing struct {
|
|
Ready bool `json:"ready"`
|
|
Why string `json:"why,omitempty"`
|
|
EditsSilently bool `json:"edits-silently,omitempty"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// What a channel says of its own delivery, on IntakeSubject.
|
|
const (
|
|
FailedWhat = "failed"
|
|
StandingWhat = "standing"
|
|
)
|
|
|
|
// Choice is a button chosen on a channel.
|
|
type Choice struct {
|
|
// ID is the channel's own for this arrival, unique, so the router acts on it once.
|
|
ID string `json:"id"`
|
|
Ticket string `json:"ticket"`
|
|
Handle string `json:"handle,omitempty"`
|
|
Sender Sender `json:"sender"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// LinkAsked is somebody on a channel asking to be linked as the operator.
|
|
type LinkAsked struct {
|
|
ID string `json:"id"`
|
|
Sender Sender `json:"sender"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept.
|
|
type Code struct {
|
|
Sender Sender `json:"sender"`
|
|
Code string `json:"code"`
|
|
Purpose string `json:"purpose"`
|
|
}
|
|
|
|
// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person.
|
|
type ProofAnswer struct {
|
|
Accepted bool `json:"accepted"`
|
|
Words string `json:"words"`
|
|
}
|