diff --git a/README.md b/README.md index 1788b82..df3ec0b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,15 @@ does not belong.* Per-module code (a service's API client, its tool implementati create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what turned the old SDK into constant maintenance and made every edit rebuild every module. +**A seat's code is not here** (novox/hq ADR 0625, ADR 0627). mesh-sdk is the runtime every module uses (the bus +connection, the tools bundle's runtime, state and its helpers) and a seat-neutral part two or more seats' clients or +holders share (sending a file in parts, `go/parts`). A seat defined whole has its generated contract, and its client +where callers share logic over it, in mesh-contracts (`go/seats/`, `go/clients/client`); the seat list is +mesh-contracts' `go/seats`, which `catalogue` alone imports. The value layouts of seats not yet defined whole stay +here until their domain moves, and no new seat-specific type is added. `packages_list_test.go` holds all of it: +mesh-sdk imports no contracts module but the seat set in `catalogue`, names none of the seats with a client outside +`catalogue`, and holds none of the packages that moved (`asks`, `asker`, `publish`). + ## What is in it Five small areas, each a thing a module's `tools/` or `provisioner/` imports: @@ -106,21 +115,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 480f212..0000000 --- a/go/asks/asks.go +++ /dev/null @@ -1,578 +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" -) - -// The seats (novox/hq ADR 0259 §3). -const ( - // Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by - // the asker. - Seat = "operator-channel" - // ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind. - ChannelSeat = "channel" - // IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry - // the kind. - IntakeSeat = "intake" -) - -// AskSubject is where an asker publishes an ask, CancelSubject its cancel, and DecidedSubject where it -// hears the warrant, or the ask's end without one. -func AskSubject(asker string) string { return "mesh.seat." + Seat + ".accept.ask." + asker } -func CancelSubject(asker string) string { return "mesh.seat." + Seat + ".accept.cancel." + asker } -func DecidedSubject(asker string) string { return "mesh.seat." + Seat + ".event.decided." + asker } - -// The work a channel takes, on ChannelSubject. -const ( - Show = "show" // a message offering answers - Edit = "edit" // a message shown before, replaced - Send = "send" // a message offering nothing -) - -// ChannelSubject is where the router sends a channel of a kind its work. -func ChannelSubject(verb, kind string) string { - return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind -} - -// What a channel says, on IntakeSubject. -const ( - Chosen = "choice" // a button tapped - Link = "link" // somebody asked to be linked as the operator -) - -// IntakeSubject is where a channel of a kind says what arrived. -func IntakeSubject(what, kind string) string { - return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind -} - -// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept. -const CodeProof = "code" - -// ProofSubject is where a channel of a kind asks a proof. -func ProofSubject(verb, kind string) string { - return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind -} - -// A Level is how much proof an option's answer needs (novox/hq ADR 0234 §8, the glossary's assurance level). -type Level string - -const ( - // Acknowledge performs only what any granted principal may already do: silencing, details. No proof. - Acknowledge Level = "acknowledge" - // Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code. - Approve Level = "approve" - // Destroy needs two proofs, one of them a code. - Destroy Level = "destroy" -) - -// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less. -func (l Level) Rank() int { - switch l { - case Acknowledge: - return 0 - case Approve: - return 1 - case Destroy: - return 2 - } - return 3 -} - -// Operator is the one role an ask may be answered by today. -const Operator = "operator" - -// Option is one answer an ask offers: a label for the button, what it does in plain words, its level. -type Option struct { - ID string `json:"id"` - Label string `json:"label"` - Does string `json:"does"` - Level Level `json:"level"` - // Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the - // machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant, - // which names the ask's digest, names it too: a warrant then authorises that act and no other, and an - // asker whose record of the act changed after it asked finds the digests differ and does nothing. - // Required on an option above acknowledge. - Binds string `json:"binds,omitempty"` -} - -// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each -// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and -// values alone, never over how a language happens to encode a struct. -type Act map[string]string - -// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical), -// written "sha256:". 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/buildstep/capacity.go b/go/buildstep/capacity.go deleted file mode 100644 index 098f657..0000000 --- a/go/buildstep/capacity.go +++ /dev/null @@ -1,7 +0,0 @@ -package buildstep - -// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how -// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names -// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config) -// lays the node's value into the module's settings file. -const CapacitySetting = "capacity" diff --git a/go/buildstep/doc.go b/go/buildstep/doc.go deleted file mode 100644 index 93c1feb..0000000 --- a/go/buildstep/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package buildstep is the contract of a build step every delivery part reads (novox/hq ADR 0593 §1): a step's id, the -// fetch request, a step a previous process left running and its end, a failure's words, a module tree's shape, the -// capacity setting and the launchers' names. The runner the build modules share is the Go build module's own package -// (mesh-catalog modules/mesh-build-go/buildstep), which imports this one. -package buildstep diff --git a/go/buildstep/failure.go b/go/buildstep/failure.go deleted file mode 100644 index bdea558..0000000 --- a/go/buildstep/failure.go +++ /dev/null @@ -1,141 +0,0 @@ -package buildstep - -import ( - "fmt" - "strings" - - "git.novox.be/novox/mesh-sdk/go/sdkversion" -) - -// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test -// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have -// always used; File is new and an older reader ignores it. -type Failure struct { - // Package is the Go package's import path, or the TypeScript package's directory in the workspace. - Package string `json:"package"` - // Test names the failing test, or the tests that were running when the program stopped; "" when none can be named. - Test string `json:"test,omitempty"` - // File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col). - File string `json:"file,omitempty"` - // Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first - // error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine). - Cause string `json:"cause,omitempty"` - // Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads - // only its first line. - Output string `json:"output,omitempty"` -} - -// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long -// assertion message and stays far under a bus message's or a verdict's bound. -const ( - OutputBytes = 4096 - LineBytes = 1024 -) - -// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides -// its cause ("the build module hid the failure ()"). -func By(module string) string { - return module + " with mesh-sdk " + sdkversion.Version() -} - -// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any -// indent), go test's "ok \t", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output. -func PassingLine(l string) bool { - if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0 - return true - } - t := strings.TrimSpace(l) - switch { - case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"): - return true - } - return tapOk(t) -} - -// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line. -// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract. -func Hidden(f Failure) string { - c := strings.TrimSpace(f.Cause) - switch { - case c == "": - return "it sent no cause" - case PassingLine(c): - return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c)) - } - return "" -} - -// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier -// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no -// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line. -// Passing lines are dropped. -func Clip(lines []string, cause string) string { - own := make([]string, 0, len(lines)) - for _, l := range lines { - if !PassingLine(l) { - own = append(own, l) - } - } - lines = own - size, from := 0, len(lines) - for from > 0 { - n := len(CutLine(lines[from-1])) + 1 - if size+n > OutputBytes && from < len(lines) { - break - } - size += n - from-- - } - kept := make([]string, 0, len(lines)-from+2) - head := strings.TrimSpace(cause) - for i := 0; head == "" && i < from; i++ { - head = strings.TrimSpace(lines[i]) - } - if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) { - kept = append(kept, CutLine(head)) - } - if from > 0 { - kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from)) - } - for _, l := range lines[from:] { - kept = append(kept, CutLine(l)) - } - return strings.Join(kept, "\n") -} - -// CutLine cuts a line to LineBytes, on a character boundary. -func CutLine(l string) string { - if len(l) <= LineBytes { - return l - } - return strings.ToValidUTF8(l[:LineBytes], "") + "..." -} - -// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the -// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a -// heading ("# ", npm's "> script"), and its output the command's lines without passing ones. -func ConsoleFailure(pkg, test, out string) Failure { - var lines []string - for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") { - if t := strings.TrimSpace(l); t != "" && !PassingLine(t) { - lines = append(lines, strings.TrimRight(l, " \t")) - } - } - cause := "" - for _, l := range lines { - if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) { - cause = t - break - } - } - for _, l := range lines { - if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") { - cause = t - } - } - if cause == "" { - cause = test + " failed and wrote nothing that says why" - } - cause = CutLine(cause) - return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)} -} diff --git a/go/buildstep/helpers.go b/go/buildstep/helpers.go deleted file mode 100644 index d9f0b98..0000000 --- a/go/buildstep/helpers.go +++ /dev/null @@ -1,19 +0,0 @@ -package buildstep - -import ( - "regexp" - "strings" -) - -// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error. -var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `) - -var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") - -func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") } - -// tapOk says a line is a passing test's "ok N - name", indented or not. -func tapOk(l string) bool { - rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ") - return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9' -} diff --git a/go/buildstep/launchers.go b/go/buildstep/launchers.go deleted file mode 100644 index 975f560..0000000 --- a/go/buildstep/launchers.go +++ /dev/null @@ -1,14 +0,0 @@ -package buildstep - -import "git.novox.be/novox/mesh-sdk/go/catalogue" - -// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as -// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says. -const LauncherSuffix = catalogue.LauncherSuffix - -// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root -// (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share -// (R4e, issue 1126), so the name a build writes is the name a reader expects. -func Launchers(language string, entrypoints []string) map[string]string { - return catalogue.Launchers(language, entrypoints) -} diff --git a/go/buildstep/launchers_test.go b/go/buildstep/launchers_test.go deleted file mode 100644 index ddea9a0..0000000 --- a/go/buildstep/launchers_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package buildstep - -import ( - "reflect" - "testing" -) - -// A TypeScript bundle's entrypoint x.js is started as x.serve.mjs beside it (novox/hq ADR 0193), the rule mesh-build-node -// and build-agent both apply; an entrypoint that is not .js gets none, and another language gets none. -func TestATypeScriptEntrypointIsStartedByTheLauncherBesideIt(t *testing.T) { - got := Launchers("typescript", []string{"index.js", "tools/x.js", "data.json"}) - want := map[string]string{"index.js": "index.serve.mjs", "tools/x.js": "tools/x.serve.mjs"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("Launchers(typescript) = %v, want %v", got, want) - } - if got := Launchers("go", []string{"index.js"}); len(got) != 0 { - t.Fatalf("a Go bundle got launchers: %v", got) - } - if got := Launchers("typescript", nil); len(got) != 0 { - t.Fatalf("a bundle with no entrypoint got launchers: %v", got) - } -} diff --git a/go/buildstep/request.go b/go/buildstep/request.go deleted file mode 100644 index 345ef00..0000000 --- a/go/buildstep/request.go +++ /dev/null @@ -1,163 +0,0 @@ -package buildstep - -import ( - "fmt" - "maps" - "path" - "regexp" - "strings" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -var ( - requestCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) - requestRepositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`) -) - -// FetchRequest is what a build module's fetch tool is asked: the repository and the full commit, the pull request's -// ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, and, for a build of a -// module whose every artifact names its paths, that module's directory and its named paths (novox/hq ADR 0499 clause 4). -// -// **The paths come from the builder, not from a manifest** (ADR 0495 clause 1; the lead's decision L8 of R4e): the -// builder fills Dir and Paths from the indexer's Module value (Path, and KeptInputs2.Paths' keys), and the build module -// gives the build and test steps a workspace holding those paths, the manifest and its lock files. A request without -// them builds from the module's directory, as before. -type FetchRequest struct { - Repository string `json:"repository"` - Commit string `json:"commit"` - Ref string `json:"ref,omitempty"` - Base string `json:"base,omitempty"` - Dir string `json:"dir,omitempty"` - Paths []string `json:"paths,omitempty"` - // Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages, - // novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version - // the repository names, checksum-verified, and names them in its log. - Packages delivery.PackageVersions `json:"packages,omitempty"` -} - -// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input. -func FetchRequestProperties() map[string]any { - return map[string]any{ - "dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"}, - "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"}, - "packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, - "description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"}, - } -} - -// Args is r as a tool call's arguments. -func (r FetchRequest) Args() map[string]any { - args := map[string]any{"repository": r.Repository, "commit": r.Commit} - for k, v := range map[string]string{"ref": r.Ref, "base": r.Base, "dir": r.Dir} { - if v != "" { - args[k] = v - } - } - if len(r.Paths) > 0 { - args["paths"] = append([]string(nil), r.Paths...) - } - if len(r.Packages) > 0 { - args["packages"] = map[string]any{} - for p, v := range r.Packages { - args["packages"].(map[string]any)[p] = v - } - } - return args -} - -// ParseFetchRequest reads a fetch tool's arguments; arguments it does not know (a build module's own) are left to the -// caller. It refuses a repository that is not /, a commit or a base that is not 40 hex digits, paths with no -// module directory or a directory with no path, a directory leaving the repository and a path leaving the directory, -// naming each. -func ParseFetchRequest(args map[string]any) (FetchRequest, error) { - var r FetchRequest - r.Repository, _ = args["repository"].(string) - r.Commit, _ = args["commit"].(string) - r.Ref, _ = args["ref"].(string) - r.Base, _ = args["base"].(string) - r.Dir, _ = args["dir"].(string) - switch v := args["paths"].(type) { - case nil: - case []string: - r.Paths = append([]string(nil), v...) - case []any: - for _, p := range v { - s, ok := p.(string) - if !ok { - return FetchRequest{}, fmt.Errorf("paths is a list of strings") - } - r.Paths = append(r.Paths, s) - } - default: - return FetchRequest{}, fmt.Errorf("paths is a list of strings") - } - packages, err := PackagesArg(args["packages"]) - if err != nil { - return FetchRequest{}, err - } - r.Packages = packages - return r, r.Check() -} - -// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives; -// nil for none. -func PackagesArg(v any) (delivery.PackageVersions, error) { - out := delivery.PackageVersions{} - switch v := v.(type) { - case nil: - return nil, nil - case delivery.PackageVersions: - maps.Copy(out, v) - case map[string]string: - maps.Copy(out, v) - case map[string]any: - for p, version := range v { - s, ok := version.(string) - if !ok { - return nil, fmt.Errorf("packages is an object of versions (strings), by package") - } - out[p] = s - } - default: - return nil, fmt.Errorf("packages is an object of versions (strings), by package") - } - if len(out) == 0 { - return nil, nil - } - return out, out.Check() -} - -// Check is what ParseFetchRequest refuses. -func (r FetchRequest) Check() error { - if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { - return fmt.Errorf("%q is not a repository, /", r.Repository) - } - if !requestCommitOK.MatchString(r.Commit) { - return fmt.Errorf("%q is not a full commit (40 hex digits)", r.Commit) - } - if r.Base != "" && !requestCommitOK.MatchString(r.Base) { - return fmt.Errorf("%q is not a trunk commit (40 hex digits)", r.Base) - } - switch { - case len(r.Paths) > 0 && r.Dir == "": - return fmt.Errorf("the request names paths and names no module directory (dir) they are relative to") - case len(r.Paths) == 0 && r.Dir != "": - return fmt.Errorf("the request names a module directory and no path") - } - if r.Dir != "" && (outside(r.Dir) || strings.HasPrefix(r.Dir, "/")) { - return fmt.Errorf("the module directory %q leaves the repository", r.Dir) - } - for _, p := range r.Paths { - if p == "" || strings.HasPrefix(p, "/") || outside(p) || path.Clean(p) == "." { - return fmt.Errorf("the path %q leaves the module's directory or is not a path in it", p) - } - } - return nil -} - -func outside(p string) bool { - c := path.Clean(p) - return c == ".." || strings.HasPrefix(c, "../") -} diff --git a/go/buildstep/request_test.go b/go/buildstep/request_test.go deleted file mode 100644 index 69102f7..0000000 --- a/go/buildstep/request_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package buildstep - -import ( - "reflect" - "strings" - "testing" -) - -// A build's fetch is handed the paths of a module that names them (novox/hq ADR 0499 clause 4): the builder fills them -// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495 -// clause 1). The request survives the trip through a tool call's arguments. -func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { - // And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4). - req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head", - Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}} - args := req.Args() - // A tool call's arguments arrive as JSON: lists of any, objects of any. - args["paths"] = []any{"package.json", "src"} - if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) { - t.Fatalf("the packages are not in the call's arguments: %v", args["packages"]) - } - got, err := ParseFetchRequest(args) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, req) { - t.Fatalf("got %+v, want %+v", got, req) - } - plain, err := ParseFetchRequest(map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40), - "base": strings.Repeat("c", 40), "beside": []any{}}) - if err != nil || plain.Paths != nil || plain.Base != strings.Repeat("c", 40) { - t.Fatalf("a request without paths: %+v, %v", plain, err) - } - - ok := map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40)} - for _, c := range []struct { - set map[string]any - says string - }{ - {map[string]any{"repository": "novox"}, "is not a repository"}, - {map[string]any{"repository": "novox/../x"}, "is not a repository"}, - {map[string]any{"commit": "abc"}, "is not a full commit"}, - {map[string]any{"base": "abc"}, "is not a trunk commit"}, - {map[string]any{"paths": []any{"src"}}, "names no module directory"}, - {map[string]any{"dir": "modules/a", "paths": []any{"../b"}}, "leaves the module's directory"}, - {map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"}, - {map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"}, - {map[string]any{"dir": "modules/a"}, "names a module directory and no path"}, - {map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"}, - {map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"}, - {map[string]any{"packages": []any{"x"}}, "packages is an object"}, - } { - args := map[string]any{} - for k, v := range ok { - args[k] = v - } - for k, v := range c.set { - args[k] = v - } - if _, err := ParseFetchRequest(args); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%v: %v, wanted a refusal saying %q", c.set, err, c.says) - } - } -} diff --git a/go/buildstep/stale.go b/go/buildstep/stale.go deleted file mode 100644 index 674b36d..0000000 --- a/go/buildstep/stale.go +++ /dev/null @@ -1,336 +0,0 @@ -package buildstep - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "strconv" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// RestartReason is why a step a previous process left running is ended. -const RestartReason = "the build module restarted while it ran" - -// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken -// for this process's, which may still run it, so EndOne refuses it. -var Boot = time.Now() - -// Owner is this process as a step's owner (delivery.Step.Owner): `..`, the node's boot id -// (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart -// that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's. -var Owner = ownerOf(bootID, os.Getpid(), Boot) - -// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once. -var bootID = readBootID() - -const noBootID = "no-boot-id" - -func ownerOf(bootID string, pid int, start time.Time) string { - return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36) -} - -// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes -// apart). -func readBootID() string { - b, err := os.ReadFile("/proc/sys/kernel/random/boot_id") - if id := strings.TrimSpace(string(b)); err == nil && id != "" { - return id - } - return noBootID -} - -// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for -// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key -// still at the revision read; both fail with stdio.ErrChanged when another writer was first. -type Scan struct { - Keys func() ([]string, error) - Get func(key string) (json.RawMessage, uint64, error) - Update func(key string, value any, revision uint64) (uint64, error) - Delete func(key string, revision uint64) error -} - -// ScanOf is the Scan of a state the process keeps. -// -// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's -// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is -// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves -// the steps state until its run writes its end, which its asker still reads (a watch takes puts only). -func ScanOf(s stdio.KeptState) Scan { - get := func(key string) (json.RawMessage, uint64, error) { - e, err := s.Get(key) - if err != nil || e == nil { - return nil, 0, err - } - return e.Value, e.Revision, nil - } - return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error { - _, rev, err := get(key) - if err != nil { - return err - } - if rev != revision { - return stdio.ErrChanged - } - return s.Delete(key) - }} -} - -// isAsk says a step is a pulled ask's: its key is . and its tool is " ask" (a hand-started step -// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was -// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key. -// A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound -// has passed (delivery.StepBound), when it is nobody's. -func isAsk(key string, s delivery.Step) bool { - _, _, err := delivery.ParseAskStepKey(key) - return err == nil && strings.HasSuffix(s.Tool, " ask") -} - -// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and -// fingerprint. -func AskWhat(a delivery.StepAsk) string { - short := func(s string) string { - if len(s) > 12 { - return s[:12] - } - return s - } - str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v } - parts := []string{a.Kind + " ask"} - switch { - case a.Check != nil: - parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ",")) - if a.Check.Run != "" { - parts = append(parts, "run "+a.Check.Run) - } - case a.Build != nil: - parts = append(parts, "module "+a.Build.Module) - if r := str(a.Build.Fetch, "repository"); r != "" { - parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit"))) - } - if a.Build.FP != "" { - parts = append(parts, "fp "+short(a.Build.FP)) - } - case a.Compose != nil: - parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit"))) - case a.Run != nil: - parts = append(parts, "run "+a.Run.Program.Package, a.Run.Program.Repository+"@"+short(a.Run.Program.Commit)) - if len(a.Run.Args) > 0 { - parts = append(parts, strings.Join(a.Run.Args, " ")) - } - parts = append(parts, fmt.Sprintf("%d heads", len(a.Run.Heads))) - } - return strings.Join(parts, ", ") -} - -// endRunning writes step ended failed with why, only while its key is still at revision. -func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error { - step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil - raw, err := delivery.EncodeStep(step) - if err != nil { - return err - } - _, err = scan.Update(key, json.RawMessage(raw), revision) - return err -} - -// EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys -// of node are read; a value that is no step is left alone. -// - A hand-started step with an owner (delivery.Step.Owner) that is not this process (Owner) is ended failed with -// RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would -// write its end (issue 1890, note 21). -// - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the -// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its -// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its -// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running -// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it -// held one of a build node's two slots). -// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it. -// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before -// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its -// start is older than delivery.StepBound, and then ended "past the step bound: its asker stopped waiting at