diff --git a/README.md b/README.md index 1788b82..a83b1ae 100644 --- a/README.md +++ b/README.md @@ -106,21 +106,20 @@ runtime's (`node-tools/internal/launch`, mesh-tools). values and then every change. Through the runtime that launched the bundle, which refuses what the module did not declare. Current values, never history and never a secret (novox/hq ADR 0201). -### `seats` and `asks` — asking a person, answering on a channel (Go) +### `seats` and `channel` — publishing on a seat, the channel's benches (Go) A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve` and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept), `stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by -compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the -contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves -who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on -(`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the -subjects each travels on. -`go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then -publishes it under the module's name; `Cancel`; `Where` reads its state from the router's record; `Listen` and -`Take` hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only -for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is -`stdio.ErrChanged`, told by the runtime's error code. +compare-and-set. The runtime does each only as far as the module's membership lists. `go/channel` holds the +shapes of the two benches a channel holds, `channel` and `intake` (the `Message` a channel takes, the `Choice`, +`LinkAsked`, `Failed`, `Standing` and `Code` it says, with the subjects each travels on): those benches are not +defined whole, so their layouts stay here (ADR 0625 §9). +Asking a person is the `operator-channel` seat's, defined whole in mesh-contracts: its `Ask` and `Warrant` are its +generated contract (`go/seats/operatorchannel`), and the checks an ask is held to, the warrant's (`For`, `Says`, +`Performs`) and an asking module's client (`Ask`, `Cancel`, `Where`, `Listen`, `Take`, each warrant handed once, +marked acted on by compare-and-set) are its client, `go/clients/operatorchannelclient` (ADR 0627 §1). A lost +compare-and-set is `stdio.ErrChanged`, told by the runtime's error code. `State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted. diff --git a/go/asker/asker.go b/go/asker/asker.go deleted file mode 100644 index 360c7b6..0000000 --- a/go/asker/asker.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package asker is a module's client for asking 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. -package asker - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" -) - -// 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 asks.Ask `json:"ask"` - Asked time.Time `json:"asked"` - Acted bool `json:"acted,omitempty"` - Ended asks.Outcome `json:"ended,omitempty"` - Answer *asks.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 asks.Ask) error { - if err := a.Check(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(asks.AskSubject(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(asks.CancelSubject(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 *asks.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 *asks.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 asks.Warrant, act func(asks.Option, asks.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 != asks.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 := w.For(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, asks.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(asks.Option, asks.Warrant) error, said func(Handled, asks.Warrant, error)) error { - subscribe := c.Subscribe - if subscribe == nil { - subscribe = stdio.Subscribe - } - return subscribe(asks.Seat+".decided."+c.Module, func(e stdio.Envelope) error { - if !strings.HasPrefix(e.Key, asks.Seat+".decided.") { - return nil - } - var w asks.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 - }) -} diff --git a/go/asker/asker_test.go b/go/asker/asker_test.go deleted file mode 100644 index 6285876..0000000 --- a/go/asker/asker_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package asker - -import ( - "encoding/json" - "strings" - "testing" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" -) - -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 -} - -var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) - -func client() (*Client, map[string][]string) { - sent := map[string][]string{} - c := &Client{Module: "mesh-delivery", 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 held() asks.Ask { - binds, _ := asks.ActDigest(releaseHQ) - return asks.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.", - Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", - Options: []asks.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: asks.Approve, Binds: binds}}} -} - -func warrant() asks.Warrant { - return asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: asks.OutcomeChosen, Option: "release", - Label: "Release", Level: asks.Approve, At: now.Add(time.Minute), AskDigest: held().Digest(), - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} -} - -func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) { - c, sent := client() - if err := c.Ask(held()); err != nil { - t.Fatal(err) - } - if got := sent[asks.AskSubject("mesh-delivery")]; len(got) != 1 || got[0] != "d-1" { - t.Fatalf("%v", sent) - } - if err := c.Ask(held()); err == nil || !strings.Contains(err.Error(), "already made") { - t.Errorf("the same id was asked twice: %v", err) - } - bad := held() - 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(held()) - acted := 0 - act := func(o asks.Option, _ asks.Warrant) error { - if err := o.Performs(releaseHQ); err != nil { - t.Errorf("the bound act: %v", err) - } - acted++ - return nil - } - if h, err := c.Take(warrant(), act); h != Acted || err != nil || acted != 1 { - t.Fatalf("%s %v %d", h, err, acted) - } - if h, _ := c.Take(warrant(), act); h != Done || acted != 1 { - t.Errorf("a redelivery was acted on: %s", h) - } - for why, change := range map[string]func(*asks.Warrant){ - "another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" }, - "an ask not made": func(w *asks.Warrant) { w.Ask = "d-9" }, - "another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge }, - "an option not offered": func(w *asks.Warrant) { w.Option = "delete" }, - "after its expiry": func(w *asks.Warrant) { w.At = now.Add(2 * time.Hour) }, - "another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:00" }, - } { - c2, _ := client() - _ = c2.Ask(held()) - w := warrant() - 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(held()) - w := warrant() - w.Outcome, w.Option, w.By = asks.OutcomeExpired, "", nil - if h, err := c.Take(w, func(asks.Option, asks.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil { - t.Fatalf("%s %v", h, err) - } - if h, _ := c.Take(warrant(), func(asks.Option, asks.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done { - t.Errorf("%s", h) - } -} diff --git a/go/asks/asks.go b/go/asks/asks.go deleted file mode 100644 index 428e441..0000000 --- a/go/asks/asks.go +++ /dev/null @@ -1,583 +0,0 @@ -// 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" - - contractseats "git.novox.be/novox/mesh-contracts/go/seats" -) - -// 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. - // - // Deprecated: the seat is defined whole in mesh-contracts (operatorchannel.Seat, its subjects and message - // types); kept for its importers until their next change takes it from there (testdata/seat-names.txt). - Seat = contractseats.OperatorChannel - // 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:". 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) - } - } - // 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 (the review of mesh-sdk #25). 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[:]) -} - -// 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 are the ask's secondary words, line by line under the content rule: who proposed it, a - // fingerprint, how to read it whole. The router shows them in the note of the ask's own message - // (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq - // issue 639). - Details string `json:"details,omitempty"` - // WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown - // in place of the explanation (a kind that proves who answers, given the ask's answers): every value an - // approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like - // Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty, - // Details are shown everywhere. - WholeDetails string `json:"whole-details,omitempty"` -} - -// 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 (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"` - // Note is the message's secondary words, after its body and its buttons: who asked and when, what each - // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart - // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's - // length cuts the body first and the note only when the title and the note alone do not fit, saying each - // cut (novox/hq ADR 0347 §5). - Note string `json:"note,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"` -} - -// 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-`, 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 } diff --git a/go/asks/asks_test.go b/go/asks/asks_test.go deleted file mode 100644 index b9a7fdc..0000000 --- a/go/asks/asks_test.go +++ /dev/null @@ -1,367 +0,0 @@ -package asks - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "reflect" - "strings" - "testing" - "time" -) - -var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) - -func held() Ask { - return Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.", - Who: Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", About: "delivery.openrazer", - Options: []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 := held().Check(now); err != nil { - t.Fatal(err) - } -} - -func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) { - for want, change := range map[string]func(*Ask){ - "is not letters": func(a *Ask) { a.ID = "a.b" }, - "headline is empty": func(a *Ask) { a.Headline = "" }, - "explains nothing": func(a *Ask) { a.Explanation = " " }, - "only the operator answers": func(a *Ask) { a.Who = "agent" }, - "offers 0 options": func(a *Ask) { a.Options = nil }, - "offers 5 options": func(a *Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) }, - "is offered twice": func(a *Ask) { a.Options[1].ID = "release" }, - "does not say what it does": func(a *Ask) { a.Options[0].Does = "" }, - "none of acknowledge": func(a *Ask) { a.Options[0].Level = "admin" }, - "expires before it is asked": func(a *Ask) { a.Expires = now }, - "approves lasts at most": func(a *Ask) { a.Expires = now.Add(25 * time.Hour) }, - "destroys lasts at most": func(a *Ask) { a.Options[0].Level = Destroy }, - "never defaults": func(a *Ask) { a.OnExpiry = "" }, - "is a key, without spaces": func(a *Ask) { a.About = "a b" }, - "does not bind what it": func(a *Ask) { a.Options[0].Binds = "" }, - } { - a := held() - change(&a) - if err := a.Check(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 = now.Add(7 * 24 * time.Hour) - if err := ack.Check(now); err != nil { - t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err) - } -} - -func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) { - w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release", - Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, - AskDigest: held().Digest(), At: now.Add(time.Minute)} - if o, err := w.For("mesh-delivery", held()); err != nil || o.ID != "release" { - t.Fatalf("a sound warrant was refused: %v", err) - } - if got := w.Says(); got != "the operator, via telegram (user id verified), chose Release" { - t.Errorf("the warrant says %q", got) - } - for why, change := range map[string]func(*Warrant){ - "another asker": func(w *Warrant) { w.Asker = "mesh-controller" }, - "another ask": func(w *Warrant) { w.Ask = "d-2" }, - "no choice": func(w *Warrant) { w.Outcome = OutcomeExpired }, - "nobody": func(w *Warrant) { w.By = nil }, - "not offered": func(w *Warrant) { w.Option = "delete" }, - "a lower level": func(w *Warrant) { w.Level = Acknowledge }, - "another role": func(w *Warrant) { w.By = &Person{Who: "agent"} }, - "no digest": func(w *Warrant) { w.AskDigest = "" }, - "no time given": func(w *Warrant) { w.At = time.Time{} }, - "after expiry": func(w *Warrant) { w.At = now.Add(2 * time.Hour) }, - "another ask's digest": func(w *Warrant) { - other := held() - other.Options[0].Binds = bind("release", "another") - w.AskDigest = other.Digest() - }, - } { - c := w - by := *w.By - c.By = &by - change(&c) - if _, err := c.For("mesh-delivery", held()); err == nil { - t.Errorf("a warrant with %s was acted on", why) - } - } -} - -func TestTheSubjectsNameTheirPublisher(t *testing.T) { - for got, want := range map[string]string{ - AskSubject("mesh-delivery"): "mesh.seat.operator-channel.accept.ask.mesh-delivery", - DecidedSubject("mesh-delivery"): "mesh.seat.operator-channel.event.decided.mesh-delivery", - ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram", - IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop", - ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram", - } { - if got != want { - t.Errorf("%s, want %s", got, want) - } - } -} - -// 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 Ask - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatal(err) - } - if decoded.Digest() != a.Digest() { - t.Fatalf("the ask's digest changed on the way: %s, %s", decoded.Digest(), a.Digest()) - } - local := a - local.Expires = a.Expires.In(time.FixedZone("CEST", 2*3600)) - if local.Digest() != a.Digest() { - 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(*Ask){ - "the headline": func(a *Ask) { a.Headline += "!" }, - "the explanation": func(a *Ask) { a.Explanation += " " }, - "a label": func(a *Ask) { a.Options[0].Label = "Go" }, - "what it binds": func(a *Ask) { a.Options[0].Binds = bind("release", "another") }, - "the expiry": func(a *Ask) { a.Expires = a.Expires.Add(time.Second) }, - "what it is about": func(a *Ask) { a.About = "delivery.other" }, - } { - c := held() - change(&c) - if c.Digest() == a.Digest() { - t.Errorf("changing %s leaves the digest", what) - } - } - o, _ := a.Option("release") - if err := o.Performs(Act{"verb": "release", "delivery": "openrazer"}); err != nil { - t.Errorf("the bound act was refused: %v", err) - } - if err := o.Performs(Act{"verb": "release", "delivery": "another"}); err == nil { - t.Error("an act the option did not bind was let through") - } - ack, _ := a.Option("silence") - if err := ack.Performs(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) { - 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(*Ask){ - "ID": func(a *Ask) { a.ID = "other" }, - "Headline": func(a *Ask) { a.Headline += "!" }, - "Explanation": func(a *Ask) { a.Explanation += "!" }, - "Who": func(a *Ask) { a.Who = "someone" }, - "Expires": func(a *Ask) { a.Expires = a.Expires.Add(time.Nanosecond) }, - "OnExpiry": func(a *Ask) { a.OnExpiry += "!" }, - "About": func(a *Ask) { a.About += "!" }, - "Urgent": func(a *Ask) { a.Urgent = !a.Urgent }, - "Options": func(a *Ask) { a.Options = a.Options[:1] }, - "Option.ID": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].ID = "x" }, - "Option.Label": func(a *Ask) { - a.Options = append([]Option(nil), a.Options...) - a.Options[0].Label += "!" - }, - "Option.Does": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].Does += "!" }, - "Option.Level": func(a *Ask) { - a.Options = append([]Option(nil), a.Options...) - a.Options[0].Level = Destroy - }, - "Option.Binds": func(a *Ask) { - a.Options = append([]Option(nil), a.Options...) - a.Options[0].Binds = "sha256:00" - }, - } - for name, mutate := range mutations { - c := base - mutate(&c) - if c.Digest() == base.Digest() { - 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 := Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: now, OnExpiry: "o", About: "a", - Options: []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 plain.Digest() != "sha256:"+hex.EncodeToString(byHand[:]) { - t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", plain.Digest()) - } - withWhole, withDetails := plain, plain - withWhole.Whole = "e, whole" - withDetails.Details = "fingerprint 1234" - if withWhole.Digest() == plain.Digest() || withDetails.Digest() == plain.Digest() || withWhole.Digest() == withDetails.Digest() { - t.Error("Whole or Details left the digest the same") - } - both := withWhole - both.Details = "fingerprint 1234" - changed := both - changed.Whole += "!" - if changed.Digest() == both.Digest() { - 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(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(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 = z.Expires.In(time.FixedZone("x", 3600)) - if z.Digest() != base.Digest() { - 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 = time.Time{} - w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release", - Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: a.Digest(), - At: now.Add(time.Minute)} - if _, err := w.For("mesh-delivery", 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") - } -} - -// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older -// than it reads every message it read before. -func TestAMessageCarriesItsNoteApart(t *testing.T) { - raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."}) - if !strings.Contains(string(raw), `"note":"Asked by x."`) { - t.Fatalf("the note is not encoded: %s", raw) - } - raw, _ = json.Marshal(Message{Handle: "h", Title: "t"}) - if strings.Contains(string(raw), "note") { - t.Fatalf("an empty note is encoded: %s", raw) - } -} - -// 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 := Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"} - before := a.Digest() - a.WholeDetails = "+ shares: media=/storage/media" - if a.Digest() == before { - t.Fatal("the whole details are not in the digest") - } - b := a - b.WholeDetails = "+ shares: media=/storage/other" - if a.Digest() == b.Digest() { - t.Fatal("two whole details digest the same") - } - a.WholeDetails = "" - if a.Digest() != 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 := Ask{ID: "a", Headline: "h", Explanation: "e"} - shapes := []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[a.Digest()]; ok { - t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a) - } - seen[a.Digest()] = i - } -} diff --git a/go/asks/condition.go b/go/asks/condition.go deleted file mode 100644 index 43aae39..0000000 --- a/go/asks/condition.go +++ /dev/null @@ -1,149 +0,0 @@ -package asks - -import ( - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" -) - -// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel -// seat, `mesh.seat.operator-channel.accept.condition.` (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("asks: 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("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) - } - return "mesh.seat." + Seat + ".accept.condition." + 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("asks: 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" -) - -// Scope is what a condition is about, under the controller's names. -type Scope struct { - Scope string `json:"scope"` - ID string `json:"id"` - Machine string `json:"machine,omitempty"` -} - -// Action is one answer a notification offers, under the controller's names. -type Action struct { - Label string `json:"label"` - Verb string `json:"verb"` - Machine string `json:"machine,omitempty"` - Arguments map[string]string `json:"arguments,omitempty"` - Level Level `json:"level,omitempty"` -} - -// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key, -// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs -// only its key, subject and state. The router alone chooses the channels. -type Condition struct { - V int `json:"v"` - Key string `json:"key"` - Severity string `json:"severity,omitempty"` - Subject Scope `json:"subject"` - State string `json:"state"` - Summary string `json:"summary,omitempty"` - Headline string `json:"headline,omitempty"` - Needs string `json:"needs,omitempty"` - Explanation string `json:"explanation,omitempty"` - Resolved string `json:"resolved,omitempty"` - Actions []Action `json:"actions,omitempty"` -} - -func (c Condition) check() error { - if c.Key == "" { - return errors.New("asks: a condition has no key") - } - if c.Subject.Scope == "" || c.Subject.ID == "" { - return errors.New("asks: a condition's subject needs its scope and id") - } - switch c.State { - case StateCleared: - return nil - case StateRaised: - default: - return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State) - } - switch c.Severity { - case SeverityUrgent, SeverityWarning: - default: - return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity) - } - if c.Headline == "" { - return errors.New("asks: a raised condition has no headline") - } - if n := len([]rune(c.Headline)); n > HeadlineLength { - return fmt.Errorf("asks: 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("asks: 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 Condition) ([]byte, error) { - if err := c.check(); 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) (Condition, error) { - var probe struct { - V *int `json:"v"` - } - if err := json.Unmarshal(data, &probe); err != nil { - return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err) - } - if probe.V == nil { - return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) - } - if *probe.V != ConditionVersion { - return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) - } - var c Condition - if err := json.Unmarshal(data, &c); err != nil { - return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err) - } - if err := c.check(); err != nil { - return Condition{}, err - } - return c, nil -} - -// SameCondition says whether two conditions are the same value. -func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) } diff --git a/go/asks/condition_test.go b/go/asks/condition_test.go deleted file mode 100644 index 05ab6fa..0000000 --- a/go/asks/condition_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package asks - -import ( - "encoding/json" - "errors" - "strings" - "testing" -) - -func TestConditionSubject(t *testing.T) { // 1202/AC1 - s, err := ConditionSubject("mesh-delivery-teller") - if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-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] != "mesh-delivery-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() Condition { - return Condition{ - Key: "delivery.stuck.mesh-mail", Severity: "warning", - Subject: Scope{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: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace", - Arguments: map[string]string{"why": ""}, Level: Approve}}, - } -} - -func clearedCondition() Condition { - return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"} -} - -func TestConditionRoundTrip(t *testing.T) { // 1202/AC2 - for _, c := range []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(*Condition)) error { - c := raisedCondition() - f(&c) - _, err := EncodeCondition(c) - return err - } - for name, f := range map[string]func(*Condition){ - "severity": func(c *Condition) { c.Severity = "critical" }, - "state": func(c *Condition) { c.State = "open" }, - "key": func(c *Condition) { c.Key = "" }, - "headline": func(c *Condition) { c.Headline = "" }, - "long headline": func(c *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 *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") - } -} diff --git a/go/channel/channel.go b/go/channel/channel.go new file mode 100644 index 0000000..a9b1d54 --- /dev/null +++ b/go/channel/channel.go @@ -0,0 +1,141 @@ +// Package channel holds the shapes of the two kinded benches a channel holds, `channel` (to show and say) and `intake` +// (to say what was chosen, and to ask a proof), and the subjects they travel on (novox/hq ADR 0259 §3). The router +// sends a channel a Message, and a channel says a Choice, a LinkAsked, a Failed or its Standing, or asks a Code's proof. +// Those benches are not defined whole, so their value layouts stay in mesh-sdk (novox/hq ADR 0625 §9, ADR 0627 §4); +// the operator-channel seat's own ask and warrant are its contract's and its client's, in mesh-contracts. +package channel + +import "time" + +// The benches (novox/hq ADR 0259 §3). +const ( + // 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" +) + +// 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 +} + +// 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"` + // Note is the message's secondary words, after its body and its buttons: who asked and when, what each + // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart + // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's + // length cuts the body first and the note only when the title and the note alone do not fit, saying each + // cut (novox/hq ADR 0347 §5). + Note string `json:"note,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"` +} diff --git a/go/channel/channel_test.go b/go/channel/channel_test.go new file mode 100644 index 0000000..bdcb31d --- /dev/null +++ b/go/channel/channel_test.go @@ -0,0 +1,32 @@ +package channel + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestTheSubjectsNameTheirPublisher(t *testing.T) { + for got, want := range map[string]string{ + ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram", + IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop", + ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram", + } { + if got != want { + t.Errorf("%s, want %s", got, want) + } + } +} + +// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older +// than it reads every message it read before. +func TestAMessageCarriesItsNoteApart(t *testing.T) { + raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."}) + if !strings.Contains(string(raw), `"note":"Asked by x."`) { + t.Fatalf("the note is not encoded: %s", raw) + } + raw, _ = json.Marshal(Message{Handle: "h", Title: "t"}) + if strings.Contains(string(raw), "note") { + t.Fatalf("an empty note is encoded: %s", raw) + } +} diff --git a/go/delivery/gate.go b/go/delivery/gate.go index 450fa5b..c6b593b 100644 --- a/go/delivery/gate.go +++ b/go/delivery/gate.go @@ -1,6 +1,7 @@ package delivery import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -10,8 +11,6 @@ import ( "sort" "strconv" "strings" - - "git.novox.be/novox/mesh-sdk/go/asks" ) // The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected @@ -131,7 +130,7 @@ func ParseAskKey(key string) (kind, group, hash string, err error) { // AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string. func AnswerKey(askKey string) string { return askKey } -// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID), +// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (its usable id), // so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and // the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back: // the teller keeps the key beside it. @@ -329,12 +328,15 @@ func (m Merge) check() error { } // Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it -// unchanged, and the group and heads it was composed for. +// unchanged, and the group and heads it was composed for. The ask is the operator-channel seat's (mesh-contracts +// operatorchannel.Ask), carried here as its JSON: mesh-sdk names no seat defined whole (novox/hq ADR 0627 §4), so its +// id (AskingID of its key and asking), its options and its digest are checked by the gate and the teller through +// operator-channel's client. type Ask struct { - V int `json:"v"` - Group string `json:"group"` - Heads []Head `json:"heads"` - Ask asks.Ask `json:"ask"` + V int `json:"v"` + Group string `json:"group"` + Heads []Head `json:"heads"` + Ask json.RawMessage `json:"ask"` // Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th. // The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id // (AskingID); the first asking's id is unchanged and the field is omitted for it. @@ -345,15 +347,18 @@ func (a Ask) check() error { if a.Asking < 0 { return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) } - key, err := MergeAskKey(a.Group, a.Heads) - if err != nil { + if _, err := MergeAskKey(a.Group, a.Heads); err != nil { return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err) } - if want := AskingID(key, a.Asking); a.Ask.ID != want { - return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group, heads and asking %d give", a.Ask.ID, want, a.Asking) - } - if len(a.Ask.Options) == 0 { - return fmt.Errorf("delivery: the ask has no option") + return routerAsk(a.Ask) +} + +// routerAsk refuses a value that carries no router's ask: none, null, or JSON that is no object. What the ask says +// is operator-channel's to check, through its client (novox/hq ADR 0627 §4). +func routerAsk(raw json.RawMessage) error { + var fields map[string]json.RawMessage + if len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil { + return fmt.Errorf("delivery: the value carries no router's ask") } return nil } diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index 0927506..4cb2c40 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -3,12 +3,11 @@ package delivery import ( "encoding/json" "errors" + "regexp" "strconv" "strings" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/asks" ) func gateHeads() []Head { @@ -66,7 +65,7 @@ func TestAskID(t *testing.T) { // 1201/AC2 if strings.Contains(id, ".") { t.Errorf("id %q holds a dot", id) } - if !asks.UsableID(id) { + if !routerUsableID(id) { t.Errorf("id %q is not usable by the router", id) } if other, dup := seen[id]; dup { @@ -83,7 +82,7 @@ func TestAskID(t *testing.T) { // 1201/AC2 if g != SettleGroup([]string{"novox/a@111111111111", "novox/b@222222222222"}) || !strings.HasPrefix(g, SettleGroupPrefix) || len(g) != len(SettleGroupPrefix)+12 { t.Errorf("SettleGroup = %q", g) } - if k, err := MergeAskKey(g, gateHeads()); err != nil || !asks.UsableID(AskID(k)) { + if k, err := MergeAskKey(g, gateHeads()); err != nil || !routerUsableID(AskID(k)) { t.Errorf("the settle ask's key %q: %v", k, err) } for group, want := range map[string]string{g: "mesh-delivery.settle", "feat/x": "mesh-delivery.merge", @@ -149,13 +148,22 @@ func sampleTurn() Turn { func sampleAsk() Ask { key, _ := MergeAskKey("feat/x", gateHeads()) - return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{ - ID: AskID(key), Headline: "Merge into main: group feat/x", Explanation: "The group passed its checks.", - Who: asks.Operator, Expires: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), OnExpiry: "nothing is merged", - Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}, - }} + return Ask{Group: "feat/x", Heads: gateHeads(), Ask: routerAskJSON(AskID(key), "Merge into main: group feat/x")} } +// routerAskJSON is a router's ask as the gate carries it: the operator-channel seat's JSON, which mesh-sdk does not +// type (novox/hq ADR 0627 §4). +func routerAskJSON(id, headline string) json.RawMessage { + raw, _ := json.Marshal(map[string]any{"id": id, "headline": headline, "explanation": "The group passed its checks.", + "who": "operator", "expires": "2026-10-20T08:00:00Z", "on-expiry": "nothing is merged", + "options": []map[string]string{{"id": "approve", "label": "Approve", "does": "merges", "level": "approve", "binds": "sha256:00"}}}) + return raw +} + +// routerUsableID is the router's rule for an ask's id (operator-channel's client, UsableID), held here to check that +// AskID and AskingID give ids the router takes. +var routerUsableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`).MatchString + func sampleOwed() []Owed { return []Owed{ {Act: OwedStatus, Context: "mesh/merge-gate", State: "success", Target: "https://mesh.example/delivery/36"}, @@ -271,13 +279,11 @@ func TestGateValuesRefused(t *testing.T) { // 1201/AC5 wantErr("merge without its ask", err, "ask") a := sampleAsk() - a.Ask.ID = "something-else" - _, err = EncodeAsk(a) - wantErr("ask with another id", err, "something-else", AskID("merge.feat/x."+mustHash(gateHeads()))) - a = sampleAsk() - a.Ask.Options = nil - _, err = EncodeAsk(a) - wantErr("ask without options", err, "option") + for _, raw := range []string{"", "null", "[]", `"ask"`} { + a.Ask = json.RawMessage(raw) + _, err = EncodeAsk(a) + wantErr("a value with no router's ask "+raw, err, "no router's ask") + } a = sampleAsk() a.Heads = nil _, err = EncodeAsk(a) @@ -343,7 +349,7 @@ func TestGateSame(t *testing.T) { // 1201/AC6 t.Error("different merges are the same") } a := sampleAsk() - a.Ask.Headline = "other" + a.Ask = routerAskJSON(AskID("merge.feat/x."+mustHash(gateHeads())), "other") if SameAsk(a, sampleAsk()) { t.Error("different asks are the same") } @@ -414,7 +420,7 @@ func TestKeysEscapeAndRoundTrip(t *testing.T) { // 1323/AC1 t.Error("answer key differs from the ask key") } // the ask and the answer of such a group encode and decode - ask := Ask{Group: name, Heads: gateHeads(), Ask: asks.Ask{ID: AskID(ak), Options: []asks.Option{{ID: "ok"}}}} + ask := Ask{Group: name, Heads: gateHeads(), Ask: routerAskJSON(AskID(ak), "ok")} if data, err := EncodeAsk(ask); err != nil { t.Errorf("encode ask for %q: %v", name, err) } else if _, err := DecodeAsk(data); err != nil { @@ -600,7 +606,7 @@ func TestAskingID(t *testing.T) { // 1370/AC1 t.Errorf("askings %d and %d give the id %q", n, other, id) } seen[id] = n - if len(id) > 64 || !asks.UsableID(id) { + if len(id) > 64 || !routerUsableID(id) { t.Errorf("id %q of asking %d is not usable by the router", id, n) } if id != AskID(key+"."+strconv.Itoa(n)) { @@ -621,7 +627,7 @@ func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2 t.Fatalf("asking 1 under the first id: %v", err) } a.Asking = 3 - a.Ask.ID = AskingID(key, 3) + a.Ask = routerAskJSON(AskingID(key, 3), "Merge into main: group feat/x") data, err := EncodeAsk(a) if err != nil { t.Fatalf("asking 3 under its id: %v", err) @@ -630,15 +636,6 @@ func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2 if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) || got.Asking != 3 { t.Errorf("asking round trip: %+v %v", got, err) } - a.Ask.ID = AskingID(key, 2) - if _, err := EncodeAsk(a); err == nil { - t.Error("asking 3 under asking 2's id was accepted") - } - a.Asking = 2 - a.Ask.ID = AskID(key) - if _, err := EncodeAsk(a); err == nil { - t.Error("asking 2 under the first asking's id was accepted") - } a.Asking = -1 if _, err := EncodeAsk(a); err == nil { t.Error("a negative asking was accepted") diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go index 9d3233e..4ba3bfa 100644 --- a/go/delivery/view_answers.go +++ b/go/delivery/view_answers.go @@ -1,11 +1,10 @@ package delivery import ( + "bytes" "encoding/json" "fmt" "strings" - - "git.novox.be/novox/mesh-sdk/go/asks" ) // The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The @@ -221,11 +220,13 @@ func ParseViewAnswerKey(key string) (askKey string, err error) { // AssignerAsk is the value of a key in AssignerAsksBucket: the router's ask whole, as the assigner composes it, so the // teller publishes it unchanged. Asking counts the times the assigner wrote the ask at this key, as the gate's Ask // does: 0 or 1 for the first, n for the n-th, and the ask's id is AskingID(key, Asking), so each asking has its own id -// and an answer to an earlier asking is never taken for a later one. +// and an answer to an earlier asking is never taken for a later one. The ask is the operator-channel seat's +// (mesh-contracts operatorchannel.Ask) as its JSON, as in the gate's Ask: its id, its options and its digest are +// checked by the assigner and the teller through operator-channel's client (novox/hq ADR 0627 §4). type AssignerAsk struct { - V int `json:"v"` - Ask asks.Ask `json:"ask"` - Asking int `json:"asking,omitempty"` + V int `json:"v"` + Ask json.RawMessage `json:"ask"` + Asking int `json:"asking,omitempty"` } func (a AssignerAsk) check(key string) error { @@ -239,17 +240,11 @@ func (a AssignerAsk) check(key string) error { if a.Asking < 0 { return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) } - if want := AskingID(key, a.Asking); a.Ask.ID != want { - return fmt.Errorf("delivery: the ask's id %q is not %q, the id its key and asking %d give", a.Ask.ID, want, a.Asking) - } - if len(a.Ask.Options) == 0 { - return fmt.Errorf("delivery: the ask has no option") - } - return nil + return routerAsk(a.Ask) } // EncodeAssignerAsk is a, the ask at key, as JSON at Version; it refuses a key of a kind that needs proof or of no -// kind, an id other than the one the key and the asking give, and an ask with no option. +// kind, a negative asking, and a value carrying no router's ask. func EncodeAssignerAsk(key string, a AssignerAsk) ([]byte, error) { if err := a.check(key); err != nil { return nil, err @@ -271,9 +266,18 @@ func DecodeAssignerAsk(key string, data []byte) (AssignerAsk, error) { return a, nil } -// SameAssignerAsk says whether two asks are the same value. +// SameAssignerAsk says whether two asks are the same value: the same version, asking and router's ask, its JSON +// compared compacted. func SameAssignerAsk(a, b AssignerAsk) bool { - return a.V == b.V && a.Asking == b.Asking && a.Ask.Digest() == b.Ask.Digest() + return a.V == b.V && a.Asking == b.Asking && bytes.Equal(compacted(a.Ask), compacted(b.Ask)) +} + +func compacted(raw json.RawMessage) []byte { + var b bytes.Buffer + if json.Compact(&b, raw) != nil { + return raw + } + return b.Bytes() } // ViewAnswer is the view's answer to an ask: the id of the asking it answers (the ask's id, AskingID of its key and @@ -295,21 +299,14 @@ func (a ViewAnswer) check() error { return nil } -// ViewAnswerFits says whether the view's answer a answers the ask as it stands now: it refuses an answer to another -// asking of the ask (a stale answer to an earlier writing of the same key) and an option the ask does not offer, -// naming both. -func ViewAnswerFits(a ViewAnswer, ask AssignerAsk) error { - if a.Asking != ask.Ask.ID { - return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, ask.Ask.ID) - } - o, ok := ask.Ask.Option(a.Option) - if !ok { - return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID) - } - if o.Level != "" && o.Level != asks.Acknowledge { - // An option that authorises is answered on a channel that proves who answered (ADR 0259 §5, ADR 0546 clause 8), - // never in the view, which proves nobody. - return fmt.Errorf("delivery: the view's answer chose %q, an option of level %s, which only a channel that proves who answered takes", a.Option, o.Level) +// ViewAnswerFits says whether the view's answer a answers the ask at key as it stands now: it refuses an answer to +// another asking of the ask (a stale answer to an earlier writing of the same key), naming both. Whether the ask +// offers the option chosen, and at the level acknowledge only (an option that authorises is answered on a channel +// that proves who answered, ADR 0259 §5, ADR 0546 clause 8, never in the view, which proves nobody), the teller reads +// from the ask through operator-channel's client (novox/hq ADR 0627 §4). +func ViewAnswerFits(a ViewAnswer, key string, ask AssignerAsk) error { + if want := AskingID(key, ask.Asking); a.Asking != want { + return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, want) } return nil } diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go index df69b2f..2d44d4b 100644 --- a/go/delivery/view_answers_test.go +++ b/go/delivery/view_answers_test.go @@ -1,11 +1,9 @@ package delivery import ( + "encoding/json" "strings" "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/asks" ) // 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an @@ -110,13 +108,14 @@ func holdAsk(t *testing.T, asking int) (string, AssignerAsk) { if err != nil { t.Fatal(err) } - return key, AssignerAsk{Asking: asking, Ask: asks.Ask{ID: AskingID(key, asking), Headline: "mesh-mail is held", Explanation: "its health failed", - Options: []asks.Option{{ID: "release", Label: "Release"}, {ID: "keep", Label: "Keep held"}}, Who: "operator", - Expires: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC), OnExpiry: "it stays held"}} + raw, _ := json.Marshal(map[string]any{"id": AskingID(key, asking), "headline": "mesh-mail is held", + "explanation": "its health failed", "who": "operator", "expires": "2026-10-30T00:00:00Z", "on-expiry": "it stays held", + "options": []map[string]string{{"id": "release", "label": "Release"}, {"id": "keep", "label": "Keep held"}}}) + return key, AssignerAsk{Asking: asking, Ask: raw} } -// 1297/B1c: the assigner's ask round-trips at its key; an id another key or asking gives, an ask of a kind that needs -// proof and one with no option are refused. +// 1297/B1c: the assigner's ask round-trips at its key; an ask of a kind that needs proof, a negative asking and a value +// carrying no router's ask are refused (its id and options are the teller's to check, ADR 0627 §4). func TestAssignerAskRoundTrip(t *testing.T) { key, a := holdAsk(t, 2) data, err := EncodeAssignerAsk(key, a) @@ -131,46 +130,39 @@ func TestAssignerAskRoundTrip(t *testing.T) { if err != nil || !SameAssignerAsk(got, a) { t.Errorf("the ask came back %+v, %v", got, err) } - other, _ := HoldAskKey("mesh-web", fp2of('f')) - if _, err := DecodeAssignerAsk(other, data); err == nil { - t.Errorf("an ask was read at another key") - } stale := a - stale.Asking = 3 + stale.Asking = -1 if _, err := EncodeAssignerAsk(key, stale); err == nil { - t.Errorf("an ask whose id is another asking's was accepted") + t.Errorf("a negative asking was accepted") } merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) - if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: asks.Ask{ID: AskingID(merge, 0), Options: a.Ask.Options}}); err == nil { + if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: a.Ask}); err == nil { t.Errorf("the assigner was let write a merge's ask") } - none := a - none.Ask.Options = nil - if _, err := EncodeAssignerAsk(key, none); err == nil { - t.Errorf("an ask with no option was accepted") + for _, raw := range []string{"", "null", "[1]"} { + none := a + none.Ask = json.RawMessage(raw) + if _, err := EncodeAssignerAsk(key, none); err == nil || !strings.Contains(err.Error(), "no router's ask") { + t.Errorf("a value carrying %q as its ask: %v", raw, err) + } + } + spaced := a + spaced.Ask = json.RawMessage(" " + string(a.Ask) + "\n") + if !SameAssignerAsk(spaced, a) { + t.Errorf("the same ask written with spaces is another") } } // 1297/B1c: a view answer fits only the asking it names: an answer to an earlier asking of the same key is refused, -// naming both, and so is an option the ask does not offer. +// naming both. Whether the option is offered, at the level acknowledge, the teller reads (ADR 0627 §4). func TestViewAnswerFits(t *testing.T) { key, first := holdAsk(t, 0) _, second := holdAsk(t, 2) answer := ViewAnswer{Asking: AskingID(key, 0), Option: "release"} - if err := ViewAnswerFits(answer, first); err != nil { + if err := ViewAnswerFits(answer, key, first); err != nil { t.Errorf("the answer to the first asking does not fit it: %v", err) } - if err := ViewAnswerFits(answer, second); err == nil || !strings.Contains(err.Error(), first.Ask.ID) || !strings.Contains(err.Error(), second.Ask.ID) { + if err := ViewAnswerFits(answer, key, second); err == nil || !strings.Contains(err.Error(), AskingID(key, 0)) || !strings.Contains(err.Error(), AskingID(key, 2)) { t.Errorf("a stale answer fits the later asking, or the refusal names neither: %v", err) } - if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil { - t.Errorf("an option the ask does not offer fits") - } - // An option that authorises is never answered in the view (ADR 0546 clause 8). - approving := first - approving.Ask.Options = append([]asks.Option(nil), first.Ask.Options...) - approving.Ask.Options[0].Level = asks.Approve - if err := ViewAnswerFits(answer, approving); err == nil || !strings.Contains(err.Error(), "approve") { - t.Errorf("the view answered an option of level approve: %v", err) - } } diff --git a/go/packages_list_test.go b/go/packages_list_test.go index be88d7d..918ced6 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -23,7 +23,7 @@ import ( // Logic only some modules need is its owner module's package. A package added here is a decision to say in the pull // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ - ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", + ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "testbus", "views", @@ -39,14 +39,9 @@ var clientSeats = []string{contractseats.OperatorChannel, contractseats.MeshVaul var notInSDK = []string{"asks", "asker", "publish"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below -// would refuse; it only shrinks, and is empty when the group is pinned: asks and asker are operator-channel's client -// (mesh-contracts go/clients/operatorchannelclient) once delivery's gate and assigner asks no longer hold the -// router's ask as asks.Ask; the vault module's name in delivery and provisions goes with the vault's client. +// would refuse; it only shrinks, and is empty when the group is pinned: the vault module's name in delivery and +// provisions goes when the vault's answers are its contract's. var untilTheJoin = []string{ - "asks/asks.go: imports git.novox.be/novox/mesh-contracts/go/seats", - "asks/asks.go: Seat", - "asks: a package that moved", - "asker: a package that moved", "delivery/signers.go: mesh-vault", "provisions/provisions.go: mesh-vault", } diff --git a/go/process/state.go b/go/process/state.go index 4d44d44..4011ef9 100644 --- a/go/process/state.go +++ b/go/process/state.go @@ -24,7 +24,7 @@ const stateTimeout = 30 * time.Second // State is one of this process's module's states, read and written on this connection (novox/hq ADR 0436 // clause 4), in the bucket _ (mesh-controller internal/broker/state.go:49) — only as the -// account's grants allow. It satisfies go/asker's Store. +// account's grants allow. It satisfies operator-channel's client's Store (mesh-contracts operatorchannelclient). type State struct { Module string Name string diff --git a/go/process/state_test.go b/go/process/state_test.go index 7c9cfb9..6950504 100644 --- a/go/process/state_test.go +++ b/go/process/state_test.go @@ -12,7 +12,6 @@ import ( "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/busretry" ) @@ -137,10 +136,18 @@ func stateOn(kv jetstream.KeyValue) State { return State{Module: "mesh-mail", Name: "sends", kv: func(context.Context) (jetstream.KeyValue, error) { return kv, nil }} } -// 1188/AC2: given the process connection's state type, when used as go/asker's Store, then it compiles and -// behaves as the interface says, against a fake KV. +// askerStore is the store operator-channel's client keeps its asks in (mesh-contracts operatorchannelclient.Store, +// whose own test holds process.State to it). +type askerStore interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) +} + +// 1188/AC2: given the process connection's state type, when used as operator-channel's client's Store, then it +// compiles and behaves as the interface says, against a fake KV. func TestStateSatisfiesAskerStore(t *testing.T) { - var _ asker.Store = State{} + var _ askerStore = State{} s := stateOn(newFakeKV()) if e, err := s.Get("ask.1"); err != nil || e != nil { diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 2f9e3b4..0c08efc 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -1,4 +1,3 @@ -asks/asks.go: Seat buscompose/health_grants.go: mesh.seat.mesh-self-check.event. catalogue/artifact_sweep_seat.go: ArtifactSweepSeat catalogue/artifacts.go: ContainerImageRegistrySeat