From ba4220ad7dc0304138a6f1c3d6cec8823c3be2bd Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 02:26:44 +0100 Subject: [PATCH 001/144] Say that the vault's reading of a plan follows a node's rule and is never it (issue 1966) Comments only: AssignmentHands and Rendering.Assigned said a node composing itself hands by the vault's word, which kept a node with no plan from ever making one. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/declaration.go | 4 ++-- go/catalogue/handed.go | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index 0da1418..14878e8 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -273,8 +273,8 @@ type Rendering struct { Hands bool Provisioner string // Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the - // vault answered the node composing itself; where it names a module, Handing follows it alone (issue 774). Nil, or a - // module it does not name, is judged by the manifest and the machine, as the composer of the plans does. + // vault answered the node composing itself. Never the rule: the node hands by the manifest, its build and the machine, + // and reads this only to say where the vault disagrees (issue 1966). Nil where the vault said nothing. Assigned map[string]bool } diff --git a/go/catalogue/handed.go b/go/catalogue/handed.go index 2fba9d0..e2853b4 100644 --- a/go/catalogue/handed.go +++ b/go/catalogue/handed.go @@ -233,8 +233,10 @@ func handedBy(m Manifest, archive Built) *Handed { // AssignmentHands is whether a module's value in the seat mesh-provisioner's plans (the assignments mirror's shape, // `{"provisioning": }`) hands its module to the provisioner: Plan.Hands of the plan it carries, false with no -// plan. A value that does not read is an error, never "not handed". The one rule the vault judges `own` by and a node -// composing itself follows (Rendering.Assigned, issue 774). +// plan. A value that does not read is an error, never "not handed". What the vault judges `own` by: the plan is what the +// node's last composition made, so it follows that node's rule (the manifest, its build and the machine) one +// composition later. Never the rule a node composing itself hands by: with no plan yet it would never make one (issue +// 1966); the node reads it only to say where the two disagree (Rendering.Assigned). func AssignmentHands(raw []byte) (bool, error) { var a struct { Provisioning *Plan `json:"provisioning"` -- 2.54.0 From 7d05a39c0c184a23b8ce631859ee7fd3949d83f5 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:41:29 +0100 Subject: [PATCH 002/144] =?UTF-8?q?SD-M1:=20one=20reader=20of=20a=20seat's?= =?UTF-8?q?=20definition=20for=20every=20part=20(ADR=200608=20=C2=A71,=20d?= =?UTF-8?q?esign=2026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indexer keeps the definitions and the build modules generate from them; both read them through go/seatdef, which refuses a missing or unknown field by name, a later format and a definition over 256 KiB. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatdef/seatdef.go | 560 +++++++++++++++++++++++++++++++++++++ go/seatdef/seatdef_test.go | 104 +++++++ 2 files changed, 664 insertions(+) create mode 100644 go/seatdef/seatdef.go create mode 100644 go/seatdef/seatdef_test.go diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go new file mode 100644 index 0000000..c915c93 --- /dev/null +++ b/go/seatdef/seatdef.go @@ -0,0 +1,560 @@ +// Package seatdef reads a seat's definition (novox/hq ADR 0608 §1, design 26 *A seat's definition*): one +// language-neutral file per seat, `seats//seat.json` in a contracts module, with the parts it includes from +// `parts/.json` and its conformance fixtures in `seats//fixtures/`. The format's own JSON Schema is +// checked in beside the definitions (mesh-contracts `format/seat-definition.schema.json`); this package is the one +// reader every part shares: the indexer, which keeps the definitions on the bus, and the build modules' generators, +// which write the Go and TypeScript types from them. It generates nothing and holds no seat's contract. +// +// **A definition is read strictly.** A key the format does not know is refused, naming it, since a misspelt key would +// describe a contract nobody serves; a newer format is a new number, which an older reader refuses by name. The +// messages a definition describes are read leniently by their readers (ADR 0608 §6): that is the generated code's +// rule, not this one's. +package seatdef + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +// MaxSize is the largest definition, or part, the mesh keeps (design 26: at most 256 KiB a definition). +const MaxSize = 256 << 10 + +// Format is the one format number this reader knows. +const Format = 1 + +// The two grains a definition is written at (ADR 0613 §1, §2; ADR 0620 §1): whole, with every verb's messages, its +// events and their bodies, its records and its provisions' forms; or the seat list's, holding what mesh-sdk's +// compiled seat list held, until its area's contract next changes. +const ( + Whole = "whole" + SeatList = "seat-list" +) + +// The two scopes a seat is held at. +const ( + ScopeMesh = "mesh" + ScopeNode = "node" +) + +// Definition is one seat's contract. +type Definition struct { + Format int `json:"format"` + Seat string `json:"seat"` + Scope string `json:"scope"` + Grain string `json:"grain"` + // Decision is the record that made it a seat. + Decision string `json:"decision"` + // Description says the role in one paragraph, for a person reading the definition. + Description string `json:"description,omitempty"` + // HolderDeclares says the seat is not in the mesh's compiled seat list: its holder's manifest declares it as a + // seat of its own (ADR 0118), and the definition is its contract all the same (design 26: operator-channel). + HolderDeclares bool `json:"holder-declares,omitempty"` + // Delivers is the provision the seat's holder answers for, or empty. + Delivers string `json:"delivers,omitempty"` + // Replicated, PulledByKind, Yields and HeldByTheEngine are the seat list's marks of the same names. + Replicated bool `json:"replicated,omitempty"` + PulledByKind bool `json:"pulled-by-kind,omitempty"` + Yields bool `json:"yields,omitempty"` + HeldByTheEngine bool `json:"held-by-the-engine,omitempty"` + // Includes names the parts whose verbs, events and types this definition takes whole, written once so the seats + // sharing them cannot drift (design 26: the four registry seats' common part). + Includes []string `json:"includes,omitempty"` + Verbs []Verb `json:"verbs,omitempty"` + // Accepts are what may be sent to the seat's holder on `mesh.seat..accept.`. + Accepts []Message `json:"accepts,omitempty"` + // Events are what is published on the seat's own subject, `mesh.seat..event.`. + Events []Message `json:"events,omitempty"` + // Family is where the seat's events are kept, when the definition fixes it. + Family *Family `json:"family,omitempty"` + // HolderEvents are the holder's own events that belong with the seat, listed beside it and never moved onto + // its subject (ADR 0620 §2): they keep the holder's subjects. + HolderEvents []HolderEvent `json:"holder-events,omitempty"` + // Records are the states and records the holder keeps for the seat. + Records []Record `json:"records,omitempty"` + // Provisions are the forms of the provisions the seat's holder answers. + Provisions []Provision `json:"provisions,omitempty"` + // Receives and Paths are the seat list's contributions and owned paths. + Receives []Receivable `json:"receives,omitempty"` + Paths []Path `json:"paths,omitempty"` + // Defs are named types a schema refers to as `{"$ref": "#/defs/"}`. + Defs map[string]map[string]any `json:"defs,omitempty"` +} + +// Verb is one verb the seat serves: its input and, written whole, its answer, each a JSON Schema. +type Verb struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + // Optional marks a verb a holder may not serve yet and still hold the seat (design 33 §7). + Optional bool `json:"optional,omitempty"` + // Replaces are the shell commands this verb is the mesh's way to do (ADR 0245). + Replaces []string `json:"replaces,omitempty"` + Input map[string]any `json:"input,omitempty"` + Answer map[string]any `json:"answer,omitempty"` + // Callers says who may call it, in words: the list itself is the holder's trusted config value (design 51). + Callers string `json:"callers,omitempty"` +} + +// Message is one accepted message or one event of the seat, its body a JSON Schema. +type Message struct { + Name string `json:"name"` + Body map[string]any `json:"body,omitempty"` + // ByCaller says the subject ends in the name of who sent it (or, for an event, of who it is for). + ByCaller bool `json:"by-caller,omitempty"` +} + +// HolderEvent is one of the holder's own events listed beside the seat. +type HolderEvent struct { + Name string `json:"name"` + // Subject is where it is published, with `` for the holding module's name. + Subject string `json:"subject"` + // Family is the stream or family it is kept in, when it is not the shared module events stream. + Family string `json:"family,omitempty"` + Body map[string]any `json:"body,omitempty"` +} + +// Family is where a seat's events are kept. +type Family struct { + Name string `json:"name"` + MaxAgeDays int `json:"max-age-days"` + MaxMiB int `json:"max-mib,omitempty"` +} + +// Record is one state the holder keeps for the seat: its key's form and its value's schema. +type Record struct { + Name string `json:"name"` + Key string `json:"key,omitempty"` + Value map[string]any `json:"value,omitempty"` +} + +// Provision is the form of a provision the seat's holder answers. +type Provision struct { + Name string `json:"name"` + Form map[string]any `json:"form"` +} + +// Receivable is one kind of contribution the seat receives (ADR 0212), as the seat list has it. +type Receivable struct { + Kind string `json:"kind"` + Comment string `json:"comment,omitempty"` + Dirs bool `json:"dirs,omitempty"` + Offered bool `json:"offered,omitempty"` + Shape *Shape `json:"shape,omitempty"` +} + +// Shape is a contribution kind given as data (ADR 0255). Its checks are code, the runtime's, never the definition's. +type Shape struct { + Places []PlaceField `json:"places,omitempty"` + Order string `json:"order,omitempty"` + Examples []map[string]any `json:"examples,omitempty"` + Text bool `json:"text,omitempty"` +} + +// PlaceField is one field a holder places by, and the values it takes. +type PlaceField struct { + Field string `json:"field"` + Values []string `json:"values,omitempty"` +} + +// Path is one path on its machine the seat's holder owns (ADR 0287). +type Path struct { + Path string `json:"path"` + StepsBackFor string `json:"steps-back-for,omitempty"` +} + +// Part is verbs, events and types several definitions include whole. +type Part struct { + Format int `json:"format"` + Part string `json:"part"` + Description string `json:"description,omitempty"` + Verbs []Verb `json:"verbs,omitempty"` + Events []Message `json:"events,omitempty"` + Defs map[string]map[string]any `json:"defs,omitempty"` +} + +// Fixture is one conformance case of a definition (design 26: data, never functions): a message as given, the +// subject it travels on, and, when it differs from what was given, what a reader must read back. +type Fixture struct { + Name string `json:"name"` + // Of is what the fixture is an instance of: `verb:.input`, `verb:.answer`, `accept:`, + // `event:` or `holder-event:`. + Of string `json:"of"` + Subject string `json:"subject,omitempty"` + Given any `json:"given"` + // Wire is what a reader reads back when it is not Given: a field it does not know dropped (ADR 0608 §6). + Wire any `json:"wire,omitempty"` + Why string `json:"why,omitempty"` +} + +// Problem is one thing wrong with a definition, naming where. +type Problem struct { + Where string + What string +} + +func (p Problem) Error() string { return p.Where + ": " + p.What } + +// Problems is every problem a definition has, in the order found. +type Problems []Problem + +func (ps Problems) Error() string { + out := make([]string, len(ps)) + for i, p := range ps { + out[i] = p.Error() + } + return strings.Join(out, "; ") +} + +var ( + seatName = regexp.MustCompile(`^[a-z][a-z0-9-]*[a-z0-9]$`) + wordName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + // eventName is a holder's event: its own words, dot-separated as a module's event keys are (preview.written). + eventName = regexp.MustCompile(`^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$`) +) + +// ErrTooLarge is a definition or part over MaxSize. +var ErrTooLarge = errors.New("larger than 256 KiB, the most a seat's definition may be (design 26)") + +// Parse reads one seat's definition and checks it, naming every field that is missing or wrong. +func Parse(raw []byte) (Definition, error) { + var d Definition + if err := strict(raw, &d); err != nil { + return Definition{}, err + } + if ps := d.check(); len(ps) > 0 { + return Definition{}, ps + } + return d, nil +} + +// ParsePart reads one part. +func ParsePart(raw []byte) (Part, error) { + var p Part + if err := strict(raw, &p); err != nil { + return Part{}, err + } + var ps Problems + if p.Format != Format { + ps = append(ps, Problem{"format", formatProblem(p.Format)}) + } + if !wordName.MatchString(p.Part) { + ps = append(ps, Problem{"part", "missing, or not a lower-case name"}) + } + ps = append(ps, checkVerbs(p.Verbs, true)...) + ps = append(ps, checkMessages("events", p.Events, true)...) + if len(ps) > 0 { + return Part{}, ps + } + return p, nil +} + +// ParseFixtures reads a fixtures file: a list of cases. +func ParseFixtures(raw []byte) ([]Fixture, error) { + var fs []Fixture + if err := strict(raw, &fs); err != nil { + return nil, err + } + var ps Problems + for i, f := range fs { + where := fmt.Sprintf("fixtures[%d]", i) + if f.Name == "" { + ps = append(ps, Problem{where + ".name", "missing"}) + } + if _, _, ok := f.Instance(); !ok { + ps = append(ps, Problem{where + ".of", fmt.Sprintf("%q is none of verb:.input, verb:.answer, "+ + "accept:, event:, holder-event:", f.Of)}) + } + if f.Given == nil { + ps = append(ps, Problem{where + ".given", "missing"}) + } + } + if len(ps) > 0 { + return nil, ps + } + return fs, nil +} + +// Instance splits a fixture's Of into its kind (verb-input, verb-answer, accept, event, holder-event) and name. +func (f Fixture) Instance() (kind, name string, ok bool) { + k, rest, found := strings.Cut(f.Of, ":") + if !found || rest == "" { + return "", "", false + } + switch k { + case "accept", "event", "holder-event": + return k, rest, true + case "verb": + if n, ok := strings.CutSuffix(rest, ".input"); ok && n != "" { + return "verb-input", n, true + } + if n, ok := strings.CutSuffix(rest, ".answer"); ok && n != "" { + return "verb-answer", n, true + } + } + return "", "", false +} + +func strict(raw []byte, into any) error { + if len(raw) > MaxSize { + return ErrTooLarge + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(into); err != nil { + if f, ok := strings.CutPrefix(err.Error(), "json: unknown field "); ok { + return Problem{strings.Trim(f, `"`), "a field the format does not know"} + } + return fmt.Errorf("not a seat definition's JSON: %w", err) + } + if dec.More() { + return errors.New("not a seat definition's JSON: more than one value") + } + return nil +} + +func formatProblem(n int) string { + if n == 0 { + return "missing" + } + return fmt.Sprintf("format %d, and this reader knows format %d only", n, Format) +} + +func (d Definition) check() Problems { + var ps Problems + if d.Format != Format { + ps = append(ps, Problem{"format", formatProblem(d.Format)}) + } + if d.Seat == "" { + ps = append(ps, Problem{"seat", "missing"}) + } else if !seatName.MatchString(d.Seat) { + ps = append(ps, Problem{"seat", fmt.Sprintf("%q is not a lower-case name", d.Seat)}) + } + switch d.Scope { + case ScopeMesh, ScopeNode: + case "": + ps = append(ps, Problem{"scope", "missing"}) + default: + ps = append(ps, Problem{"scope", fmt.Sprintf("%q is neither mesh nor node", d.Scope)}) + } + switch d.Grain { + case Whole, SeatList: + case "": + ps = append(ps, Problem{"grain", "missing"}) + default: + ps = append(ps, Problem{"grain", fmt.Sprintf("%q is neither whole nor seat-list", d.Grain)}) + } + if d.Decision == "" { + ps = append(ps, Problem{"decision", "missing"}) + } + whole := d.Grain == Whole + if !whole && (len(d.Includes) > 0 || d.Family != nil || len(d.HolderEvents) > 0 || len(d.Records) > 0 || + len(d.Provisions) > 0 || len(d.Defs) > 0) { + ps = append(ps, Problem{"grain", "seat-list holds the seat list's fields only; includes, family, " + + "holder-events, records, provisions and defs are a whole definition's"}) + } + if d.HolderDeclares && !whole { + ps = append(ps, Problem{"holder-declares", "only a whole definition describes a seat its holder declares"}) + } + for i, inc := range d.Includes { + if !wordName.MatchString(inc) { + ps = append(ps, Problem{fmt.Sprintf("includes[%d]", i), fmt.Sprintf("%q is not a part's name", inc)}) + } + } + ps = append(ps, checkVerbs(d.Verbs, whole)...) + ps = append(ps, checkMessages("accepts", d.Accepts, whole)...) + ps = append(ps, checkMessages("events", d.Events, whole)...) + if whole && len(d.Events) > 0 && d.Family == nil { + ps = append(ps, Problem{"family", "missing: a whole definition with events says where they are kept"}) + } + if d.Family != nil { + if d.Family.Name == "" { + ps = append(ps, Problem{"family.name", "missing"}) + } + if d.Family.MaxAgeDays < 1 { + ps = append(ps, Problem{"family.max-age-days", "missing, or under one day"}) + } + } + for i, e := range d.HolderEvents { + where := fmt.Sprintf("holder-events[%d]", i) + if !eventName.MatchString(e.Name) { + ps = append(ps, Problem{where + ".name", "missing, or not lower-case words joined by dots"}) + } + if e.Subject == "" { + ps = append(ps, Problem{where + ".subject", "missing"}) + } + } + for i, r := range d.Records { + if !wordName.MatchString(r.Name) { + ps = append(ps, Problem{fmt.Sprintf("records[%d].name", i), "missing, or not a lower-case name"}) + } + } + for i, p := range d.Provisions { + where := fmt.Sprintf("provisions[%d]", i) + if p.Name == "" { + ps = append(ps, Problem{where + ".name", "missing"}) + } + if p.Form == nil { + ps = append(ps, Problem{where + ".form", "missing"}) + } + } + for i, r := range d.Receives { + if r.Kind == "" { + ps = append(ps, Problem{fmt.Sprintf("receives[%d].kind", i), "missing"}) + } + } + for i, p := range d.Paths { + if !strings.HasPrefix(p.Path, "/") { + ps = append(ps, Problem{fmt.Sprintf("paths[%d].path", i), "missing, or not absolute"}) + } + } + return ps +} + +func checkVerbs(vs []Verb, whole bool) Problems { + var ps Problems + seen := map[string]bool{} + for i, v := range vs { + where := fmt.Sprintf("verbs[%d]", i) + if !wordName.MatchString(v.Name) { + ps = append(ps, Problem{where + ".name", "missing, or not a lower-case name"}) + } else if seen[v.Name] { + ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", v.Name)}) + } + seen[v.Name] = true + if whole && v.Input == nil { + ps = append(ps, Problem{where + ".input", "missing: a whole definition gives every verb's input"}) + } + if whole && v.Answer == nil { + ps = append(ps, Problem{where + ".answer", "missing: a whole definition gives every verb's answer"}) + } + } + return ps +} + +func checkMessages(field string, ms []Message, whole bool) Problems { + var ps Problems + seen := map[string]bool{} + for i, m := range ms { + where := fmt.Sprintf("%s[%d]", field, i) + if !wordName.MatchString(m.Name) { + ps = append(ps, Problem{where + ".name", "missing, or not a lower-case name"}) + } else if seen[m.Name] { + ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", m.Name)}) + } + seen[m.Name] = true + if whole && m.Body == nil { + ps = append(ps, Problem{where + ".body", "missing: a whole definition gives every message's body"}) + } + } + return ps +} + +// Resolve is a definition with every part it includes taken in: the part's verbs and events first, in the order +// the definition names the parts, then its own; and the part's types beside its own. A part named that is not given, +// or a name the part and the definition both define, is refused. +func Resolve(d Definition, parts map[string]Part) (Definition, error) { + if len(d.Includes) == 0 { + return d, nil + } + out := d + out.Includes = nil + out.Verbs, out.Events = nil, nil + out.Defs = map[string]map[string]any{} + var ps Problems + for _, name := range d.Includes { + p, ok := parts[name] + if !ok { + ps = append(ps, Problem{"includes", fmt.Sprintf("part %q is not given", name)}) + continue + } + out.Verbs = append(out.Verbs, p.Verbs...) + out.Events = append(out.Events, p.Events...) + for k, v := range p.Defs { + out.Defs[k] = v + } + } + out.Verbs = append(out.Verbs, d.Verbs...) + out.Events = append(out.Events, d.Events...) + for k, v := range d.Defs { + if _, twice := out.Defs[k]; twice { + ps = append(ps, Problem{"defs." + k, "defined by a part it includes too"}) + } + out.Defs[k] = v + } + if len(out.Defs) == 0 { + out.Defs = nil + } + ps = append(ps, checkVerbs(out.Verbs, d.Grain == Whole)...) + ps = append(ps, checkMessages("events", out.Events, d.Grain == Whole)...) + if len(ps) > 0 { + return Definition{}, ps + } + return out, nil +} + +// EventSubject is where the seat's event is published; by a caller, the caller's name follows. +func EventSubject(seat, event string) string { return "mesh.seat." + seat + ".event." + event } + +// AcceptSubject is where a message the seat accepts is sent. +func AcceptSubject(seat, accept string) string { return "mesh.seat." + seat + ".accept." + accept } + +// Index is the reading order of a contracts module's seats, `seats/index.json`: the seat list is read in it. +type Index struct { + Format int `json:"format"` + Seats []string `json:"seats"` +} + +// ParseIndex reads the index, refusing a seat named twice. +func ParseIndex(raw []byte) (Index, error) { + var ix Index + if err := strict(raw, &ix); err != nil { + return Index{}, err + } + var ps Problems + if ix.Format != Format { + ps = append(ps, Problem{"format", formatProblem(ix.Format)}) + } + seen := map[string]bool{} + for i, s := range ix.Seats { + if seen[s] { + ps = append(ps, Problem{fmt.Sprintf("seats[%d]", i), fmt.Sprintf("%q twice", s)}) + } + seen[s] = true + } + if len(ps) > 0 { + return Index{}, ps + } + return ix, nil +} + +// Names are the verbs', or messages', names in order. +func VerbNames(vs []Verb) []string { + out := make([]string, len(vs)) + for i, v := range vs { + out[i] = v.Name + } + return out +} + +// MessageNames are the messages' names in order. +func MessageNames(ms []Message) []string { + out := make([]string, len(ms)) + for i, m := range ms { + out[i] = m.Name + } + return out +} + +// DefNames are a definition's named types, sorted. +func (d Definition) DefNames() []string { + out := make([]string, 0, len(d.Defs)) + for k := range d.Defs { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go new file mode 100644 index 0000000..e1ca81e --- /dev/null +++ b/go/seatdef/seatdef_test.go @@ -0,0 +1,104 @@ +package seatdef + +import ( + "errors" + "strings" + "testing" +) + +// TestADefinitionIsReadStrictlyNamingWhatIsWrong is the format's own table (design 26: a valid definition parses; +// each missing required field is refused naming it; one over 256 KiB is refused), with the parts and fixtures beside. +func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { + const listGrain = `{"format":1,"seat":"node-hostname","scope":"node","grain":"seat-list","decision":"ADR 1", + "verbs":[{"name":"add","input":{"type":"object"}}]}` + const whole = `{"format":1,"seat":"mesh-broker","scope":"mesh","grain":"whole","decision":"ADR 1", + "verbs":[{"name":"list","input":{"type":"object"},"answer":{"type":"object"}}], + "events":[{"name":"created","body":{"type":"object"}}],"family":{"name":"broker.events","max-age-days":30}}` + cases := []struct { + name, raw string + refused string // a text the refusal names, empty when it parses + }{ + {"the seat list's grain parses", listGrain, ""}, + {"a whole definition parses", whole, ""}, + {"no format", `{"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x"}`, "format: missing"}, + {"a later format", `{"format":2,"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x"}`, "format 2"}, + {"no seat", `{"format":1,"scope":"mesh","grain":"seat-list","decision":"x"}`, "seat: missing"}, + {"no scope", `{"format":1,"seat":"a-b","grain":"seat-list","decision":"x"}`, "scope: missing"}, + {"another scope", `{"format":1,"seat":"a-b","scope":"room","grain":"seat-list","decision":"x"}`, "scope"}, + {"no grain", `{"format":1,"seat":"a-b","scope":"mesh","decision":"x"}`, "grain: missing"}, + {"no decision", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list"}`, "decision: missing"}, + {"an unknown field", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x","sevres":[]}`, + "sevres: a field the format does not know"}, + {"a whole verb with no answer", strings.Replace(whole, `,"answer":{"type":"object"}`, "", 1), + "verbs[0].answer: missing"}, + {"a whole verb with no input", strings.Replace(whole, `"input":{"type":"object"},`, "", 1), + "verbs[0].input: missing"}, + {"a whole event with no body", strings.Replace(whole, `"name":"created","body":{"type":"object"}`, + `"name":"created"`, 1), "events[0].body: missing"}, + {"whole events kept nowhere", strings.Replace(whole, `,"family":{"name":"broker.events","max-age-days":30}`, + "", 1), "family: missing"}, + {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, + {"records at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, + `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, + {"over 256 KiB", listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) + `"}`, + ErrTooLarge.Error()}, + } + for _, c := range cases { + _, err := Parse([]byte(c.raw)) + switch { + case c.refused == "" && err != nil: + t.Errorf("%s: refused: %v", c.name, err) + case c.refused != "" && err == nil: + t.Errorf("%s: parsed, want refused naming %q", c.name, c.refused) + case c.refused != "" && !strings.Contains(err.Error(), c.refused): + t.Errorf("%s: refused with %q, want it to name %q", c.name, err, c.refused) + } + } + if _, err := Parse([]byte(listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) + + `"}`)); !errors.Is(err, ErrTooLarge) { + t.Errorf("over 256 KiB is ErrTooLarge, got %v", err) + } + + // A part is taken in whole, its verbs before the definition's own; a part not given, or a type defined twice, + // is refused. + part, err := ParsePart([]byte(`{"format":1,"part":"registry","verbs":[{"name":"publish","input":{"type":"object"}, + "answer":{"type":"object"}}],"events":[{"name":"published","body":{"type":"object"}}], + "defs":{"Ref":{"type":"string"}}}`)) + if err != nil { + t.Fatal(err) + } + d, err := Parse([]byte(strings.Replace(whole, `"decision":"ADR 1",`, `"decision":"ADR 1","includes":["registry"],`, 1))) + if err != nil { + t.Fatal(err) + } + got, err := Resolve(d, map[string]Part{"registry": part}) + if err != nil { + t.Fatal(err) + } + if v := VerbNames(got.Verbs); strings.Join(v, ",") != "publish,list" { + t.Errorf("resolved verbs %v, want the part's first", v) + } + if m := MessageNames(got.Events); strings.Join(m, ",") != "published,created" || got.Defs["Ref"] == nil { + t.Errorf("resolved events %v, defs %v", m, got.Defs) + } + if _, err := Resolve(d, nil); err == nil || !strings.Contains(err.Error(), `part "registry" is not given`) { + t.Errorf("a part not given: %v", err) + } + d.Defs = map[string]map[string]any{"Ref": {"type": "string"}} + if _, err := Resolve(d, map[string]Part{"registry": part}); err == nil || !strings.Contains(err.Error(), "defs.Ref") { + t.Errorf("a type defined twice: %v", err) + } + + // Fixtures name what they are an instance of. + if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list.input","given":{}},{"name":"b","of":"event:created", + "subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil { + t.Errorf("fixtures: %v", err) + } + if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list","given":{}}]`)); err == nil || + !strings.Contains(err.Error(), "fixtures[0].of") { + t.Errorf("a fixture of nothing known: %v", err) + } + if _, err := ParseIndex([]byte(`{"format":1,"seats":["a-b","a-b"]}`)); err == nil { + t.Error("an index naming a seat twice parsed") + } +} -- 2.54.0 From 6c99075ab998891c5b0842519eaaf34ad743b091 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:52:09 +0100 Subject: [PATCH 003/144] ENG-10 sdk: a routed image says the seat it came through and that seat's holder (design 51) declaration.Container gains ImageSeat and ImageHolder, catalogue.Rendering SeatHolder, for the node-engine to retry a failed pull on the holder's settled (A7's request R1). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/declaration.go | 5 +++++ go/declaration/declaration.go | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/go/catalogue/declaration.go b/go/catalogue/declaration.go index 14878e8..5bb710b 100644 --- a/go/catalogue/declaration.go +++ b/go/catalogue/declaration.go @@ -238,6 +238,11 @@ type Rendering struct { // when no holder is reachable yet; see seat_into.go for which seats are answered. SeatReach map[string]string + // SeatHolder is the module holding each seat SeatReach answers, by seat, from the holdings the composition has (novox/hq + // design 51, issue 1966 ENG-10): written beside an image routed through the seat, for the node-engine to wait on when its + // pull fails. Absent for a seat with no holder on record. + SeatHolder map[string]string + // Built is every `/` the mesh has built. What tells a reference recorded // with an address — before references were kept without one — from an image a module runs // straight from a public registry. diff --git a/go/declaration/declaration.go b/go/declaration/declaration.go index 3d70e00..f7cb883 100644 --- a/go/declaration/declaration.go +++ b/go/declaration/declaration.go @@ -1368,8 +1368,14 @@ type Container struct { Module string `json:"module,omitempty"` Name string `json:"name"` // Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not. - Image string `json:"image"` - Env map[string]string `json:"env,omitempty"` + Image string `json:"image"` + // ImageSeat and ImageHolder say, beside an image the composition routed through a store, the seat the reference came + // through and that seat's holder (novox/hq design 51, issue 1966 ENG-10): what the node-engine finds the holder to wait + // on when the pull fails. Not part of what a container is: they are no part of its spec or digest, so a declaration + // composed again for a new holder replaces no container. + ImageSeat string `json:"image-seat,omitempty"` + ImageHolder string `json:"image-holder,omitempty"` + Env map[string]string `json:"env,omitempty"` // EnvFile names files the runtime reads environment from, in order. // // **Because a secret may not travel in Env.** A declaration reaches a node over the broker, -- 2.54.0 From 5e02dccb9840c11b09a68a3a8e22980aa68b5371 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:57:50 +0100 Subject: [PATCH 004/144] =?UTF-8?q?SDK-8,=20SDK-12:=20the=20seat=20list=20?= =?UTF-8?q?is=20mesh-contracts'=20generated=20one;=20mesh-sdk=20keeps=20th?= =?UTF-8?q?e=20runtime=20(ADR=200608=20=C2=A75,=20ADR=200613=20=C2=A72,=20?= =?UTF-8?q?ADR=200620=20=C2=A73,=20design=2026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catalogue's compiled set is read from git.novox.be/novox/mesh-contracts/go/seats; the hand-written seat literal and its verb builders go. The bar's block keeps its checks as code beside the data. A case of the seat set's test holds the generated set equal, entry for entry, to the list compiled at 5a38a12 (testdata), but for the whole seats' additions and the vault's two verbs; the three new registry seats are the only new entries. A holder of mesh-broker now serves its eight verbs. SDK-12: the vault's seat no longer promises accept and move-asks, which no holder served and nothing called; retire is optional (design 32). The intake and move rows join one table; the move's own test goes with the two verbs. Requires mesh-contracts (no version on the forge yet: a go.work, uncommitted, uses the sibling worktree until the group pins it). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/artifact_sweep_seat.go | 38 - go/catalogue/catalog_seat.go | 22 - go/catalogue/checker_seat.go | 28 - go/catalogue/control_records_seats.go | 44 - go/catalogue/delivery_seat.go | 70 - go/catalogue/engine_seat.go | 95 +- go/catalogue/enrolment_seat.go | 59 - go/catalogue/files.go | 74 - go/catalogue/graphical_session.go | 147 +- go/catalogue/graphical_session_test.go | 2 +- go/catalogue/health_seats.go | 96 - go/catalogue/holdings_test.go | 6 +- go/catalogue/node_resolver.go | 30 - go/catalogue/placement_seat.go | 54 - go/catalogue/reads.go | 58 - go/catalogue/seats.go | 639 +-- go/catalogue/seats_test.go | 88 + go/catalogue/shares.go | 73 - go/catalogue/testdata/seats-before-move.json | 4810 ++++++++++++++++++ go/catalogue/vault_seat_test.go | 68 +- go/catalogue/verbs.go | 17 - go/packages_list_test.go | 2 +- 22 files changed, 4989 insertions(+), 1531 deletions(-) create mode 100644 go/catalogue/testdata/seats-before-move.json diff --git a/go/catalogue/artifact_sweep_seat.go b/go/catalogue/artifact_sweep_seat.go index 0add09f..752852c 100644 --- a/go/catalogue/artifact_sweep_seat.go +++ b/go/catalogue/artifact_sweep_seat.go @@ -3,41 +3,3 @@ package catalogue // ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by // distribution, and not the provision artifact-store it delivers. const ArtifactSweepSeat = "mesh-artifact-sweep" - -// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks. -func artifactSweepVerbs() []Verb { - return []Verb{ - {Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " + - "repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " + - "newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " + - "unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", - Input: schema(map[string]string{ - "repository": "only this repository's references, as / (the counts stay whole)", - "collected": "\"true\": list the collected references too (counted always)", - }, nil, "collected")}, - {Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " + - "held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " + - "Reads only.", - Input: listed(schema(map[string]string{ - "references": "the artifacts' references, artifact-store:////blobs/sha256:: a list, " + - "or one text separated by commas", - }, []string{"references"}), "references")}, - {Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " + - "dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " + - "then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " + - "names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " + - "seconds; what is left is said. Never touches a digest the mesh did not make.", - Input: schema(map[string]string{ - "confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done", - "why": "with confirm: why, required and recorded", - "most": "let go of at most this many (500 by default, at most 5000)", - }, nil, "confirm")}, - {Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " + - "it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " + - "it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", - Input: schema(map[string]string{}, nil)}, - {Name: "images", Description: "Every container image one machine's want and have name, each with the resources " + - "naming it: what the machine keeps when it prunes its images. Reads only.", - Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, - } -} diff --git a/go/catalogue/catalog_seat.go b/go/catalogue/catalog_seat.go index 09db316..c75243c 100644 --- a/go/catalogue/catalog_seat.go +++ b/go/catalogue/catalog_seat.go @@ -10,28 +10,6 @@ import "encoding/json" // CatalogDependents, whose field names are those of the catalog module's tools they replace. const CatalogSeat = "mesh-catalog" -// catalogVerbs are the seat's four verbs, each read from its holder's states. Each is optional until the holder that -// runs serves it (design 33 §7): the module that claimed the seat before serves none of them, and a judge holding them -// required would read it as outgrown for as long as both claims stand (novox/hq ADR 0604 §6). -func catalogVerbs() []Verb { - return []Verb{ - {Name: "modules", Optional: true, Description: "Every module the trunk holds, each with its repository, its " + - "directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the " + - "catalog of the mesh, read from its holder's state. Reads only.", - Input: schema(map[string]string{}, nil)}, - {Name: "module", Optional: true, Description: "One module whole: its repository, directory, commit and " + - "fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.", - Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})}, - {Name: "provides", Optional: true, Description: "Which modules provide a provision (a module provides its own " + - "name too) and which require it, from the module graph's provision edges. Reads only.", - Input: schema(map[string]string{"provision": "the provision, such as postgres-database"}, []string{"provision"})}, - {Name: "dependents", Optional: true, Description: "The modules whose build edges reach a module, directly or " + - "through others (a base, a mesh package it publishes, the build module that builds them): what a change of it " + - "builds again, from the module graph. Reads only.", - Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})}, - } -} - // CatalogEntry is one module of the catalog: its repository, its directory there ("." for the root), the merge commit it // was last changed at, and its new-form fingerprint or why it has none. type CatalogEntry struct { diff --git a/go/catalogue/checker_seat.go b/go/catalogue/checker_seat.go index a553dd5..2b785cf 100644 --- a/go/catalogue/checker_seat.go +++ b/go/catalogue/checker_seat.go @@ -2,31 +2,3 @@ package catalogue // CheckerSeat is the seat mesh-delivery-checker holds. const CheckerSeat = "mesh-delivery-checker" - -// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them -// everywhere. -func checkerVerbs() []Verb { - return []Verb{ - {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + - "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " + - "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " + - "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " + - "4 wait or run at once, mesh-wide.", - Input: schema(map[string]string{"repository": "/", - "head": "the full commit to try, 40 or 64 hex digits", - "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", - "tests": "a go test -run pattern: only those tests run, and nothing is kept", - "level": "unit or integration: only this level; both when absent"}, - []string{"repository", "head"}), - Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, - {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " + - "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " + - "it was merged onto, the time it took and what failed first. Read-only.", - Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})}, - {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " + - "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " + - "id. A try that ended already is refused.", - Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"}, - []string{"id", "why"})}, - } -} diff --git a/go/catalogue/control_records_seats.go b/go/catalogue/control_records_seats.go index 67c754f..7850660 100644 --- a/go/catalogue/control_records_seats.go +++ b/go/catalogue/control_records_seats.go @@ -8,47 +8,3 @@ const ( // controlRecordsDecision is the record that made the two seats. const controlRecordsDecision = "novox/hq ADR 0565" - -// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15). -func wantsVerbs() []Verb { - return []Verb{ - {Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " + - "and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " + - "rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " + - "with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", - Input: schema(map[string]string{ - "module": "one module", - "policy": "with module: roll, together, record, or default to take a person's choice back", - "why": "with policy: why, required and kept", - "compare": "\"true\": each node's own composition, by its digest, and the assigner's findings", - }, nil, "compare")}, - {Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " + - "indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " + - "the step itself is the put on the operator's warrant.", - Input: schema(map[string]string{}, nil)}, - } -} - -// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them. -func handActsVerbs() []Verb { - return []Verb{ - {Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " + - "(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " + - "runtime proves, never said by the caller. Said as act-recorded.", - Input: schema(map[string]string{ - "what": "what was done, in a line", - "why": "why it had to be done by hand", - "cause": "the cause in a word, or a condition's kind", - "condition": "the key of the condition it addressed, if any (optional)", - "verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", - }, []string{"what", "why"})}, - {Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " + - "cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", - Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause", - "who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")}, - {Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " + - "with which proofs and which answer read from the router's own record of that module's ask, never from the " + - "caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", - Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})}, - } -} diff --git a/go/catalogue/delivery_seat.go b/go/catalogue/delivery_seat.go index 9efc57c..3d3eed6 100644 --- a/go/catalogue/delivery_seat.go +++ b/go/catalogue/delivery_seat.go @@ -15,76 +15,6 @@ var RemovedDeliveryVerbs = map[string]string{ "retire-history": "nothing to retire: the gate keeps no delivery after its merge", } -// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat. -func deliveryRemovedInR6(name string) Verb { - return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + - RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true} -} - -// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2 -// on a merge (ADR 0539 clause 17). -func deliveryVerbs() []Verb { - return []Verb{ - {Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " + - "each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " + - "by state, repository or group.", - Input: schema(map[string]string{"state": "one state, e.g. delivering or held", - "repository": "owner/repository", "group": "a group's id (its branch name)", - "all": "\"true\": the final ones of the last thirty days too"}, nil, "all")}, - deliveryRemovedInR6("times"), - {Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " + - "machine receives, what is not an ordinary send), every transition with when and why, the machine " + - "steps of its walk, its group and its order.", - Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})}, - {Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " + - "(declared, built by, version skew, engine before controller, by name), its composed check and its " + - "state, derived from its members.", - Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")}, - deliveryRemovedInR6("what-if"), - {Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " + - "of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " + - "mesh/delivery, …) with its state, " + - "description and when it was set; the merge check's full verdict as the controller said it — each " + - "layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " + - "protection would let it merge, every required status being success.", - Input: schema(map[string]string{"repository": "owner/repository", - "number": "a pull request's number: its head is read", - "commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}), - // Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere. - Optional: true}, - {Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " + - "each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", - Input: schema(map[string]string{}, nil)}, - {Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " + - "lets healer H2 take for it, or none.", - Input: schema(map[string]string{}, nil)}, - {Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " + - "controller is asked for its check. With why.", - Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"}, - []string{"id", "why"})}, - deliveryRemovedInR6("release"), - deliveryRemovedInR6("stop"), - {Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " + - "past its bound. Refused for any other.", - Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"}, - []string{"id", "why"})}, - deliveryRemovedInR6("retire-history"), - {Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " + - "trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " + - "The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " + - "is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " + - "reason, naming why.", - Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"}, - "properties": map[string]any{ - "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1, - "description": "the held deliveries' ids (owner/repository@commit)"}, - "why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"}, - }}, - // Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere. - Optional: true}, - } -} - // SeatSays is whether a consumed `.` names one of the seat's own events: the seat emits it, // exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the // seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index dfe2960..bcbbd2f 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -1,85 +1,30 @@ package catalogue -// EngineSeat is the node seat every node's engine holds. -const EngineSeat = "node-engine" +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" -// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them. +// EngineSeat is the node seat every node's engine holds. +const EngineSeat = contractseats.NodeEngine + +// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the +// generated seat list (design 26). func EngineVerbs() []Verb { - return []Verb{ - {Name: "hand-over", Description: "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, " + - "§5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, " + - "reached through no link) and refuses in words, or asks the operator on their channel at the level approve, " + - "binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands " + - "it over. One ask is open per path; acts shows where it stands.", - Input: schema(map[string]string{"path": "the directory's absolute path, as the engine states it", - "why": "why it is asked, in words the operator reads"}, []string{"path"})}, - {Name: "access-act", Description: "Ask the operator for the take act on a library whose root holds data outside its " + - "group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its " + - "root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes " + - "sets the library's group, group write and setgid under its root. One ask is open per access.", - Input: schema(map[string]string{"act": "the act: take", "access": "the access, library:", - "why": "why it is asked, in words the operator reads"}, []string{"act", "access"})}, - {Name: "judge-directories", Description: "Which of this machine's declared directories the engine would refuse to " + - "make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", - Input: schema(map[string]string{}, nil)}, - {Name: "setuid-search", Description: "Throw away the engine's last search for setuid programs and start a full one " + - "(novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while " + - "one runs, and when one was started within the hour, naming when the next may start.", - Input: schema(map[string]string{"why": "why it is asked, in words"}, nil)}, - {Name: "root-free", Description: "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only " + - "on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root " + - "without a person, and the login shell's execute not served here. Anything else is no pass, and says why. " + - "Answered by this machine's engine alone.", - Input: schema(map[string]string{}, nil)}, - {Name: "acts", Description: "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): " + - "each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " + - "operator's warrant. Read-only.", - Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)}, - // The packages this node's compositions declare (novox/hq ADR 0565 §6, §15): optional until every engine serves it. - {Name: "packages", Optional: true, Description: "The packages this machine's compositions declare, present or absent, " + - "each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller " + - "hands its lease away, beside the controller's mirror for this machine and their difference. With write, the " + - "key written again from what the engine composed.", - Input: schema(map[string]string{"write": "\"true\": write this machine's key again from what the engine composed"}, nil, "write")}, - // Read-only (issue 774): why a module waits for a yes, or what its next apply would change. Optional until every - // engine serves it. - {Name: "composition", Optional: true, Description: "One module's composition on this machine beside what runs of " + - "it, read-only: the composition its want's build has in the bucket composed- (the latest revision, or the " + - "one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs " + - "(applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, " + - "and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for " + - "a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers " + - "that join or leave.", - Input: schema(map[string]string{"module": "the module, as placed on this machine; node for the node's own", - "digest": "a composition's digest (sha256:…), to read that revision rather than the latest", - "diff": "\"true\": list the resources that differ from what runs"}, []string{"module"})}, - // The recovery of a composition that failed (issue 774, 2026-10-31: ace kept route-proxy failed for a vault that - // answered again a minute later). Optional until every engine serves it. - {Name: "compose", Optional: true, Description: "Compose this machine's modules now and say how it went, so a " + - "composition that failed, or a module held, recovers without a restart: what the pass refused or held, each " + - "with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no " + - "responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried " + - "again. Changes only what a pass the engine makes on its own would change.", - Input: schema(map[string]string{"module": "a module placed on this machine whose apply is also tried again (optional)"}, nil)}, - // A composition that does not exist yet (novox/hq ADR 0593 §7). Optional until every engine serves it: a node - // whose engine does not is a node that does not answer (§9). - {Name: "compose-candidate", Optional: true, Description: "Compose a check's candidate on this machine in shadow, " + - "with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it " + - "composes (with its digest and whether the candidate changes it), is refused by the candidate or is held " + - "without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: " + - "applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", - Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, .", - "input": "\"true\": also answer the composition input the candidate was composed from, for the sandbox path"}, - []string{"candidate"}, "input")}, - } + s, _ := seatIn(defaultSeats, EngineSeat) + return append([]Verb(nil), s.Serves...) } // EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime // proved it and the ask (ADR 0563 §1). -var EngineEvents = []string{"asked", "done", "refused"} +var EngineEvents = func() []string { + s, _ := seatIn(defaultSeats, EngineSeat) + return append([]string(nil), s.Emits...) +}() -// engineSeat is the seat's row in the closed set. -func engineSeat() Seat { - return Seat{Name: EngineSeat, Scope: ScopeNode, Serves: EngineVerbs(), Emits: append([]string(nil), EngineEvents...), - HeldByTheEngine: true, Decision: "novox/hq ADR 0563"} +// seatIn is the seat of that name in a list. +func seatIn(list []Seat, name string) (Seat, bool) { + for _, s := range list { + if s.Name == name { + return s, true + } + } + return Seat{}, false } diff --git a/go/catalogue/enrolment_seat.go b/go/catalogue/enrolment_seat.go index 26593bd..f5489fa 100644 --- a/go/catalogue/enrolment_seat.go +++ b/go/catalogue/enrolment_seat.go @@ -29,62 +29,3 @@ var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, Enrolmen // enrolmentDecision is the record that made the seat. const enrolmentDecision = "novox/hq ADR 0587" - -// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional: -// the seat is new with its holder, so no holder of before serves fewer. -func enrolmentVerbs() []Verb { - str := func(description string) map[string]any { - return map[string]any{"type": "string", "description": description} - } - return []Verb{ - {Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " + - "refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " + - "key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " + - "reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " + - "warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " + - "The token itself is read only through tokens.", - Input: schema(map[string]string{ - "node": "the new machine's name in the mesh", - "why": "why it joins, shown to the operator with the ask", - "expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", - // The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node - // enters placement with it and the hub peers it before redeem. - "overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", - }, []string{"node", "why", "overlay_key"})}, - {Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol. " + - "alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " + - "vault for the node's account and certificate, writes the node into placement, and answers the account sealed " + - "to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " + - "it finishes what is not done and answers the same; with other keys it is refused.", - Input: map[string]any{"type": "object", "additionalProperties": false, - "required": []string{"node", "public_key", "sealing_key", "proof"}, - "properties": map[string]any{ - "node": str("the node the token was issued for"), - "public_key": str("the identity key's public half, base64"), - "overlay_key": str("the private network key's public half"), - "sealing_key": str("the sealing key's public half, which the account is sealed to"), - "serving_key": str("the serving key's public half, which the certificate binds"), - "profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"}, - "tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"}, - "proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"), - }}}, - {Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " + - "node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " + - "enrolment.", - Input: schema(map[string]string{}, nil)}, - {Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " + - "an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " + - "it, records the act as a hand act and says enrolment-revoked.", - Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"}, - []string{"node", "why"})}, - {Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " + - "the bucket's fill against its cap. For a node at live, its " + - "token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " + - "history, the ended enrolments from the seat's events, as far back as the events are kept.", - Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"}, - nil, "history")}, - {Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " + - "and nothing more.", - Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})}, - } -} diff --git a/go/catalogue/files.go b/go/catalogue/files.go index 0f602af..7590483 100644 --- a/go/catalogue/files.go +++ b/go/catalogue/files.go @@ -1,9 +1,5 @@ package catalogue -import ( - "strings" -) - // FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394). const FilesSeat = "node-files" @@ -16,73 +12,3 @@ const ( "secret is kept, or a known credential file, is refused by name" overrideWord = "a reason, to change a path a module owns anyway (optional)" ) - -// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads, -// with their descriptions restated for the operator's account, and the writes. -func filesVerbs() []Verb { - var verbs []Verb - for _, v := range readsVerbs() { - v.Description = strings.Replace(v.Description, - "Read as the holder's own account, which holds no secret: a component it may not reach says so.", - "", 1) - v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "." - verbs = append(verbs, v) - } - write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) Verb { - in["reason"] = "why the file is changed, for the audit trail" - in["override"] = overrideWord - return Verb{Name: name, Description: description + " Refuses a path a module owns " + - "unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces} - } - return append(verbs, - write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.", - map[string]string{"path": "an absolute path", "content": "the text to write", - "mode": "the file's mode, such as 0644 (optional)"}, - []string{"path", "content"}, []string{"mode"}, "tee", ">"), - write("copy", "Copy a file or directory to a path on this node. A target on another node (:/path) is "+ - "refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+ - "kept in the node's trash for 7 days.", - map[string]string{"from": "an absolute path", "to": "an absolute path on this node"}, - []string{"from", "to"}, nil, "cp", "cp -a", "ssh cp"), - write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+ - "comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.", - map[string]string{"from": "an absolute path", "to": "an absolute path"}, - []string{"from", "to"}, nil, "mv"), - write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.", - map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"}, - []string{"path"}, []string{"recursive"}, "rm", "rm -r"), - write("mkdir", "Make a directory.", - map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"}, - []string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"), - write("chmod", "Change the mode of a file or directory.", - map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x", - "recursive": "\"true\": also everything under a directory"}, - []string{"path", "mode"}, []string{"recursive"}, "chmod"), - write("chown", "Change the owner and group of a file or directory.", - map[string]string{"path": "an absolute path", "owner": "an account's name or number", - "group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"}, - []string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), - Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " + - "and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " + - "At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " + - "before anything is sent. Read " + filesAsAccount + ".", - Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into", - "key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})}, - // download is a plain Verb, not through write(): write() types its optional arguments as true/false - // switches, which a hash and a reason are not (issue 1719). - Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " + - "never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " + - "the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " + - "unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path", - "sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)", - "reason": "why the file is changed, for the audit trail", - "override": overrideWord}, []string{"bucket", "key", "path", "reason"})}, - Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " + - "original path and when it went.", - Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, - write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.", - map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"), - ) -} diff --git a/go/catalogue/graphical_session.go b/go/catalogue/graphical_session.go index 2111d95..446d1f8 100644 --- a/go/catalogue/graphical_session.go +++ b/go/catalogue/graphical_session.go @@ -23,117 +23,6 @@ const ( SecretServiceSeat = "node-secret-service" ) -// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs -// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead, -// ADR 0255), the compositor and the secret -// service are roles a second holder competes for, and nothing has needed to ask them anything. -func graphicalSessionSeats() []Seat { - const decided = "novox/hq ADR 0208" - return []Seat{ - {Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ - {Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " + - "one the operator account starts by default.", - Input: schema(map[string]string{}, nil)}, - }}, - {Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ - {Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " + - "where it is placed; and the layout profile in force, if one matches.", - Input: schema(map[string]string{}, nil)}, - // Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6). - {Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " + - "identities: list them, save the current arrangement under a name, or apply one.", - Input: withEnum(schema(map[string]string{ - "action": "list, save or apply", - "name": "the profile to save or apply (save and apply only)", - }, []string{"action"}), "action", "list", "save", "apply")}, - }}, - // It receives window-manager configuration lines from every other module (novox/hq ADR 0212): - // bindings, start-up commands, rules — placed by the session's holder, never written into - // its directory by the contributor. - {Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided, - Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []Verb{ - {Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.", - Input: schema(map[string]string{}, nil)}, - {Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " + - "it is visible or focused.", - Input: schema(map[string]string{}, nil)}, - {Name: "windows", Description: "The session's windows: each one's title, class, workspace and " + - "whether it has focus; narrowed to one workspace when named.", - Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)}, - }}, - {Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ - {Name: "open", Description: "Open a terminal window in the operator's session, running a command " + - "or the login shell, in a directory or the account's home.", - Input: schema(map[string]string{ - "command": "what to run in it (optional; the login shell when absent)", - "directory": "where it starts (optional; the account's home when absent)", - }, nil)}, - }}, - {Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ - {Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " + - "one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.", - Input: map[string]any{"type": "object", "required": []string{"choices"}, - "properties": map[string]any{ - "choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "the lines to choose between, in order"}, - "prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"}, - }}}, - // A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer - // is sealed to the asker's key, so it is never plaintext on the bus or in any call's record. - // **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live. - {Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " + - "does not show what is typed, and answer it sealed to the key the asker gives — never in " + - "the clear — or cancelled when the prompt was dismissed or not answered in time.", - // By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the - // module, the secret and the machine, and says the controller asks — the bus lets nobody else - // ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator. - Input: schema(map[string]string{ - "module": "the module whose own secret is asked for", - "secret": "the own secret's name", - "node": "the machine the module runs on", - "seal_to": "the asker's public sealing key: the answer is sealed to it", - "timeout_seconds": "give up after this long (optional)", - }, []string{"module", "secret", "node", "seal_to"})}, - }}, - {Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ - {Name: "send", Description: "Show the operator a notification.", - Input: withEnum(schema(map[string]string{ - "title": "the notification's summary", - "body": "its text (optional)", - "urgency": "low, normal (the default) or critical", - }, []string{"title"}), "urgency", "low", "normal", "critical")}, - {Name: "history", Description: "The notifications shown lately, newest first.", - Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)}, - }}, - {Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ - {Name: "lock", Description: "Lock the operator's session now.", - Input: schema(map[string]string{}, nil)}, - }}, - {Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{ - {Name: "history", Description: "What the clipboard held lately, newest first.", - Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)}, - {Name: "copy", Description: "Put text on the operator's clipboard.", - Input: schema(map[string]string{"text": "the text"}, []string{"text"})}, - }}, - // It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which - // the holder renders in its own grammar. A module that knows something about the machine — the - // power module its battery — gives the block; the bar knows no machine's hardware. - {Name: BarSeat, Scope: ScopeNode, Decision: decided, - Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}}, - {Name: CompositorSeat, Scope: ScopeNode, Decision: decided}, - {Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided}, - } -} - -// withEnum narrows one string property of a schema to the values it may take, so a caller is told -// the choices by the schema rather than by a refusal. -func withEnum(s map[string]any, property string, values ...string) map[string]any { - props := s["properties"].(map[string]any) - p := props[property].(map[string]any) - p["enum"] = values - return s -} - // BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255). const BarKindBlock = "block" @@ -171,34 +60,6 @@ type barOption struct { required bool } -// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order, -// what it shows, and that kind's options. -// -// - bar: top or bottom; -// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the -// clock) — the two places every holder keeps for contributed blocks; -// - order: 0–99 within the place, 50 when absent; -// - shows: battery or command; options: what that kind takes. -func barBlockShape() *Shape { - return &Shape{ - Text: true, - Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}}, - {Field: "place", Values: []string{"resources", "status"}}}, - Order: "order", - Check: barBlockProblems, - Reads: barBlockReads, - Examples: []map[string]any{ - {"bar": "bottom", "place": "status", "shows": "battery"}, - {"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}}, - {"bar": "bottom", "place": "status", "shows": "command", - "options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}}, - {"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}}, - {"bar": "bottom", "place": "status", "shows": "state", - "options": map[string]any{"state": "power.draw", "key": "a-machine"}}, - }, - } -} - func barBlockProblems(data map[string]any) []string { var problems []string for field := range data { @@ -245,3 +106,11 @@ func barBlockProblems(data map[string]any) []string { } return problems } + +// withShapeCode gives a data-shaped kind of the generated seat list the checks its definition cannot carry, being code: +// the bar's block (novox/hq ADR 0255, ADR 0260). +func withShapeCode(seat, kind string, sh *Shape) { + if seat == BarSeat && kind == BarKindBlock { + sh.Check, sh.Reads = barBlockProblems, barBlockReads + } +} diff --git a/go/catalogue/graphical_session_test.go b/go/catalogue/graphical_session_test.go index e433036..4cd4239 100644 --- a/go/catalogue/graphical_session_test.go +++ b/go/catalogue/graphical_session_test.go @@ -55,7 +55,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) { t.Errorf("layout's actions are %v", action["enum"]) } // And they survive the store's JSON, which is where the live set comes from. - if _, err := json.Marshal(graphicalSessionSeats()); err != nil { + if _, err := json.Marshal(DefaultSeats()); err != nil { t.Fatal(err) } } diff --git a/go/catalogue/health_seats.go b/go/catalogue/health_seats.go index 68164fc..c776978 100644 --- a/go/catalogue/health_seats.go +++ b/go/catalogue/health_seats.go @@ -10,99 +10,3 @@ const ( // healthDecision is the record that made the four seats. const healthDecision = "novox/hq ADR 0564" - -// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12). -func conditionsVerbs() []Verb { - return []Verb{ - {Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " + - "raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " + - "the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " + - "lately.", - Input: schema(map[string]string{ - "key": "a condition's key: that one whole; with history, only its transitions", - "scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", - "severity": "only urgent, or only warning", - "machine": "only those about this machine", - "history": "\"true\": every raising, change, silence and clearing lately, oldest first", - "days": "with history: how many days back (default 7, at most 90)", - }, nil, "history")}, - {Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " + - "status, and the silence is kept as its transition in history.", - Input: schema(map[string]string{ - "key": "the condition's key", - "for": "how long: 30m, 4h, 2d; at most 7d", - "why": "why, required and recorded", - "cause": "the cause in a word (the condition's kind when absent)", - }, []string{"key", "for", "why"})}, - {Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " + - "conditions: the recovery of a keeper that missed a change.", - Input: schema(map[string]string{}, nil)}, - } -} - -// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12). -func watchdogsVerbs() []Verb { - return []Verb{ - {Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " + - "its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " + - "expiry wrote.", - Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)}, - {Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " + - "when and the words of its finding.", - Input: schema(map[string]string{"machine": "only those on this machine"}, nil)}, - {Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " + - "those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", - Input: schema(map[string]string{"machine": "one machine"}, nil)}, - {Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " + - "consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " + - "whole. With deliver or drop, a hand act with why.", - Input: schema(map[string]string{ - "id": "a dead letter's id, as the list gives it: that one whole, with its message", - "consumer": "a consumer's name, or .: only what that one gave up on", - "limit": "how many to list, newest first (default 50); only when listing", - "deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", - "drop": "a dead letter's id: drop it for good (needs why)", - "why": "with deliver or drop: why, required and recorded", - "cause": "with deliver or drop: the cause in a word (dead-letter when absent)", - }, nil)}, - {Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " + - "the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", - Input: schema(map[string]string{}, nil)}, - } -} - -// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6). -func selfCheckVerbs() []Verb { - return []Verb{ - {Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " + - "(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " + - "probe's verdict whole. Runs every five minutes on its own; each failure is a finding.", - Input: schema(map[string]string{ - "run": "\"true\": run every probe now and answer the verdict", - "probe": "one probe's id (D6): its verdict alone, with every finding", - }, nil, "run")}, - {Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.", - Input: schema(map[string]string{}, nil)}, - } -} - -// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8). -func healersVerbs() []Verb { - return []Verb{ - {Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " + - "budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.", - Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)}, - {Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.", - Input: schema(map[string]string{ - "key": "the condition's key", - "why": "why now, kept with the act", - }, []string{"key", "why"})}, - {Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " + - "what it tests. A drill is never a repair: no healer is wanted for it however often it is run.", - Input: schema(map[string]string{ - "what": "what was done on purpose, in a line", - "why": "what the drill tests", - "condition": "the key of the condition the drill is meant to raise, if any (optional)", - }, []string{"what", "why"})}, - } -} diff --git a/go/catalogue/holdings_test.go b/go/catalogue/holdings_test.go index 59ea517..790c290 100644 --- a/go/catalogue/holdings_test.go +++ b/go/catalogue/holdings_test.go @@ -3,6 +3,8 @@ package catalogue import ( "strings" "testing" + + "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" ) // **A seat's holder on record settles who holds it, and lets the next holder stand beside the @@ -20,7 +22,7 @@ func busSeatDelivering(t *testing.T, delivers string) { func newBroker() Manifest { return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}, - Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: meshbroker.Verbs()}}} } // CanHold is the one judgement registration and the handover share, against the store's row. @@ -41,7 +43,7 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) { t.Fatalf("a claim at the wrong scope was allowed: %v", err) } cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}}, - Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: meshbroker.Verbs()}}} if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) { t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err) } diff --git a/go/catalogue/node_resolver.go b/go/catalogue/node_resolver.go index 1f3aa18..3c7c325 100644 --- a/go/catalogue/node_resolver.go +++ b/go/catalogue/node_resolver.go @@ -6,33 +6,3 @@ const ResolverSeat = "node-resolver" // ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where // one is held. const ResolverFile = "/etc/resolv.conf" - -// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed -// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to -// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the -// operator's way to read and correct the same. -func resolverVerbs() []Verb { - return []Verb{ - {Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " + - "which answer every name not routed elsewhere, and each link given servers of its own with the " + - "domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " + - "wrote it as far as the file says, whether a module took it, and when the resolver's own file was " + - "put back.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}}, - {Name: "route", Description: "Send these domains, and every name under them, to these servers over " + - "this link — and only them: the link is never the machine's default route for names, and the " + - "mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " + - "takes its route with it.", - Input: schema(map[string]string{ - "link": "the network link the servers are reached over, by name (a VPN's tunnel interface)", - "domains": "the domains to route there, separated by spaces or commas", - "servers": "the servers' addresses, separated by spaces or commas", - }, []string{"link", "domains", "servers"}), - Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}}, - {Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " + - "resolvers. Nothing changes when the link has none.", - Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}), - Replaces: []string{"resolvectl revert"}}, - } -} diff --git a/go/catalogue/placement_seat.go b/go/catalogue/placement_seat.go index 37f9a0a..ed15f97 100644 --- a/go/catalogue/placement_seat.go +++ b/go/catalogue/placement_seat.go @@ -11,57 +11,3 @@ const ( PlacementVerbWithdraw = "withdraw" PlacementVerbAgain = "again" ) - -// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10). -// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same -// delivery as its holder. -func placementVerbs() []Verb { - changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " + - "place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " + - "unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " + - "(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " + - "layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " + - "proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " + - "remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " + - "on the same warrant)", - "items": map[string]any{"type": "object", "required": []string{"op", "node"}}} - return []Verb{ - {Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " + - "module is placed on, or every key, each with its revision, and how full the state is against its cap. With " + - "history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " + - "proposal's changes from its placement-changed event.", - Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on", - "history": "\"true\": the changes in order, from the stream and the state's revisions", - // ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders. - "seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"}, - nil, "history", "seats")}, - {Name: PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " + - "the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " + - "answered, its verdict and its ask. Narrowed to one by id.", - Input: schema(map[string]string{"id": "one proposal's id"}, nil)}, - {Name: PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " + - "the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " + - "on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " + - "hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " + - "it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " + - "caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " + - "is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", - Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"}, - "properties": map[string]any{ - "why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"}, - "changes": changes, - // ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose - // Approve is the proposal's; the holder writes it with no ask of its own. - "warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " + - "warrant {node, address, ask, warrant}, naming the change's node and address", - "required": []string{"node", "address", "ask", "warrant"}}, - }}}, - {Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " + - "candidates are deleted and placement-withdrawn carries it whole. With why.", - Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})}, - {Name: PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " + - "stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " + - "Nothing more: the recovery of a proposal whose judgement or ask was lost.", - Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})}, - } -} diff --git a/go/catalogue/reads.go b/go/catalogue/reads.go index ef584e8..5b8b515 100644 --- a/go/catalogue/reads.go +++ b/go/catalogue/reads.go @@ -6,61 +6,3 @@ const ReadsSeat = "node-reads" // ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is // kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5). const ReadsSecretPlaces = "MESH_SECRET_PLACES" - -// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the -// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in -// every answer it cuts. -func readsVerbs() []Verb { - return []Verb{ - {Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " + - "(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " + - "file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " + - "holds no secret: a component it may not reach says so. At most 200 paths in one call.", - Input: schema(map[string]string{ - "paths": "one absolute path, or several separated by line breaks", - "follow": "\"true\": also say where the path leads once every link is followed", - }, []string{"paths"}, "follow"), - Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}}, - {Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " + - "link is followed, and whether that exists.", - Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}), - Replaces: []string{"readlink", "readlink -f", "realpath"}}, - {Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " + - "link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " + - "is refused by name.", - Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}), - Replaces: []string{"ls", "ls -la"}}, - {Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " + - "(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " + - "kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " + - "number said. /proc and /sys are read as far as the holder's account may.", - Input: schema(map[string]string{ - "path": "an absolute path to a file", - "from": "the first line to answer, counted from 1 (optional)", - "lines": "how many lines to answer (optional)", - "bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", - }, []string{"path"}), - Replaces: []string{"cat", "head", "tail", "less"}}, - {Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " + - "every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " + - "8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " + - "binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " + - "secret is withheld. The answer says which bound it met.", - Input: schema(map[string]string{ - "text": "the fixed string to find", - "path": "an absolute path to a file or a directory", - "depth": "how many directories deep, up to 8 (optional)", - "matches": "the most matches to answer, up to 500 (optional)", - "case": "\"false\": match whatever the case (optional)", - }, []string{"text", "path"}, "case"), - Replaces: []string{"grep", "grep -r", "grep -F"}}, - {Name: "account", Description: "One account or group by name or number, as this node's account files say: " + - "an account's number, group, home, shell and every group it is in; a group's number and members. With " + - "neither, every account and group. Never a password field.", - Input: schema(map[string]string{ - "name": "an account's name or number (optional)", - "group": "a group's name or number (optional)", - }, nil), - Replaces: []string{"getent passwd", "getent group", "id", "groups"}}, - } -} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 15154bb..60ea1a7 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -3,6 +3,8 @@ package catalogue import ( "fmt" "strings" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // Seat is one role the mesh defines. @@ -84,428 +86,48 @@ type Receivable struct { } // defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the -// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is -// written; the store's table is seeded from it and thereafter is the live, editable copy. -// -// In the order a person reads it: the mesh's own, then a node's. -var defaultSeats = append([]Seat{ - // The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable, - // served by whichever module holds the seat with tools of these names. - {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079", - Serves: []Verb{ - {Name: "databases", Description: "Every database the store holds, with its on-disk size.", - Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}}, - {Name: "query", Description: "One read-only statement against one database the store holds.", - Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"}, - []string{"database", "sql"}), Replaces: []string{"psql"}}, - }}, - // **Delivers the mesh's own bus, not `amqp`.** Those were the same word until - // ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is - // the mesh's own transport. ADR 0128 then made that connection something a module requires - // rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient - // connection would mint a credential for each. - {Name: BrokerSeat, Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"}, - // The vault: the controller seals every minted credential with what it provides, which is the - // test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a - // second claimant, refused by name, rather than a candidate for a pin. - // - // Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an - // asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate` - // take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from - // the controller alone, which carries the value sealed to a key the vault made for the call and cannot open - // it. All optional until the vault that runs serves them (design 33 §7). - {Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161", - Serves: []Verb{ - {Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " + - "sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " + - "bus proved it. Never the value in the clear, and never to a private key.", - Input: schema(map[string]string{"name": "the secret's name", - "public_key": "the asker's X25519 public key as SPKI PEM — never a private key"}, - []string{"name", "public_key"})}, - {Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " + - "controller alone: name the secret and get a call and a key made for it, then send the call and the " + - "value sealed to that key. The vault alone opens it, and never remakes an accepted credential.", - Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret", - "module": "the module, for a module's own secret", "name": "the secret's name in the module", - "provider": "the provider's machine, for a provision's credential", "provision": "the provision", - "by": "who gave it at the terminal", - "origin": "issued for a bus account the controller minted for a core or moved module and hands " + - "at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)}, - {Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " + - "from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " + - "0563 §8): the ask is recorded in the vault's store and bounded before the prompt " + - "opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", - Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for", - "module": "the module", "name": "the secret's name in the module", - "desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller", - "outcome": "instead of sealed: how the prompt ended"}), nil)}, - {Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " + - "provision, from the controller alone, which asks the provider's tool with a key the vault made for " + - "the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " + - "each as that holder's own; the same value again writes nothing.", - Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision", - "provider": "the provider's machine", "module": "the provider's module"}), nil))}, - {Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " + - "software issued and for an accepted credential. Never answers the value.", - Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module", - "name": "the secret's name in the module", "provider": "the provider's machine", - "provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)}, - // The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what - // was sealed to its machine, which the controller cannot open; the controller copies its record of asks. - {Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " + - "from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " + - "and generation and get a call and a key made for it, then send the call, the value sealed to that " + - "key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " + - "back sealed to that key.", - Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine", - "module": "its module", "name": "its name in the module", - "provider": "a pair credential: the provider's machine", "provision": "the provision", - "consumer": "the consumer's machine", "consumer_module": "the consuming module", - "local": "the credential's local name, when it has one", - "origin": "made, accepted or issued, as the controller's store says", - "generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)", - "read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)}, - // A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786). - {Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " + - "own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " + - "first or changed key is recorded and the machine's core secrets are said changed; the same key " + - "again changes nothing.", - Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)}, - // A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547 - // §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says. - {Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " + - "alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " + - "credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " + - "credential the vault does not hold is named missing; never a value in the clear.", - Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{ - "type": "array", "items": map[string]any{"type": "object"}, - "description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " + - "provision, consumer, consumer_module, provider, local}"}}}}, - {Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " + - "into the vault's, as they are and once, from the controller alone. No value is in any of them.", - Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{ - "type": "array", "items": map[string]any{"type": "object"}, - "description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}}, - {Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " + - "ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " + - "controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", - Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})}, - {Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " + - "kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " + - "node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " + - "a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " + - "redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " + - "and the signer's fingerprint, and says signed; never a key.", - Input: schema(map[string]string{"kind": "node-certificate or enrolment", - "node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", - "serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented", - "reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})}, - // Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's - // manifest names for these two. - {Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " + - "issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " + - "written as the bus user enrol.. Answers only what the joining machine must trust (the bus's " + - "address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " + - "sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " + - "dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " + - "carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", - Input: withObject(schema(map[string]string{"node": "the node the token is for", - "expires": "when the token expires (RFC 3339), as the enrolment's key says", - "sealed": "\"true\": answer the token sealed to the operator key", - "drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"), - "tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+ - "public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", - map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port", - "address": "the joining node's tunnel address", - "key": "the joining machine's own tunnel public key, as given at issue", - "range": "the private network's range, a prefix"})}, - {Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node., sealed " + - "to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " + - "account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " + - "node's node-logins key naming node.. Asked by mesh-enrolment alone. Never the value in the clear.", - Input: schema(map[string]string{"node": "the joining node", - "sealing_key": "the sealing key's public half the node presented, which the account is sealed to", - "keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)", - "retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")}, - // A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus - // node last; the state node-logins says where each node stands. - {Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " + - "answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " + - "its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " + - "rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " + - "node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.", - Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence", - "act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})}, - {Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " + - "asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " + - "The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " + - "without it.", - Input: schema(map[string]string{}, nil)}, - {Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " + - "the new login and its value dropped, the old one kept; a node that switched switches back first.", - Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})}, - }}, - // The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets - // mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10). - // Optional until the mesh-config that runs serves it (design 33 §7). - {Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565", - Serves: []Verb{ - {Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " + - "mesh config or node config for a predefined key, or a module's own value on one node, each set " + - "with why, validated, and naming each module on each node whose value moves.", - Input: configInput()}, - }}, - // The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which - // domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect, - // which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads - // them. Optional until the mesh-provisioner that runs serves it (design 33 §7). - {Name: "mesh-provisioner", Scope: ScopeMesh, Decision: "novox/hq ADR 0349", - Serves: []Verb{ - {Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " + - "(which domain answers which kind of placeholder), the needs each module on each node recorded, what " + - "is applied where, or the items of every collection, narrowed by node and module. Reads only.", - Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect", - "node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)}, - }}, - // Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as - // an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes — - // images and archives, by digest — which is why the provision is the artifact store and not an - // image registry. - {Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, - // The catalog (novox/hq ADR 0604): what the mesh holds of every module, its verbs read from its holder's states (CatalogSeat). - {Name: CatalogSeat, Scope: ScopeMesh, Serves: catalogVerbs(), Decision: "novox/hq ADR 0604"}, - // The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every - // machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act. - // What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it. - {Name: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"}, - // Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by - // a proposal the nodes answer and the operator approves. Its states and events are the holder module's. - {Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"}, - // The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's. - {Name: CheckerSeat, Scope: ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"}, - // The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the - // watchman's watchdogs, the self-check and the healers. - {Name: ConditionsSeat, Scope: ScopeMesh, Decision: healthDecision, - Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()}, - {Name: WatchdogsSeat, Scope: ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()}, - {Name: SelfCheckSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()}, - {Name: HealersSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()}, - // The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's - // policy and the bus step at the assigner, and the hand-act log. - {Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()}, - {Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()}, - // Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment. - {Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()}, - // The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go). - {Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"}, - Serves: artifactSweepVerbs()}, - // Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a - // delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight. - // They keep their names until that migration is done deliberately, apart from the node-* pass. - {Name: "npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"}, - {Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"}, - // **A build node's work, one seat per build language** (novox/hq ADR 0525 §2, §10; ADR 0536 §4; issue 1887). The - // build module of each language claims its seat on every node it runs on; the builder and the checker put their - // asks on the seat's accepted kinds, which the broker derives as one work queue with a shared worker per kind, and - // each holder pulls delivery first, then check, then recheck, at most its node's build capacity at once. Its verbs - // say what the node runs and pause or resume it there: every holder pulls from the same worker, so a node's pause is - // its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace - // node-build-agent, retired with build-agent (ADR 0524 §5). - {Name: "node-build-go", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"), - Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, - {Name: "node-build-node", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"), - Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, - {Name: "node-build-image", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"), - Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"}, - // **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and - // every node and container asks them and nothing else. Replicated since ADR 0223: held on more - // than one machine, each answering the same names from the same roster, and every machine's - // resolver file lists every holder — its own first — and no public resolver, so whichever answers - // first gives the one answer. Delivers what a machine's resolver configuration requires, so that - // requirement resolves to a holder wherever they are placed. - {Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Replicated: true, - Decision: "novox/hq ADR 0194, ADR 0223"}, - // **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes - // /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts - // file as the operator's, changed through these three verbs on that machine alone. The controller - // holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an - // alias on a mesh that knew it. - {Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223", - // The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287). - Paths: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}}, - Serves: []Verb{ - {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + - "the operator's, or the block of the module or tool that writes it.", - Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}}, - {Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " + - "/etc/hosts — a name for this machine's own programs, not the mesh's.", - Input: schema(map[string]string{"address": "the IPv4 or IPv6 address", - "names": "the names for it, separated by spaces"}, []string{"address", "names"}), - Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}, - {Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " + - "lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", - Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"}, - []string{"name"}), Replaces: []string{"sed -i /etc/hosts"}}, - }}, - // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list - // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all - // four; the jails themselves are composed from the modules the machine runs (to-be 31). - {Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121", - Serves: []Verb{ - {Name: "status", Description: "Every jail on this machine with how many it is watching and " + - "holding now, and the totals since the jail started; one jail's detail when named.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}}, - {Name: "banned", Description: "Every address banned on this machine right now, with the jail " + - "that holds it and when the ban ends.", - Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}}, - {Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " + - "operator's act on the live ban list, which the mesh never writes itself.", - Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}), - Replaces: []string{"fail2ban-client set banip"}}, - {Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.", - Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}), - Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}}, - }}, - // The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever - // filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did - // not write. Every holder serves all three; what differs by filter is the holder's own tools. - {Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121", - Serves: []Verb{ - {Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " + - "ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " + - "chain when asked.", - Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)", - "chain": "one chain of that table (optional)"}, nil), - Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}}, - {Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " + - "and answer with the mesh's table as loaded.", - Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}}, - {Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " + - "host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " + - "DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " + - "active found firewall's chains. An operator's act, by name, never a flush.", - Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}), - Replaces: []string{"nft delete", "iptables -X"}}, - }}, - // The machine's service manager (novox/hq ADR 0177). The host applies every declared unit, - // system or user scope; the holder answers questions and operator acts about them, each verb - // taking the unit and an optional scope. The holder runs nothing of its own: its verbs are - // served by the node tools runtime (ADR 0175). - {Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177", - Serves: serviceManagerVerbs()}, - // The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on - // it being held on its node, as one declaring a `service` depends on node-service-manager: the - // mesh's word for "something on this machine answers for installing", where a capability only - // says the software is there. No verbs yet — the seat says who answers, and what may be asked - // of it is decided when someone needs to ask. - {Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"}, - // The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module - // declaring a `container` depends on it being held on its node. Its verbs, and the host creating - // containers through its holder, wait for ADR 0166's acceptance — seeded without them so the - // dependency has a seat to name and the runtime's module has one to claim. - {Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"}, - // The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and - // every module contributes to it. No verbs — the seat says who places the environment's files, - // and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing - // which module wrote it. - {Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"}, - // The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a - // bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the - // machine's templates. It serves no verb an agent calls. - {Name: "node-provisioner", Scope: ScopeNode, Decision: "novox/hq ADR 0349"}, - // The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176): - // the mesh's, so a second shell module claims the seat rather than declaring a second one, and - // the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller - // may call where the machine serves it (optional: ADR 0268); the holder places every module's shell - // code in its slots. - {Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204", - Serves: loginShellVerbs()}, - // A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the - // code modules contribute for the power moments, and publishes the machine's power states as - // its events. Every machine has one — every machine boots and shuts down. No verbs yet. - {Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"}, - // The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager - // does not — a laptop's vendor keys — run by one module per machine, which owns its - // configuration. Every other module with keys contributes trigger lines to it. - {Name: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212", - Receives: []Receivable{{Kind: "trigger", Comment: "#"}}}, - // The machine's message bus (novox/hq ADR 0215): its holder owns the D-Bus implementation and its - // system service, never restarts it live, and publishes curated events about it, never its traffic. - // It receives nothing yet: packages ship their own policies. - {Name: MessageBusSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0215"}, - // The machine's backups (novox/hq ADR 0214, to-be 43): its holder keeps nightly restore points of - // the data every module on the machine declares, on the machine, against mistakes rather than - // disasters. A module contributes `backup` lines — what to run to take a consistent copy, and - // which of its directories to keep. - {Name: BackupSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0214", - Serves: backupVerbs(), - // `backup` is what to run and which paths to keep; `data` every item a module declares, with its - // class, for the holder to measure (novox/hq ADR 0233). Both derived from modules' data sections. - Receives: []Receivable{{Kind: BackupKindBackup, Comment: "#", Dirs: true}, {Kind: BackupKindData, Comment: "#", Dirs: true}}}, - // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client - // model change, not a rename, so it stays until that is built. - {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, - // The program that manages the machine's own network. It delivers nothing: its holder keeps the - // manager and the mesh from contradicting each other — the private network's interface left - // alone — and writes the machine's resolver file itself, because the manager is what would - // otherwise rewrite it (novox/hq ADR 0223, which retired node-resolver-config into this seat). - // It never declares a link, an address or a wireless network, because the link is the only - // channel a fix could arrive on. A seat - // rather than a condition in the resolver's module, so a machine running two managers is - // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). - // - // Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the - // resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers - // the manager knows for it — the same for every holder, so a question about a machine's names is asked - // the same way whatever manages its network. Optional until both holders serve them. - // - // It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287, - // ADR 0247). - {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(), - Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: ResolverSeat}}}, - // The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a - // VPN client whose domains must go to its own servers while every other name goes to the mesh's. - // Where it is held, its holder writes the resolver file and the uplink's holder steps back from it - // (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link. - {Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(), - Paths: []SeatPath{{Path: ResolverFile}}}, - // A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server - // exports a machine's folders to the private network and provides each as `nfs-share`; the holder of - // node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go). - {Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()}, - {Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()}, - // A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds - // no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool. - {Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()}, - // A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned - // on every node, then node-reads goes (ADR 0394 §9). - {Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()}, - // The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module. - engineSeat(), -}, - // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. - graphicalSessionSeats()...) +// fallback when it has none (novox/hq ADR 0122). **It is generated, never written here** (novox/hq ADR 0608 §5, +// ADR 0613 §2, design 26 *The seat set has one source*): every seat's definition lives in mesh-contracts, and its +// generated seat list is what this runtime reads, in the order a person reads it: the mesh's own, then a node's. What +// a definition cannot say, being code, is attached here: a contribution kind's checks (withShapeCode). +var defaultSeats = fromContracts(contractseats.List()) + +// fromContracts is the generated seat list in this runtime's types, each data-shaped kind given its checks. +func fromContracts(list []contractseats.Seat) []Seat { + out := make([]Seat, 0, len(list)) + for _, c := range list { + s := Seat{Name: c.Name, Scope: c.Scope, Replicated: c.Replicated, Delivers: c.Delivers, Accepts: c.Accepts, + Emits: c.Emits, PulledByKind: c.PulledByKind, Yields: c.Yields, HeldByTheEngine: c.HeldByTheEngine, + Decision: c.Decision} + for _, v := range c.Serves { + s.Serves = append(s.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input, Output: v.Output, + Optional: v.Optional, Replaces: v.Replaces}) + } + for _, r := range c.Receives { + rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered} + if r.Shape != nil { + sh := &Shape{Order: r.Shape.Order, Examples: r.Shape.Examples, Text: r.Shape.Text} + for _, f := range r.Shape.Places { + sh.Places = append(sh.Places, PlaceField{Field: f.Field, Values: f.Values}) + } + withShapeCode(c.Name, r.Kind, sh) + rv.Shape = sh + } + s.Receives = append(s.Receives, rv) + } + for _, p := range c.Paths { + s.Paths = append(s.Paths, SeatPath{Path: p.Path, StepsBackFor: p.StepsBackFor}) + } + out = append(out, s) + } + return out +} // seats is the working set the lookups read. It starts as the compiled defaults and is replaced by // what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a // change to data, not to this code. var seats = defaultSeats -// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its -// text), and a limit that is a number; every other argument a string. -func configInput() map[string]any { - in := schema(map[string]string{"action": "list (when not given), set, clear or history", - "key": "the config key", "value": "for set: the value, of the key's type", - "module": "a module: its own value, with node", "node": "a node", - "why": "for set and clear: why", "by": "who gave the value at the controller's terminal", - "limit": "for history: at most this many, newest"}, nil) - props := in["properties"].(map[string]any) - props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " + - "a boolean, or for a trusted key an object or a list"} - props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"} - return in -} - // DefaultSeats is the set the mesh ships with, for seeding the store's seat table. func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) } @@ -704,189 +326,6 @@ func SeatsWithAProtocol() []Seat { return out } -// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR -// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an -// optional scope — "system" when absent, "user" for the operator account's own manager — so a -// caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers -// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional -// (Verb.Optional) until every holder serves them (ADR 0246). -func serviceManagerVerbs() []Verb { - scoped := func(more map[string]string, required []string) map[string]any { - props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"} - for k, v := range more { - props[k] = v - } - return schema(props, required) - } - unit := map[string]string{"unit": "the unit's name, as the service manager knows it"} - return []Verb{ - {Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", - Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}}, - {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}}, - {Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}}, - {Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}}, - {Name: "restart", Description: "Restart one unit.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}}, - {Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}}, - {Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}}, - // **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the - // minutes it happened in, and with no window on the verb a person reached for a shell. Every - // argument is the holder's to validate — passed to journalctl as one word of its own, never through - // a shell — and what the unit printed of a secret is redacted before it is answered. - {Name: "journal", Description: "The last lines of one unit's journal (at most 2000), in a time window and " + - "narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as " + - "[redacted: ].", - Input: scoped(map[string]string{ - "unit": unit["unit"], - "lines": "how many lines from the end of what matches (default 100, at most 2000)", - "since": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)", - "until": "the window's end, in the same forms (optional; now when absent)", - "match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", - "priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", - }, []string{"unit"}), - Replaces: []string{"journalctl"}}, - // What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers - // it, so a caller asks every machine the same way. **Optional while its holders catch up**: the - // systemd module running today serves it as its own systemd_failed, and a required verb would - // refuse it before the version serving `failed` could be delivered. - {Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " + - "account's; a manager that does not answer is reported with its error, never as nothing failed.", - Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil), - Replaces: []string{"systemctl --failed"}}, - // **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose - // file is gone stays failed in its manager until the record is reset, and what keeps asking for it - // is a dependency or an enable link no verb could show. Required since every holder serves them - // (ADR 0246 step 3). - {Name: "reset-failed", Description: "Clear one unit's failed record in its manager — " + - "its failed state and its start-limit count — and answer its state after. It removes no file and " + - "starts nothing.", - Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}}, - {Name: "wanted-by", Description: "What wants, requires or triggers one unit, read-only: the " + - "manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " + - "every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " + - "each with where it points and whether it dangles — the account's own directory included in user scope.", - Input: scoped(unit, []string{"unit"}), - Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy", - "ls ~/.config/systemd/user/*.wants"}}, - // **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable` - // leaves a link whose unit file is gone, so the manager keeps asking for the unit. Required since - // every holder serves it (ADR 0246 step 3). - {Name: "unlink-dangling", Description: "Remove the dangling enable links named for one " + - "unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " + - "configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " + - "the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " + - "removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " + - "runtime placed, is left and said.", - Input: scoped(map[string]string{"unit": unit["unit"], - "why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}), - Replaces: []string{"rm ~/.config/systemd/user/*.wants/", "unlink /etc/systemd/system/*.wants/"}}, - } -} - -// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers -// about what the machine resolves through and over which links, whatever program manages its network. -func uplinkVerbs() []Verb { - return []Verb{ - {Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " + - "domains and options it lists, whether it is the file the mesh declares, and when it is not, who " + - "wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " + - "in its place, a known writer running.", - Input: schema(nil, nil), Optional: true}, - {Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " + - "the default route leaves through it, and the resolvers and search domains the network manager " + - "knows for it — a VPN's tunnel included.", - Input: schema(nil, nil), Optional: true}, - } -} - -// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now, -// and restore beside the live data — never over it. -func backupVerbs() []Verb { - return []Verb{ - {Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " + - "its last good night, how many restore points are kept and the repository's size.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}}, - {Name: "now", Description: "Take a backup now, of one module or of every module on this " + - "machine — before a migration, a retirement or anything else that could go wrong.", - Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}}, - {Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " + - "data, never over it: each directory as .restored-. Swapping it in is a " + - "person's act. Lists the restore points when none is named.", - Input: schema(map[string]string{ - "module": "the module", - "snapshot": "the restore point (from `backed-up`; the newest when omitted)", - "path": "one of the module's directories (all of them when omitted)", - }, []string{"module"}), Replaces: []string{"restic restore"}}, - } -} - -// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run -// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit -// so a hung command answers rather than times the caller out. -// -// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can -// become root without a person, so a machine may withhold it: the control-node does, through the -// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark -// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its -// silence on the bus is not judged — a holder withholding it serves nothing on the seat. -func loginShellVerbs() []Verb { - return []Verb{ - {Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " + - "non-interactive login shell in its home; answers with what it printed and how it exited.", - Input: schema(map[string]string{ - "command": "the command line, as you would type it", - "timeout_seconds": "give up after this long, at most 25 (default 20)", - }, []string{"command"})}, - } -} - -// buildAskKinds are a build seat's accepted kinds, in the order a holder pulls them (ADR 0536 §4; mesh-sdk -// delivery.AskKinds). -func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} } - -// buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10). -func buildSeatVerbs(language string) []Verb { - return []Verb{ - {Name: "current", Description: "The " + language + " asks this node runs now (each ask's id, kind, module and when it " + - "started), its build capacity, and whether it is paused, taking no new ask.", - Input: schema(map[string]string{}, nil)}, - {Name: "pause", Description: "Take no new " + language + " ask on this node until resumed; an ask running here " + - "ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the " + - "lasting way to keep a node from building.", - Input: schema(map[string]string{}, nil)}, - {Name: "resume", Description: "Take " + language + " asks again on this node.", - Input: schema(map[string]string{}, nil)}, - } -} - // BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may // snapshot the bus's streams and do nothing else (novox/hq ADR 0235). -const BrokerSeat = "mesh-broker" - -// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed. -// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a -// value for each, which the vault keeps as that holder's own and hands to it alone. -func withHolders(input map[string]any) map[string]any { - props, _ := input["properties"].(map[string]any) - props["holders"] = map[string]any{"type": "array", - "description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " + - "for each, kept as that holder's own and handed to it alone", - "items": map[string]any{"type": "object", "properties": map[string]any{ - "node": map[string]any{"type": "string", "description": "the holder's machine"}, - "module": map[string]any{"type": "string", "description": "the consuming module"}}}} - return input -} - -func vaultIntake(first map[string]string) map[string]string { - out := map[string]string{"call": "the second step: the call the first step answered", - "sealed": "the second step: the value sealed to the call's key — never the value"} - for k, v := range first { - out[k] = v - } - return out -} +const BrokerSeat = contractseats.MeshBroker diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index e4e5217..15de169 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -1,6 +1,10 @@ package catalogue import ( + "encoding/json" + "os" + "reflect" + "slices" "testing" ) @@ -30,4 +34,88 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { if len(Seats()) != 1 { t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) } + + // **The compiled set is the generated one, and the move changed nothing a reader sees** (design 26, SD-M3, SDK-8): + // every seat the list mesh-sdk compiled before the move held (testdata, written from 5a38a12) is in the set + // generated from mesh-contracts' definitions, entry for entry, its verbs' optional marks and a kind's code + // included. A seat defined whole may add verbs, events and answers; the vault drops accept and move-asks (ADR 0620 + // §3); three registry seats are new. + UseSeats(DefaultSeats()) + type golden struct { + Seat + Optional []string + ShapeCode []string + } + raw, err := os.ReadFile("testdata/seats-before-move.json") + if err != nil { + t.Fatal(err) + } + var compiled []golden + if err := json.Unmarshal(raw, &compiled); err != nil { + t.Fatal(err) + } + whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker"} + dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} + norm := func(v any) any { + b, _ := json.Marshal(v) + var out any + _ = json.Unmarshal(b, &out) + return out + } + var names []string + for _, want := range compiled { + names = append(names, want.Name) + got, ok := SeatNamed(want.Name) + if !ok { + t.Errorf("%s left the generated set", want.Name) + continue + } + g := golden{Seat: got} + for _, v := range got.Serves { + if v.Optional { + g.Optional = append(g.Optional, v.Name) + } + } + for _, r := range got.Receives { + if r.Shape != nil && (r.Shape.Check != nil || r.Shape.Reads != nil) { + g.ShapeCode = append(g.ShapeCode, r.Kind) + } + } + if slices.Contains(whole, want.Name) { + want := &want + byName := map[string]Verb{} + for _, v := range got.Serves { + v.Output = nil + byName[v.Name] = v + } + var kept []Verb + for _, v := range want.Serves { + if dropped[want.Name+"."+v.Name] { + if _, still := byName[v.Name]; still { + t.Errorf("%s still serves %s", want.Name, v.Name) + } + continue + } + kept = append(kept, byName[v.Name]) + } + g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts + g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool { return n == "renames" || n == "retire" }) + want.Optional = slices.DeleteFunc(want.Optional, func(n string) bool { return dropped[want.Name+"."+n] }) + want.Serves = slices.DeleteFunc(slices.Clone(want.Serves), func(v Verb) bool { return dropped[want.Name+"."+v.Name] }) + } + if !reflect.DeepEqual(norm(g), norm(want)) { + gb, _ := json.Marshal(g) + wb, _ := json.Marshal(want) + t.Errorf("%s is not as the compiled list held it:\n got %s\nwant %s", want.Name, gb, wb) + } + } + var added []string + for _, s := range DefaultSeats() { + if !slices.Contains(names, s.Name) { + added = append(added, s.Name) + } + } + if want := []string{"go-package-registry", "container-image-registry", "oci-artifact-registry"}; !slices.Equal(added, want) { + t.Errorf("the generated set adds %v, want %v", added, want) + } } diff --git a/go/catalogue/shares.go b/go/catalogue/shares.go index 64b652e..3a66edd 100644 --- a/go/catalogue/shares.go +++ b/go/catalogue/shares.go @@ -5,76 +5,3 @@ const NFSServerSeat = "node-nfs-server" // MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263). const MountsSeat = "node-mounts" - -// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263). -func nfsServerVerbs() []Verb { - return []Verb{ - {Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " + - "read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " + - "node's private address and access (only the nodes the server grants it to and that ask for it), " + - "what is granted and not asked for or asked for and not granted, and whether the kernel holds it " + - "now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " + - "/etc/exports.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}}, - {Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " + - "knows its clients.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}}, - {Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " + - "NFS service is up; with an address, also whether the share is exported to that address: a node's " + - "own private address, when the server grants it the share and the node asks for it. What a machine " + - "mounting the share asks before it mounts.", - Input: schema(map[string]string{ - "share": "the share, by its name", - "address": "a node's private address, to ask whether the share is exported to it (optional)", - }, []string{"share"}), - Replaces: []string{"showmount -e"}}, - {Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " + - "-ra) and answer the shares as it then holds them. The module's own process writes its export " + - "file; this is for after a change made outside it. Changes no shared path.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"exportfs -ra"}}, - {Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " + - "property for a path the mesh's own export now serves — only when that export is live. Without " + - "confirm, says what it would do and changes nothing.", - Input: schema(map[string]string{ - "path": "the shared path whose hand-made export is taken over", - "confirm": "\"true\": change it (needs why); anything else is a dry run", - "why": "why, for the record", - }, []string{"path"}, "confirm"), - Replaces: []string{"zfs set sharenfs=off"}}, - } -} - -// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263). -func mountsVerbs() []Verb { - return []Verb{ - {Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " + - "automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " + - "and which settings asked for it. A password in a mount's options is never shown.", - Input: schema(map[string]string{}, nil), - Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}}, - {Name: "test", Description: "Whether one share's or occasional source's server answers from this " + - "machine now, without touching its mount point.", - Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), - Replaces: []string{"showmount -e", "ping"}}, - {Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " + - "access.", - Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), - Replaces: []string{"mount"}}, - {Name: "unmount", Description: "Release one share or occasional source now; its automount stays " + - "armed, so the next access mounts it again.", - Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}), - Replaces: []string{"umount"}}, - {Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " + - "line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " + - "the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", - Input: schema(map[string]string{ - "mountpoint": "the mount point whose fstab line is taken over", - "confirm": "\"true\": change it (needs why); anything else is a dry run", - "why": "why, for the record", - }, []string{"mountpoint"}, "confirm"), - Replaces: []string{"sed -i /etc/fstab"}}, - } -} diff --git a/go/catalogue/testdata/seats-before-move.json b/go/catalogue/testdata/seats-before-move.json new file mode 100644 index 0000000..0bd3492 --- /dev/null +++ b/go/catalogue/testdata/seats-before-move.json @@ -0,0 +1,4810 @@ +[ + { + "Name": "mesh-store", + "Scope": "mesh", + "Replicated": false, + "Delivers": "postgres-database", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "databases", + "description": "Every database the store holds, with its on-disk size.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "psql -l" + ] + }, + { + "name": "query", + "description": "One read-only statement against one database the store holds.", + "input": { + "properties": { + "database": { + "description": "the database to query", + "type": "string" + }, + "sql": { + "description": "the read-only statement", + "type": "string" + } + }, + "required": [ + "database", + "sql" + ], + "type": "object" + }, + "replaces": [ + "psql" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0079" + }, + { + "Name": "mesh-broker", + "Scope": "mesh", + "Replicated": false, + "Delivers": "mesh-bus", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0079" + }, + { + "Name": "mesh-vault", + "Scope": "mesh", + "Replicated": false, + "Delivers": "secret", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "get", + "description": "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the bus proved it. Never the value in the clear, and never to a private key.", + "input": { + "properties": { + "name": { + "description": "the secret's name", + "type": "string" + }, + "public_key": { + "description": "the asker's X25519 public key as SPKI PEM — never a private key", + "type": "string" + } + }, + "required": [ + "name", + "public_key" + ], + "type": "object" + } + }, + { + "name": "accept", + "description": "Take a value given at the controller's terminal, from the controller alone: name the secret and get a call and a key made for it, then send the call and the value sealed to that key. The vault alone opens it, and never remakes an accepted credential.", + "input": { + "properties": { + "by": { + "description": "who gave it at the terminal", + "type": "string" + }, + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "module": { + "description": "the module, for a module's own secret", + "type": "string" + }, + "name": { + "description": "the secret's name in the module", + "type": "string" + }, + "node": { + "description": "the machine, for a module's own secret", + "type": "string" + }, + "origin": { + "description": "issued for a bus account the controller minted for a core or moved module and hands at once (ADR 0374, issue 787); left out for a value given at the terminal", + "type": "string" + }, + "provider": { + "description": "the provider's machine, for a provision's credential", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "secret-ask", + "description": "Ask the operator for a module's own secret at a desk, from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR 0563 §8): the ask is recorded in the vault's store and bounded before the prompt opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", + "input": { + "properties": { + "asked_by": { + "description": "who asked, as the bus named its caller", + "type": "string" + }, + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "desk": { + "description": "the machine whose prompt asks", + "type": "string" + }, + "module": { + "description": "the module", + "type": "string" + }, + "name": { + "description": "the secret's name in the module", + "type": "string" + }, + "node": { + "description": "the machine the secret is for", + "type": "string" + }, + "outcome": { + "description": "instead of sealed: how the prompt ended", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "issued", + "description": "Take the credential a provider's software issued for a provision, from the controller alone, which asks the provider's tool with a key the vault made for the call; kept as the provision's one credential, or, when the tool answers a value per holder named, each as that holder's own; the same value again writes nothing.", + "input": { + "properties": { + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "holders": { + "description": "the provision's holders from that provider, each {node, module}: its tool may answer a value for each, kept as that holder's own and handed to it alone", + "items": { + "properties": { + "module": { + "description": "the consuming module", + "type": "string" + }, + "node": { + "description": "the holder's machine", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "module": { + "description": "the provider's module", + "type": "string" + }, + "provider": { + "description": "the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "rotate", + "description": "Make a secret anew. Refused for a value a provider's software issued and for an accepted credential. Never answers the value.", + "input": { + "properties": { + "by": { + "description": "who asked, when the controller carries the act", + "type": "string" + }, + "module": { + "description": "the module", + "type": "string" + }, + "name": { + "description": "the secret's name in the module", + "type": "string" + }, + "node": { + "description": "the machine, for a module's own secret", + "type": "string" + }, + "provider": { + "description": "the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "move-in", + "description": "Take one value of the controller's store in, as it is, from the node-engine of a machine it was sealed to and no other caller: name the row, its origin and generation and get a call and a key made for it, then send the call, the value sealed to that key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read back sealed to that key.", + "input": { + "properties": { + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "consumer": { + "description": "the consumer's machine", + "type": "string" + }, + "consumer_module": { + "description": "the consuming module", + "type": "string" + }, + "generation": { + "description": "the controller's generation for the value", + "type": "string" + }, + "local": { + "description": "the credential's local name, when it has one", + "type": "string" + }, + "made_at": { + "description": "when it was made (RFC 3339)", + "type": "string" + }, + "module": { + "description": "its module", + "type": "string" + }, + "name": { + "description": "its name in the module", + "type": "string" + }, + "node": { + "description": "a module's own secret: its machine", + "type": "string" + }, + "origin": { + "description": "made, accepted or issued, as the controller's store says", + "type": "string" + }, + "provider": { + "description": "a pair credential: the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + }, + "read_back_to": { + "description": "the second step: a raw X25519 public key the read-back is sealed to", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "node-key", + "description": "State this machine's sealing key to the vault, in its own name, from the node-engine of the machine alone: at its start and whenever the key changes. A first or changed key is recorded and the machine's core secrets are said changed; the same key again changes nothing.", + "input": { + "properties": { + "key": { + "description": "the machine's sealing key, a raw X25519 public key in base64", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "own", + "description": "Answer the node-engine of one machine, about its own machine alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A credential the vault does not hold is named missing; never a value in the clear.", + "input": { + "properties": { + "credentials": { + "description": "the pair credentials the machine's composition reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local}", + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + }, + { + "name": "move-asks", + "description": "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once, from the controller alone. No value is in any of them.", + "input": { + "properties": { + "asks": { + "description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome", + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + }, + { + "name": "issue-account", + "description": "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", + "input": { + "properties": { + "user": { + "description": "the bus user, e.g. ace.mesh-build-go", + "type": "string" + } + }, + "required": [ + "user" + ], + "type": "object" + } + }, + { + "name": "sign", + "description": "Sign with the vault's key or authority (novox/hq ADR 0550 §2): kind node-certificate signs the certificate of a machine's internal name, asked by that machine's node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", + "input": { + "properties": { + "kind": { + "description": "node-certificate or enrolment", + "type": "string" + }, + "node": { + "description": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", + "type": "string" + }, + "reply": { + "description": "the redeem answer's bytes in base64, for an enrolment", + "type": "string" + }, + "serving_key": { + "description": "from mesh-enrolment: the serving key's public half the enrolling node presented", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + }, + { + "name": "enrolment-token", + "description": "Make a one-time token for a node mesh-enrolment issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash written as the bus user enrol.\u003cnode\u003e. Answers only what the joining machine must trust (the bus's address, the bus certificate's fingerprint, the vault's public signing key), never the token; with sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", + "input": { + "properties": { + "drop": { + "description": "\"true\": drop the node's token and its hash", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "expires": { + "description": "when the token expires (RFC 3339), as the enrolment's key says", + "type": "string" + }, + "node": { + "description": "the node the token is for", + "type": "string" + }, + "sealed": { + "description": "\"true\": answer the token sealed to the operator key", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "tunnel": { + "additionalProperties": false, + "description": "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", + "properties": { + "address": { + "description": "the joining node's tunnel address", + "type": "string" + }, + "hub_endpoint": { + "description": "the hub's endpoint, host:port", + "type": "string" + }, + "hub_key": { + "description": "the hub's tunnel public key", + "type": "string" + }, + "key": { + "description": "the joining machine's own tunnel public key, as given at issue", + "type": "string" + }, + "range": { + "description": "the private network's range, a prefix", + "type": "string" + } + }, + "required": [ + "address", + "hub_endpoint", + "hub_key", + "key", + "range" + ], + "type": "object" + } + }, + "required": [ + "node" + ], + "type": "object" + } + }, + { + "name": "node-account", + "description": "Mint a joining node's bus account node.\u003cnode\u003e, sealed to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the node's node-logins key naming node.\u003cnode\u003e. Asked by mesh-enrolment alone. Never the value in the clear.", + "input": { + "properties": { + "keys_digest": { + "description": "the digest of every public key the node presented (delivery.KeysDigest)", + "type": "string" + }, + "node": { + "description": "the joining node", + "type": "string" + }, + "retire": { + "description": "\"true\": retire the account minted for this node", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "sealing_key": { + "description": "the sealing key's public half the node presented, which the account is sealed to", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + } + }, + { + "name": "rotate-node", + "description": "Begin a node's account rotation to its other login: answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.", + "input": { + "properties": { + "act": { + "description": "the id of the hand act recorded for it in mesh-hand-acts", + "type": "string" + }, + "node": { + "description": "the node whose account rotates", + "type": "string" + }, + "why": { + "description": "why, in a sentence", + "type": "string" + } + }, + "required": [ + "node", + "why", + "act" + ], + "type": "object" + } + }, + { + "name": "node-account-confirm", + "description": "Confirm a node's switch to its new login: asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. The old login's hash is then removed, and rotation-done said once the bus node's have shows the list without it.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "abandon", + "description": "Abandon a node's rotation before its old login is retired: the new login and its value dropped, the old one kept; a node that switched switches back first.", + "input": { + "properties": { + "node": { + "description": "the node whose rotation is abandoned", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0161", + "Optional": [ + "get", + "accept", + "secret-ask", + "issued", + "rotate", + "move-in", + "node-key", + "own", + "move-asks", + "issue-account", + "sign", + "enrolment-token", + "node-account", + "rotate-node", + "node-account-confirm", + "abandon" + ] + }, + { + "Name": "mesh-config", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "config", + "description": "Set, clear, list or read the history of config values: mesh config or node config for a predefined key, or a module's own value on one node, each set with why, validated, and naming each module on each node whose value moves.", + "input": { + "properties": { + "action": { + "description": "list (when not given), set, clear or history", + "type": "string" + }, + "by": { + "description": "who gave the value at the controller's terminal", + "type": "string" + }, + "key": { + "description": "the config key", + "type": "string" + }, + "limit": { + "description": "for history: at most this many, newest", + "type": "number" + }, + "module": { + "description": "a module: its own value, with node", + "type": "string" + }, + "node": { + "description": "a node", + "type": "string" + }, + "value": { + "description": "for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list" + }, + "why": { + "description": "for set and clear: why", + "type": "string" + } + }, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0348, ADR 0565", + "Optional": [ + "config" + ] + }, + { + "Name": "mesh-provisioner", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "provisioning", + "description": "Read what the provisioner keeps: the registry (which domain answers which kind of placeholder), the needs each module on each node recorded, what is applied where, or the items of every collection, narrowed by node and module. Reads only.", + "input": { + "properties": { + "collection": { + "description": "for collect: a collection", + "type": "string" + }, + "module": { + "description": "a module: for collect, a contributor", + "type": "string" + }, + "node": { + "description": "a node", + "type": "string" + }, + "what": { + "description": "registry (when not given), needs, applied or collect", + "type": "string" + } + }, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0349", + "Optional": [ + "provisioning" + ] + }, + { + "Name": "mesh-artifact-store", + "Scope": "mesh", + "Replicated": false, + "Delivers": "artifact-store", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0075" + }, + { + "Name": "mesh-catalog", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "modules", + "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "module", + "description": "One module whole: its repository, directory, commit and fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.", + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + }, + { + "name": "provides", + "description": "Which modules provide a provision (a module provides its own name too) and which require it, from the module graph's provision edges. Reads only.", + "input": { + "properties": { + "provision": { + "description": "the provision, such as postgres-database", + "type": "string" + } + }, + "required": [ + "provision" + ], + "type": "object" + } + }, + { + "name": "dependents", + "description": "The modules whose build edges reach a module, directly or through others (a base, a mesh package it publishes, the build module that builds them): what a change of it builds again, from the module graph. Reads only.", + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0604", + "Optional": [ + "modules", + "module", + "provides", + "dependents" + ] + }, + { + "Name": "mesh-delivery", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "deliveries", + "description": "Every delivery not final, and those that ended in the last day, one line each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, repository or group.", + "input": { + "properties": { + "all": { + "description": "\"true\": the final ones of the last thirty days too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "group": { + "description": "a group's id (its branch name)", + "type": "string" + }, + "repository": { + "description": "owner/repository", + "type": "string" + }, + "state": { + "description": "one state, e.g. delivering or held", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "times", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the delivery view (nox-delivery): each machine's want and have, and since when", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "show", + "description": "One delivery or group whole: its delivery plan (what it builds, what each machine receives, what is not an ordinary send), every transition with when and why, the machine steps of its walk, its group and its order.", + "input": { + "properties": { + "id": { + "description": "a delivery's id, or a group's", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + { + "name": "groups", + "description": "Every delivery group: its members in order, why each pair is ordered (declared, built by, version skew, engine before controller, by name), its composed check and its state, derived from its members.", + "input": { + "properties": { + "all": { + "description": "\"true\": the groups that ended too", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "what-if", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the merge check's composition, which composes every machine a change touches", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "checks", + "description": "What the mesh's checks said of a pull request's head or of one commit: each of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, mesh/delivery, …) with its state, description and when it was set; the merge check's full verdict as the controller said it — each layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's protection would let it merge, every required status being success.", + "input": { + "properties": { + "commit": { + "description": "a commit's sha, or the start of one, instead of a pull request", + "type": "string" + }, + "number": { + "description": "a pull request's number: its head is read", + "type": "string" + }, + "repository": { + "description": "owner/repository", + "type": "string" + } + }, + "required": [ + "repository" + ], + "type": "object" + } + }, + { + "name": "table", + "description": "The state table every delivery runs by: each transition with its guard, each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "stalled", + "description": "Every delivery held past its state's bound, with the transition the table lets healer H2 take for it, or none.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "recheck", + "description": "Check a rejected or ready delivery again: it goes back to proposed and the controller is asked for its check. With why.", + "input": { + "properties": { + "id": { + "description": "the delivery's id", + "type": "string" + }, + "why": { + "description": "why, kept with the transition", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + } + }, + { + "name": "release", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "stop", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "close", + "description": "Healer H2's verb: take the one transition the table names for a delivery held past its bound. Refused for any other.", + "input": { + "properties": { + "id": { + "description": "the delivery's id", + "type": "string" + }, + "why": { + "description": "the condition it answers", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + } + }, + { + "name": "retire-history", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: nothing to retire: the gate keeps no delivery after its merge", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "settle", + "description": "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another reason, naming why.", + "input": { + "additionalProperties": false, + "properties": { + "ids": { + "description": "the held deliveries' ids (owner/repository@commit)", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "why": { + "description": "why they may be settled, shown to the operator", + "type": "string" + } + }, + "required": [ + "ids", + "why" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0239", + "Optional": [ + "times", + "what-if", + "checks", + "release", + "stop", + "retire-history", + "settle" + ] + }, + { + "Name": "mesh-placement", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "placement", + "description": "Read placement: one node's key, the nodes one module is placed on, or every key, each with its revision, and how full the state is against its cap. With history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the proposal's changes from its placement-changed event.", + "input": { + "properties": { + "history": { + "description": "\"true\": the changes in order, from the stream and the state's revisions", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module: the nodes it is placed on", + "type": "string" + }, + "node": { + "description": "one node", + "type": "string" + }, + "seats": { + "description": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "proposals", + "description": "Every open proposal of placement: its changes, the revision of each key it was laid over, its candidates, every node's answer, the differences computed and answered, its verdict and its ask. Narrowed to one by id.", + "input": { + "properties": { + "id": { + "description": "one proposal's id", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "propose", + "description": "Propose a change of placement, with why: laid over the current keys and refused at once, naming every finding, when the result cannot be placed (two modules on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", + "input": { + "additionalProperties": false, + "properties": { + "changes": { + "description": "the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)", + "items": { + "required": [ + "op", + "node" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "warrant": { + "description": "for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address", + "required": [ + "node", + "address", + "ask", + "warrant" + ], + "type": "object" + }, + "why": { + "description": "why, shown to the operator with the proposal", + "type": "string" + } + }, + "required": [ + "why", + "changes" + ], + "type": "object" + } + }, + { + "name": "withdraw", + "description": "Withdraw an open proposal: its key and its candidates are deleted and placement-withdrawn carries it whole. With why.", + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + }, + "why": { + "description": "why, kept in the event", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + } + }, + { + "name": "again", + "description": "Judge an open proposal now with the answers that stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. Nothing more: the recovery of a proposal whose judgement or ask was lost.", + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0567", + "Optional": [ + "placement", + "proposals", + "propose", + "withdraw", + "again" + ] + }, + { + "Name": "mesh-delivery-checker", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "try", + "description": "Try a pushed head before its pull request is opened: the checker judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most 4 wait or run at once, mesh-wide.", + "input": { + "properties": { + "head": { + "description": "the full commit to try, 40 or 64 hex digits", + "type": "string" + }, + "level": { + "description": "unit or integration: only this level; both when absent", + "type": "string" + }, + "repository": { + "description": "\u003cowner\u003e/\u003cname\u003e", + "type": "string" + }, + "tests": { + "description": "a go test -run pattern: only those tests run, and nothing is kept", + "type": "string" + }, + "trunk": { + "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "type": "string" + } + }, + "required": [ + "repository", + "head" + ], + "type": "object" + }, + "replaces": [ + "sh merge-check.sh", + "docker run postgres" + ] + }, + { + "name": "try-read", + "description": "Where a try stands, as stored: queued, running, or its end (pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit it was merged onto, the time it took and what failed first. Read-only.", + "input": { + "properties": { + "id": { + "description": "the try's id, try-\u003cdigits\u003e", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + { + "name": "try-end", + "description": "End a try that waits or runs, as error, by hand: say why (kept in what it says). Its place among the tries is free at once and the next try of its head asks anew with a new id. A try that ended already is refused.", + "input": { + "properties": { + "id": { + "description": "the try's id, try-\u003cdigits\u003e", + "type": "string" + }, + "why": { + "description": "why it is ended, in words", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0547", + "Optional": [ + "try", + "try-read", + "try-end" + ] + }, + { + "Name": "mesh-conditions", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "condition-raised", + "condition-changed", + "condition-cleared" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "conditions", + "description": "What is wrong with the mesh now: every open condition, urgent first, then oldest, raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition lately.", + "input": { + "properties": { + "days": { + "description": "with history: how many days back (default 7, at most 90)", + "type": "string" + }, + "history": { + "description": "\"true\": every raising, change, silence and clearing lately, oldest first", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "key": { + "description": "a condition's key: that one whole; with history, only its transitions", + "type": "string" + }, + "machine": { + "description": "only those about this machine", + "type": "string" + }, + "scope": { + "description": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", + "type": "string" + }, + "severity": { + "description": "only urgent, or only warning", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "silence", + "description": "Stop one condition's messages for a while, a hand act with why: it stays open and in status, and the silence is kept as its transition in history.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word (the condition's kind when absent)", + "type": "string" + }, + "for": { + "description": "how long: 30m, 4h, 2d; at most 7d", + "type": "string" + }, + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why, required and recorded", + "type": "string" + } + }, + "required": [ + "key", + "for", + "why" + ], + "type": "object" + } + }, + { + "name": "resync", + "description": "Read every observer's findings again and raise and clear what differs from the open conditions: the recovery of a keeper that missed a change.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0564" + }, + { + "Name": "mesh-watchdogs", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "signals", + "description": "Every watchdog of the signals table the watchman keeps, each a key that expires unless its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its expiry wrote.", + "input": { + "properties": { + "row": { + "description": "one row, e.g. S1", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "stuck", + "description": "Every want, build or step the watchman says stopped moving past its bound, with since when and the words of its finding.", + "input": { + "properties": { + "machine": { + "description": "only those on this machine", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "presence", + "description": "Every machine and tool runner the watchman sees connected, with its last word, and those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", + "input": { + "properties": { + "machine": { + "description": "one machine", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "dead-letters", + "description": "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one whole. With deliver or drop, a hand act with why.", + "input": { + "properties": { + "cause": { + "description": "with deliver or drop: the cause in a word (dead-letter when absent)", + "type": "string" + }, + "consumer": { + "description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on", + "type": "string" + }, + "deliver": { + "description": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", + "type": "string" + }, + "drop": { + "description": "a dead letter's id: drop it for good (needs why)", + "type": "string" + }, + "id": { + "description": "a dead letter's id, as the list gives it: that one whole, with its message", + "type": "string" + }, + "limit": { + "description": "how many to list, newest first (default 50); only when listing", + "type": "string" + }, + "why": { + "description": "with deliver or drop: why, required and recorded", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "resync", + "description": "Rebuild presence from the bus's connections, and the pulses and findings from the keys the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0564" + }, + { + "Name": "mesh-self-check", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "checked" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "doctor", + "description": "The self-check: the last run's verdict at once, each probe passed, failed, deferred (its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one probe's verdict whole. Runs every five minutes on its own; each failure is a finding.", + "input": { + "properties": { + "probe": { + "description": "one probe's id (D6): its verdict alone, with every finding", + "type": "string" + }, + "run": { + "description": "\"true\": run every probe now and answer the verdict", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "probes", + "description": "The registry: what each probe asserts, where it reads its facts, and the finding it writes.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0564" + }, + { + "Name": "mesh-healers", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "healer-acted" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "healers", + "description": "The healers: each registered response to one kind of condition, its repair, its budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.", + "input": { + "properties": { + "days": { + "description": "how many days of acts back (default 7)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "heal", + "description": "Take one healer's act for one open condition now, within its budget and the brake.", + "input": { + "properties": { + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why now, kept with the act", + "type": "string" + } + }, + "required": [ + "key", + "why" + ], + "type": "object" + } + }, + { + "name": "drill", + "description": "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it tests. A drill is never a repair: no healer is wanted for it however often it is run.", + "input": { + "properties": { + "condition": { + "description": "the key of the condition the drill is meant to raise, if any (optional)", + "type": "string" + }, + "what": { + "description": "what was done on purpose, in a line", + "type": "string" + }, + "why": { + "description": "what the drill tests", + "type": "string" + } + }, + "required": [ + "what", + "why" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0564" + }, + { + "Name": "mesh-wants", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "policy-changed" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "upgrade", + "description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + "input": { + "properties": { + "compare": { + "description": "\"true\": each node's own composition, by its digest, and the assigner's findings", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module", + "type": "string" + }, + "policy": { + "description": "with module: roll, together, record, or default to take a person's choice back", + "type": "string" + }, + "why": { + "description": "with policy: why, required and kept", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "bus", + "description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0565" + }, + { + "Name": "mesh-hand-acts", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "act-recorded" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "hand-act", + "description": "Record one act done by hand: what, why, its cause in a word or a condition's kind (the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the runtime proves, never said by the caller. Said as act-recorded.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word, or a condition's kind", + "type": "string" + }, + "condition": { + "description": "the key of the condition it addressed, if any (optional)", + "type": "string" + }, + "verb": { + "description": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + "type": "string" + }, + "what": { + "description": "what was done, in a line", + "type": "string" + }, + "why": { + "description": "why it had to be done by hand", + "type": "string" + } + }, + "required": [ + "what", + "why" + ], + "type": "object" + } + }, + { + "name": "hand-acts", + "description": "What was done by hand lately, over this log and the controller's: who, why and the cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + "input": { + "properties": { + "cause": { + "description": "only this cause", + "type": "string" + }, + "days": { + "description": "how many days back (default 14)", + "type": "string" + }, + "warrants": { + "description": "\"true\": every warrant recorded", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "who": { + "description": "only acts whose who contains this", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "warranted", + "description": "Record what a module did on the operator's warrant: who chose, through which channel, with which proofs and which answer read from the router's own record of that module's ask, never from the caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + "input": { + "properties": { + "ask": { + "description": "its ask's id", + "type": "string" + }, + "asker": { + "description": "the module that asked", + "type": "string" + } + }, + "required": [ + "asker", + "ask" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0565" + }, + { + "Name": "mesh-enrolment", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "enrolment-issued", + "node-enrolled", + "enrolment-refused", + "enrolment-revoked", + "enrolment-expired" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "issue", + "description": "Issue a one-time token for a new machine, on the operator's Approve: refused at once, naming every finding, for a name that is not a node name or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise reserves the lowest free private-network address, asks the operator under the seat's own name and, on the warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.", + "input": { + "properties": { + "expires": { + "description": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", + "type": "string" + }, + "node": { + "description": "the new machine's name in the mesh", + "type": "string" + }, + "overlay_key": { + "description": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", + "type": "string" + }, + "why": { + "description": "why it joins, shown to the operator with the ask", + "type": "string" + } + }, + "required": [ + "node", + "why", + "overlay_key" + ], + "type": "object" + } + }, + { + "name": "redeem", + "description": "Spend a token: asked by a joining node-engine dialled as enrol.\u003cnode\u003e alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the vault for the node's account and certificate, writes the node into placement, and answers the account sealed to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys it finishes what is not done and answers the same; with other keys it is refused.", + "input": { + "additionalProperties": false, + "properties": { + "node": { + "description": "the node the token was issued for", + "type": "string" + }, + "overlay_key": { + "description": "the private network key's public half", + "type": "string" + }, + "profile": { + "description": "what the machine is, as its record says it", + "type": "object" + }, + "proof": { + "description": "the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64", + "type": "string" + }, + "public_key": { + "description": "the identity key's public half, base64", + "type": "string" + }, + "sealing_key": { + "description": "the sealing key's public half, which the account is sealed to", + "type": "string" + }, + "serving_key": { + "description": "the serving key's public half, which the certificate binds", + "type": "string" + }, + "tunnel": { + "description": "a tunnel found on the machine: everything but its private key", + "type": "object" + } + }, + "required": [ + "node", + "public_key", + "sealing_key", + "proof" + ], + "type": "object" + } + }, + { + "name": "confirm", + "description": "Confirm an enrolment: asked by the node-engine dialled as its own node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the enrolment.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "revoke", + "description": "End an enrolment at any stage, with why, without an Approve: withdraws an open ask, deletes the node's key and its address, has the vault retire an account it already minted for it, records the act as a hand act and says enrolment-revoked.", + "input": { + "properties": { + "node": { + "description": "the node", + "type": "string" + }, + "why": { + "description": "why, recorded with the hand act", + "type": "string" + } + }, + "required": [ + "node", + "why" + ], + "type": "object" + } + }, + { + "name": "tokens", + "description": "Read every open enrolment, or one: its stage, expiry, ask and revision, the bucket's fill against its cap. For a node at live, its token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With history, the ended enrolments from the seat's events, as far back as the events are kept.", + "input": { + "properties": { + "history": { + "description": "\"true\": the ended enrolments too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "node": { + "description": "one node", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "again", + "description": "Raise an issue's ask again when the operator's channel does not hold it, and nothing more.", + "input": { + "properties": { + "node": { + "description": "the node whose ask is raised again", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0587" + }, + { + "Name": "mesh-artifact-sweep", + "Scope": "mesh", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "artifacts-gone" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "artifacts", + "description": "Every artifact the mesh made that lives in its artifact store, each with its repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", + "input": { + "properties": { + "collected": { + "description": "\"true\": list the collected references too (counted always)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "repository": { + "description": "only this repository's references, as \u003cmodule\u003e/\u003cartifact\u003e (the counts stay whole)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "held", + "description": "Whether the artifact store holds each reference given, asked with one HEAD each: held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. Reads only.", + "input": { + "properties": { + "references": { + "description": "the artifacts' references, artifact-store://\u003cmodule\u003e/\u003cartifact\u003e/blobs/sha256:\u003chex\u003e: a list, or one text separated by commas", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "references" + ], + "type": "object" + } + }, + { + "name": "collect", + "description": "The artifact store's sweep, now: what is eligible is let go of. Without confirm a dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 seconds; what is left is said. Never touches a digest the mesh did not make.", + "input": { + "properties": { + "confirm": { + "description": "\"true\": let go of what is eligible (needs why); without it nothing is done", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "most": { + "description": "let go of at most this many (500 by default, at most 5000)", + "type": "string" + }, + "why": { + "description": "with confirm: why, required and recorded", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "mirrors", + "description": "The bases the mesh copied into its artifact store, each kept (a kept build stood on it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "images", + "description": "Every container image one machine's want and have name, each with the resources naming it: what the machine keeps when it prunes its images. Reads only.", + "input": { + "properties": { + "node": { + "description": "the machine's name", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0571" + }, + { + "Name": "npm-package-registry", + "Scope": "mesh", + "Replicated": false, + "Delivers": "npm-package-registry", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0109" + }, + { + "Name": "git", + "Scope": "mesh", + "Replicated": false, + "Delivers": "git", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0111" + }, + { + "Name": "node-build-go", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": [ + "delivery", + "check", + "recheck" + ], + "Emits": null, + "PulledByKind": true, + "Yields": true, + "Serves": [ + { + "name": "current", + "description": "The Go asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "pause", + "description": "Take no new Go ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "resume", + "description": "Take Go asks again on this node.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0525, ADR 0536, ADR 0553" + }, + { + "Name": "node-build-node", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": [ + "delivery", + "check", + "recheck" + ], + "Emits": null, + "PulledByKind": true, + "Yields": true, + "Serves": [ + { + "name": "current", + "description": "The Node asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "pause", + "description": "Take no new Node ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "resume", + "description": "Take Node asks again on this node.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0525, ADR 0536, ADR 0553" + }, + { + "Name": "node-build-image", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": [ + "delivery", + "check", + "recheck" + ], + "Emits": null, + "PulledByKind": true, + "Yields": true, + "Serves": [ + { + "name": "current", + "description": "The image asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "pause", + "description": "Take no new image ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "resume", + "description": "Take image asks again on this node.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0525, ADR 0536, ADR 0553" + }, + { + "Name": "mesh-dns-resolver", + "Scope": "mesh", + "Replicated": true, + "Delivers": "wildcard-resolution", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0194, ADR 0223" + }, + { + "Name": "node-hostname", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "entries", + "description": "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "cat /etc/hosts", + "getent hosts" + ] + }, + { + "name": "add", + "description": "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's.", + "input": { + "properties": { + "address": { + "description": "the IPv4 or IPv6 address", + "type": "string" + }, + "names": { + "description": "the names for it, separated by spaces", + "type": "string" + } + }, + "required": [ + "address", + "names" + ], + "type": "object" + }, + "replaces": [ + "edit /etc/hosts", + "HOSTALIASES" + ] + }, + { + "name": "remove", + "description": "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", + "input": { + "properties": { + "name": { + "description": "a host name, or an address to remove every line of", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "replaces": [ + "sed -i /etc/hosts" + ] + } + ], + "Receives": null, + "Paths": [ + { + "path": "/etc/hosts" + }, + { + "path": "/etc/hostname" + } + ], + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0199, ADR 0223" + }, + { + "Name": "node-intrusion-prevention", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "status", + "description": "Every jail on this machine with how many it is watching and holding now, and the totals since the jail started; one jail's detail when named.", + "input": { + "properties": { + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "fail2ban-client status" + ] + }, + { + "name": "banned", + "description": "Every address banned on this machine right now, with the jail that holds it and when the ban ends.", + "input": { + "properties": { + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "fail2ban-client banned" + ] + }, + { + "name": "ban", + "description": "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.", + "input": { + "properties": { + "ip": { + "description": "the address", + "type": "string" + }, + "jail": { + "description": "the jail to hold it", + "type": "string" + } + }, + "required": [ + "ip", + "jail" + ], + "type": "object" + }, + "replaces": [ + "fail2ban-client set banip" + ] + }, + { + "name": "unban", + "description": "Let one address go, from one jail or from every jail when none is named.", + "input": { + "properties": { + "ip": { + "description": "the address", + "type": "string" + }, + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "required": [ + "ip" + ], + "type": "object" + }, + "replaces": [ + "fail2ban-client unban", + "fail2ban-client set unbanip" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0121" + }, + { + "Name": "node-packet-filter", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "rules", + "description": "The packet filter as this machine enforces it now: the nftables ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or chain when asked.", + "input": { + "properties": { + "chain": { + "description": "one chain of that table (optional)", + "type": "string" + }, + "table": { + "description": "one nftables table, as `family name` (optional)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "nft list ruleset", + "iptables -L", + "iptables-save" + ] + }, + { + "name": "reload", + "description": "Load the mesh's own filter again from the file the mesh writes, and answer with the mesh's table as loaded.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "nft -f" + ] + }, + { + "name": "remove", + "description": "Remove one rule set the mesh did not write, named exactly as the host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an active found firewall's chains. An operator's act, by name, never a flush.", + "input": { + "properties": { + "where": { + "description": "the rule set, as `node show` lists it", + "type": "string" + } + }, + "required": [ + "where" + ], + "type": "object" + }, + "replaces": [ + "nft delete", + "iptables -X" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0121" + }, + { + "Name": "node-service-manager", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "units", + "description": "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", + "input": { + "properties": { + "pattern": { + "description": "a glob the unit's name must match (optional)", + "type": "string" + }, + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "systemctl list-units" + ] + }, + { + "name": "status", + "description": "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl status", + "systemctl is-active" + ] + }, + { + "name": "start", + "description": "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl start" + ] + }, + { + "name": "stop", + "description": "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl stop" + ] + }, + { + "name": "restart", + "description": "Restart one unit.", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl restart" + ] + }, + { + "name": "enable", + "description": "Make one unit start at boot (or at the account's login, in user scope).", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl enable" + ] + }, + { + "name": "disable", + "description": "Stop one unit starting at boot (or at login, in user scope).", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl disable" + ] + }, + { + "name": "journal", + "description": "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as [redacted: \u003cwhat it was\u003e].", + "input": { + "properties": { + "lines": { + "description": "how many lines from the end of what matches (default 100, at most 2000)", + "type": "string" + }, + "match": { + "description": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", + "type": "string" + }, + "priority": { + "description": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", + "type": "string" + }, + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "since": { + "description": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + }, + "until": { + "description": "the window's end, in the same forms (optional; now when absent)", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "journalctl" + ] + }, + { + "name": "failed", + "description": "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, never as nothing failed.", + "input": { + "properties": { + "scope": { + "description": "\"system\" or \"user\": only that manager (both when absent)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "systemctl --failed" + ] + }, + { + "name": "reset-failed", + "description": "Clear one unit's failed record in its manager — its failed state and its start-limit count — and answer its state after. It removes no file and starts nothing.", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl reset-failed" + ] + }, + { + "name": "wanted-by", + "description": "What wants, requires or triggers one unit, read-only: the manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), each with where it points and whether it dangles — the account's own directory included in user scope.", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + }, + "replaces": [ + "systemctl list-dependencies --reverse", + "systemctl show -p WantedBy", + "ls ~/.config/systemd/user/*.wants" + ] + }, + { + "name": "unlink-dangling", + "description": "Remove the dangling enable links named for one unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the runtime placed, is left and said.", + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + }, + "why": { + "description": "the person's reason for removing the links, kept in the record", + "type": "string" + } + }, + "required": [ + "unit", + "why" + ], + "type": "object" + }, + "replaces": [ + "rm ~/.config/systemd/user/*.wants/\u003cunit\u003e", + "unlink /etc/systemd/system/*.wants/\u003cunit\u003e" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0177", + "Optional": [ + "failed" + ] + }, + { + "Name": "node-package-manager", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0207" + }, + { + "Name": "node-container-runtime", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0166, ADR 0207" + }, + { + "Name": "node-environment", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0203" + }, + { + "Name": "node-provisioner", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0349" + }, + { + "Name": "node-login-shell", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "execute", + "description": "Run one command on this machine as the operator account, in a non-interactive login shell in its home; answers with what it printed and how it exited.", + "input": { + "properties": { + "command": { + "description": "the command line, as you would type it", + "type": "string" + }, + "timeout_seconds": { + "description": "give up after this long, at most 25 (default 20)", + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0204", + "Optional": [ + "execute" + ] + }, + { + "Name": "node-power", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0211" + }, + { + "Name": "node-hotkeys", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": [ + { + "Kind": "trigger", + "Comment": "#", + "Dirs": false, + "Shape": null, + "Offered": false + } + ], + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0212" + }, + { + "Name": "node-message-bus", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0215" + }, + { + "Name": "node-backup", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "backed-up", + "description": "What this machine backs up: each module, what it declared, its last good night, how many restore points are kept and the repository's size.", + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "restic snapshots" + ] + }, + { + "name": "now", + "description": "Take a backup now, of one module or of every module on this machine — before a migration, a retirement or anything else that could go wrong.", + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "restic backup" + ] + }, + { + "name": "restore", + "description": "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as \u003cpath\u003e.restored-\u003cdate\u003e. Swapping it in is a person's act. Lists the restore points when none is named.", + "input": { + "properties": { + "module": { + "description": "the module", + "type": "string" + }, + "path": { + "description": "one of the module's directories (all of them when omitted)", + "type": "string" + }, + "snapshot": { + "description": "the restore point (from `backed-up`; the newest when omitted)", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + }, + "replaces": [ + "restic restore" + ] + } + ], + "Receives": [ + { + "Kind": "backup", + "Comment": "#", + "Dirs": true, + "Shape": null, + "Offered": false + }, + { + "Kind": "data", + "Comment": "#", + "Dirs": true, + "Shape": null, + "Offered": false + } + ], + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0214" + }, + { + "Name": "the-private-network", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0110" + }, + { + "Name": "node-uplink", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "resolvers", + "description": "The machine's resolver file as it is now: the resolvers, search domains and options it lists, whether it is the file the mesh declares, and when it is not, who wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link in its place, a known writer running.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "links", + "description": "Every network link on the machine: its state, its addresses, whether the default route leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's tunnel included.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": [ + { + "path": "/etc/resolv.conf", + "steps-back-for": "node-resolver" + } + ], + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0117", + "Optional": [ + "resolvers", + "links" + ] + }, + { + "Name": "node-resolver", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "routes", + "description": "What this machine's own resolver sends where: the mesh's resolvers, which answer every name not routed elsewhere, and each link given servers of its own with the domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who wrote it as far as the file says, whether a module took it, and when the resolver's own file was put back.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "resolvectl status", + "resolvectl dns", + "resolvectl domain" + ] + }, + { + "name": "route", + "description": "Send these domains, and every name under them, to these servers over this link — and only them: the link is never the machine's default route for names, and the mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away takes its route with it.", + "input": { + "properties": { + "domains": { + "description": "the domains to route there, separated by spaces or commas", + "type": "string" + }, + "link": { + "description": "the network link the servers are reached over, by name (a VPN's tunnel interface)", + "type": "string" + }, + "servers": { + "description": "the servers' addresses, separated by spaces or commas", + "type": "string" + } + }, + "required": [ + "link", + "domains", + "servers" + ], + "type": "object" + }, + "replaces": [ + "resolvectl dns", + "resolvectl domain", + "resolvectl default-route" + ] + }, + { + "name": "unroute", + "description": "Take one link's route away: its domains go back to the mesh's resolvers. Nothing changes when the link has none.", + "input": { + "properties": { + "link": { + "description": "the network link, by name", + "type": "string" + } + }, + "required": [ + "link" + ], + "type": "object" + }, + "replaces": [ + "resolvectl revert" + ] + } + ], + "Receives": null, + "Paths": [ + { + "path": "/etc/resolv.conf" + } + ], + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0247" + }, + { + "Name": "node-nfs-server", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "exports", + "description": "Every share this machine exports: its name, its path, read-write or read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that node's private address and access (only the nodes the server grants it to and that ask for it), what is granted and not asked for or asked for and not granted, and whether the kernel holds it now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in /etc/exports.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "exportfs -v", + "cat /etc/exports", + "zfs get sharenfs" + ] + }, + { + "name": "clients", + "description": "Which machines have mounted which share now, as the NFS server knows its clients.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "ss -tn sport = :2049", + "cat /proc/fs/nfsd/clients/*/info" + ] + }, + { + "name": "test", + "description": "Whether this machine exports one share for the mesh now, and whether its NFS service is up; with an address, also whether the share is exported to that address: a node's own private address, when the server grants it the share and the node asks for it. What a machine mounting the share asks before it mounts.", + "input": { + "properties": { + "address": { + "description": "a node's private address, to ask whether the share is exported to it (optional)", + "type": "string" + }, + "share": { + "description": "the share, by its name", + "type": "string" + } + }, + "required": [ + "share" + ], + "type": "object" + }, + "replaces": [ + "showmount -e" + ] + }, + { + "name": "reload", + "description": "Have the kernel read this machine's export files again now (exportfs -ra) and answer the shares as it then holds them. The module's own process writes its export file; this is for after a change made outside it. Changes no shared path.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "exportfs -ra" + ] + }, + { + "name": "adopt", + "description": "Take over an export a person made by hand: clear a dataset's sharenfs property for a path the mesh's own export now serves — only when that export is live. Without confirm, says what it would do and changes nothing.", + "input": { + "properties": { + "confirm": { + "description": "\"true\": change it (needs why); anything else is a dry run", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "the shared path whose hand-made export is taken over", + "type": "string" + }, + "why": { + "description": "why, for the record", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "replaces": [ + "zfs set sharenfs=off" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0263" + }, + { + "Name": "node-mounts", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "list", + "description": "Every mount point on this machine: its fstab line, the mesh's mount and automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, and which settings asked for it. A password in a mount's options is never shown.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "cat /etc/fstab", + "findmnt", + "systemctl list-units --type=mount" + ] + }, + { + "name": "test", + "description": "Whether one share's or occasional source's server answers from this machine now, without touching its mount point.", + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "replaces": [ + "showmount -e", + "ping" + ] + }, + { + "name": "mount", + "description": "Mount one share or occasional source now, rather than at its first access.", + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "replaces": [ + "mount" + ] + }, + { + "name": "unmount", + "description": "Release one share or occasional source now; its automount stays armed, so the next access mounts it again.", + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "replaces": [ + "umount" + ] + }, + { + "name": "adopt", + "description": "Take over a mount a person wrote by hand: comment out the /etc/fstab line for a mount point the mesh's own units now serve, keeping a copy of the file — only when the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", + "input": { + "properties": { + "confirm": { + "description": "\"true\": change it (needs why); anything else is a dry run", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "mountpoint": { + "description": "the mount point whose fstab line is taken over", + "type": "string" + }, + "why": { + "description": "why, for the record", + "type": "string" + } + }, + "required": [ + "mountpoint" + ], + "type": "object" + }, + "replaces": [ + "sed -i /etc/fstab" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0263" + }, + { + "Name": "node-reads", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "stat", + "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which holds no secret: a component it may not reach says so. At most 200 paths in one call.", + "input": { + "properties": { + "follow": { + "description": "\"true\": also say where the path leads once every link is followed", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "paths": { + "description": "one absolute path, or several separated by line breaks", + "type": "string" + } + }, + "required": [ + "paths" + ], + "type": "object" + }, + "replaces": [ + "stat", + "ls -ld", + "namei -l", + "findmnt -T" + ] + }, + { + "name": "readlink", + "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists.", + "input": { + "properties": { + "path": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "replaces": [ + "readlink", + "readlink -f", + "realpath" + ] + }, + { + "name": "list", + "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name.", + "input": { + "properties": { + "path": { + "description": "an absolute path to a directory", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "replaces": [ + "ls", + "ls -la" + ] + }, + { + "name": "read", + "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may.", + "input": { + "properties": { + "bytes": { + "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + "type": "string" + }, + "from": { + "description": "the first line to answer, counted from 1 (optional)", + "type": "string" + }, + "lines": { + "description": "how many lines to answer (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "replaces": [ + "cat", + "head", + "tail", + "less" + ] + }, + { + "name": "search", + "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met.", + "input": { + "properties": { + "case": { + "description": "\"false\": match whatever the case (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "depth": { + "description": "how many directories deep, up to 8 (optional)", + "type": "string" + }, + "matches": { + "description": "the most matches to answer, up to 500 (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file or a directory", + "type": "string" + }, + "text": { + "description": "the fixed string to find", + "type": "string" + } + }, + "required": [ + "text", + "path" + ], + "type": "object" + }, + "replaces": [ + "grep", + "grep -r", + "grep -F" + ] + }, + { + "name": "account", + "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field.", + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "type": "string" + }, + "name": { + "description": "an account's name or number (optional)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "getent passwd", + "getent group", + "id", + "groups" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0310" + }, + { + "Name": "node-files", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "stat", + "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". At most 200 paths in one call. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "follow": { + "description": "\"true\": also say where the path leads once every link is followed", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "paths": { + "description": "one absolute path, or several separated by line breaks", + "type": "string" + } + }, + "required": [ + "paths" + ], + "type": "object" + }, + "replaces": [ + "stat", + "ls -ld", + "namei -l", + "findmnt -T" + ] + }, + { + "name": "readlink", + "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "path": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "replaces": [ + "readlink", + "readlink -f", + "realpath" + ] + }, + { + "name": "list", + "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "path": { + "description": "an absolute path to a directory", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "replaces": [ + "ls", + "ls -la" + ] + }, + { + "name": "read", + "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "bytes": { + "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + "type": "string" + }, + "from": { + "description": "the first line to answer, counted from 1 (optional)", + "type": "string" + }, + "lines": { + "description": "how many lines to answer (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "replaces": [ + "cat", + "head", + "tail", + "less" + ] + }, + { + "name": "search", + "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "case": { + "description": "\"false\": match whatever the case (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "depth": { + "description": "how many directories deep, up to 8 (optional)", + "type": "string" + }, + "matches": { + "description": "the most matches to answer, up to 500 (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file or a directory", + "type": "string" + }, + "text": { + "description": "the fixed string to find", + "type": "string" + } + }, + "required": [ + "text", + "path" + ], + "type": "object" + }, + "replaces": [ + "grep", + "grep -r", + "grep -F" + ] + }, + { + "name": "account", + "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "type": "string" + }, + "name": { + "description": "an account's name or number (optional)", + "type": "string" + } + }, + "type": "object" + }, + "replaces": [ + "getent passwd", + "getent group", + "id", + "groups" + ] + }, + { + "name": "write", + "description": "Write one text file whole, replacing what it holds; the old content goes to the node's trash. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "content": { + "description": "the text to write", + "type": "string" + }, + "mode": { + "description": "the file's mode, such as 0644 (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "path", + "content", + "reason" + ], + "type": "object" + }, + "replaces": [ + "tee", + "\u003e" + ] + }, + { + "name": "copy", + "description": "Copy a file or directory to a path on this node. A target on another node (\u003cnode\u003e:/path) is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "from": { + "description": "an absolute path", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "to": { + "description": "an absolute path on this node", + "type": "string" + } + }, + "required": [ + "from", + "to", + "reason" + ], + "type": "object" + }, + "replaces": [ + "cp", + "cp -a", + "ssh \u003cnode\u003e cp" + ] + }, + { + "name": "move", + "description": "Move or rename a file or directory. A target on another node is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "from": { + "description": "an absolute path", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "to": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "from", + "to", + "reason" + ], + "type": "object" + }, + "replaces": [ + "mv" + ] + }, + { + "name": "delete", + "description": "Delete a file or directory into the node's trash, where restore can bring it back. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": delete a directory with what it holds", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "reason" + ], + "type": "object" + }, + "replaces": [ + "rm", + "rm -r" + ] + }, + { + "name": "mkdir", + "description": "Make a directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "parents": { + "description": "\"true\": make the missing parents too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "path", + "reason" + ], + "type": "object" + }, + "replaces": [ + "mkdir", + "mkdir -p" + ] + }, + { + "name": "chmod", + "description": "Change the mode of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "mode": { + "description": "the mode, such as 0644 or u+x", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": also everything under a directory", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "mode", + "reason" + ], + "type": "object" + }, + "replaces": [ + "chmod" + ] + }, + { + "name": "chown", + "description": "Change the owner and group of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "owner": { + "description": "an account's name or number", + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": also everything under a directory", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "owner", + "reason" + ], + "type": "object" + }, + "replaces": [ + "chown", + "chgrp" + ] + }, + { + "name": "upload", + "description": "Send one file of this node into a bucket of the object store, over a short signed link and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, before anything is sent. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "bucket": { + "description": "the bucket the file goes into", + "type": "string" + }, + "key": { + "description": "the object's key (optional; the file's SHA-256 when not given)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path", + "bucket" + ], + "type": "object" + } + }, + { + "name": "download", + "description": "Write one object of a bucket to a file of this node, over a short signed link and never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "bucket": { + "description": "the bucket", + "type": "string" + }, + "key": { + "description": "the object's key", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "sha256": { + "description": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)", + "type": "string" + } + }, + "required": [ + "bucket", + "key", + "path", + "reason" + ], + "type": "object" + } + }, + { + "name": "trash", + "description": "What the node's trash holds: each deleted or replaced item with its id, original path and when it went.", + "input": { + "properties": {}, + "type": "object" + }, + "replaces": [ + "ls ~/.local/share/Trash" + ] + }, + { + "name": "restore", + "description": "Put an item of the node's trash back where it was; refuses when something is there now. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "id": { + "description": "an id from trash", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "id", + "reason" + ], + "type": "object" + }, + "replaces": [ + "trash-restore" + ] + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0394" + }, + { + "Name": "node-engine", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": [ + "asked", + "done", + "refused" + ], + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "hand-over", + "description": "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, §5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, reached through no link) and refuses in words, or asks the operator on their channel at the level approve, binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands it over. One ask is open per path; acts shows where it stands.", + "input": { + "properties": { + "path": { + "description": "the directory's absolute path, as the engine states it", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "access-act", + "description": "Ask the operator for the take act on a library whose root holds data outside its group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes sets the library's group, group write and setgid under its root. One ask is open per access.", + "input": { + "properties": { + "access": { + "description": "the access, library:\u003cname\u003e", + "type": "string" + }, + "act": { + "description": "the act: take", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "act", + "access" + ], + "type": "object" + } + }, + { + "name": "judge-directories", + "description": "Which of this machine's declared directories the engine would refuse to make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "setuid-search", + "description": "Throw away the engine's last search for setuid programs and start a full one (novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while one runs, and when one was started within the hour, naming when the next may start.", + "input": { + "properties": { + "why": { + "description": "why it is asked, in words", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "root-free", + "description": "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root without a person, and the login shell's execute not served here. Anything else is no pass, and says why. Answered by this machine's engine alone.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "acts", + "description": "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the operator's warrant. Read-only.", + "input": { + "properties": { + "id": { + "description": "one act, by its id", + "type": "string" + }, + "limit": { + "description": "at most this many, newest", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "packages", + "description": "The packages this machine's compositions declare, present or absent, each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller hands its lease away, beside the controller's mirror for this machine and their difference. With write, the key written again from what the engine composed.", + "input": { + "properties": { + "write": { + "description": "\"true\": write this machine's key again from what the engine composed", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "composition", + "description": "One module's composition on this machine beside what runs of it, read-only: the composition its want's build has in the bucket composed-\u003cnode\u003e (the latest revision, or the one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs (applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers that join or leave.", + "input": { + "properties": { + "diff": { + "description": "\"true\": list the resources that differ from what runs", + "type": "string" + }, + "digest": { + "description": "a composition's digest (sha256:…), to read that revision rather than the latest", + "type": "string" + }, + "module": { + "description": "the module, as placed on this machine; node for the node's own", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + }, + { + "name": "compose", + "description": "Compose this machine's modules now and say how it went, so a composition that failed, or a module held, recovers without a restart: what the pass refused or held, each with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried again. Changes only what a pass the engine makes on its own would change.", + "input": { + "properties": { + "module": { + "description": "a module placed on this machine whose apply is also tried again (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "compose-candidate", + "description": "Compose a check's candidate on this machine in shadow, with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it composes (with its digest and whether the candidate changes it), is refused by the candidate or is held without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", + "input": { + "properties": { + "candidate": { + "description": "the key of the indexer's candidate, \u003cgroup\u003e.\u003cheads hash\u003e", + "type": "string" + }, + "input": { + "description": "\"true\": also answer the composition input the candidate was composed from, for the sandbox path", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "candidate" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": true, + "Decision": "novox/hq ADR 0563", + "Optional": [ + "packages", + "composition", + "compose", + "compose-candidate" + ] + }, + { + "Name": "node-login-manager", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "sessions", + "description": "The sessions the login manager offers on this machine, and which one the operator account starts by default.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-display-server", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "displays", + "description": "The monitors connected now, each with its identity, its modes and where it is placed; and the layout profile in force, if one matches.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "layout", + "description": "The monitor layout profiles, keyed by the connected monitors' identities: list them, save the current arrangement under a name, or apply one.", + "input": { + "properties": { + "action": { + "description": "list, save or apply", + "enum": [ + "list", + "save", + "apply" + ], + "type": "string" + }, + "name": { + "description": "the profile to save or apply (save and apply only)", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-display-session", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "reload", + "description": "Reload the session's configuration in place, keeping its windows.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "workspaces", + "description": "The session's workspaces: each one's name, output, and whether it is visible or focused.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "windows", + "description": "The session's windows: each one's title, class, workspace and whether it has focus; narrowed to one workspace when named.", + "input": { + "properties": { + "workspace": { + "description": "one workspace (optional)", + "type": "string" + } + }, + "type": "object" + } + } + ], + "Receives": [ + { + "Kind": "config", + "Comment": "#", + "Dirs": false, + "Shape": null, + "Offered": false + } + ], + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-terminal-emulator", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "open", + "description": "Open a terminal window in the operator's session, running a command or the login shell, in a directory or the account's home.", + "input": { + "properties": { + "command": { + "description": "what to run in it (optional; the login shell when absent)", + "type": "string" + }, + "directory": { + "description": "where it starts (optional; the account's home when absent)", + "type": "string" + } + }, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-launcher", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "menu", + "description": "Put a menu of choices in front of the operator and answer with the one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.", + "input": { + "properties": { + "choices": { + "description": "the lines to choose between, in order", + "items": { + "type": "string" + }, + "type": "array" + }, + "prompt": { + "description": "what the menu asks (optional)", + "type": "string" + } + }, + "required": [ + "choices" + ], + "type": "object" + } + }, + { + "name": "secret", + "description": "Ask the operator for a value in a prompt that does not show what is typed, and answer it sealed to the key the asker gives — never in the clear — or cancelled when the prompt was dismissed or not answered in time.", + "input": { + "properties": { + "module": { + "description": "the module whose own secret is asked for", + "type": "string" + }, + "node": { + "description": "the machine the module runs on", + "type": "string" + }, + "seal_to": { + "description": "the asker's public sealing key: the answer is sealed to it", + "type": "string" + }, + "secret": { + "description": "the own secret's name", + "type": "string" + }, + "timeout_seconds": { + "description": "give up after this long (optional)", + "type": "string" + } + }, + "required": [ + "module", + "secret", + "node", + "seal_to" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208", + "Optional": [ + "secret" + ] + }, + { + "Name": "node-notifier", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "send", + "description": "Show the operator a notification.", + "input": { + "properties": { + "body": { + "description": "its text (optional)", + "type": "string" + }, + "title": { + "description": "the notification's summary", + "type": "string" + }, + "urgency": { + "description": "low, normal (the default) or critical", + "enum": [ + "low", + "normal", + "critical" + ], + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + { + "name": "history", + "description": "The notifications shown lately, newest first.", + "input": { + "properties": { + "limit": { + "description": "how many (optional, default 20)", + "type": "string" + } + }, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-lock-screen", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "lock", + "description": "Lock the operator's session now.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-clipboard", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": [ + { + "name": "history", + "description": "What the clipboard held lately, newest first.", + "input": { + "properties": { + "limit": { + "description": "how many (optional, default 20)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "copy", + "description": "Put text on the operator's clipboard.", + "input": { + "properties": { + "text": { + "description": "the text", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + } + } + ], + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-bar", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": [ + { + "Kind": "block", + "Comment": "#", + "Dirs": false, + "Shape": { + "Places": [ + { + "Field": "bar", + "Values": [ + "bottom", + "top" + ] + }, + { + "Field": "place", + "Values": [ + "resources", + "status" + ] + } + ], + "Order": "order", + "Examples": [ + { + "bar": "bottom", + "place": "status", + "shows": "battery" + }, + { + "bar": "bottom", + "options": { + "device": "BAT0" + }, + "place": "status", + "shows": "battery" + }, + { + "bar": "bottom", + "options": { + "command": "echo \"a line\"", + "interval": 5 + }, + "place": "status", + "shows": "command" + }, + { + "bar": "bottom", + "options": { + "state": "power.draw" + }, + "place": "status", + "shows": "state" + }, + { + "bar": "bottom", + "options": { + "key": "a-machine", + "state": "power.draw" + }, + "place": "status", + "shows": "state" + } + ], + "Text": true + }, + "Offered": true + } + ], + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208", + "ShapeCode": [ + "block" + ] + }, + { + "Name": "node-compositor", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + }, + { + "Name": "node-secret-service", + "Scope": "node", + "Replicated": false, + "Delivers": "", + "Accepts": null, + "Emits": null, + "PulledByKind": false, + "Yields": false, + "Serves": null, + "Receives": null, + "Paths": null, + "HeldByTheEngine": false, + "Decision": "novox/hq ADR 0208" + } +] diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 69a2f73..1e06f5e 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -59,12 +59,13 @@ func TestTheVaultsSeatPromisesGet(t *testing.T) { } // The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises -// accept, secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted, -// and, optional while the vault that runs does not, so is one that does not yet. +// secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted, and, +// optional while the vault that runs does not, so is one that does not yet. And the move of the controller's secrets +// (design 57's V3, issue 676): move-in, asked in two steps, never a value in the clear. accept and move-asks, which no +// holder served and nothing called, are not the seat's (ADR 0620 §3); retire is, optional (design 32). func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { seat, _ := SeatNamed("mesh-vault") want := map[string][]string{ - "accept": {"node", "module", "name", "provider", "provision", "by", "call", "sealed"}, "secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"}, "issued": {"provision", "provider", "module", "call", "sealed", "holders"}, "rotate": {"node", "module", "name", "provider", "provision", "by"}, @@ -76,6 +77,18 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { "rotate-node": {"node", "why", "act"}, "node-account-confirm": {}, "abandon": {"node"}, + "move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin", + "generation", "made_at", "call", "sealed", "read_back_to"}, + // And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key. + "own": {"credentials"}, + "retire": {"node", "module", "name", "warrant"}, + } + for _, gone := range []string{"accept", "move-asks"} { + for _, v := range seat.Serves { + if v.Name == gone { + t.Errorf("the vault's seat still promises %s, which no holder serves (ADR 0620 §3)", gone) + } + } } for name, args := range want { var verb *Verb @@ -124,58 +137,13 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { } serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, - Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account", - "rotate-node", "node-account-confirm", "abandon"}}}} + Serves: []string{"get", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account", + "rotate-node", "node-account-confirm", "abandon", "move-in", "node-key", "own"}}}} if err := CanHold(serving, seat); err != nil { t.Fatalf("the vault serving its verbs was refused: %v", err) } } -// The move of the controller's secrets into the vault (design 57's V3, issue 676): the seat promises move-in, which a -// machine's node-engine asks in two steps with the row, its origin and generation, then the value sealed to the -// call's key and a key to read back to; and move-asks, the controller's record of asks copied as it is. Optional -// while the vault that runs does not serve them; never a value in the clear. -func TestTheVaultsSeatPromisesTheMove(t *testing.T) { - seat, _ := SeatNamed("mesh-vault") - want := map[string][]string{ - "move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin", - "generation", "made_at", "call", "sealed", "read_back_to"}, - "move-asks": {"asks"}, - // And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key. - "own": {"credentials"}, - } - for name, args := range want { - var verb *Verb - for i := range seat.Serves { - if seat.Serves[i].Name == name { - verb = &seat.Serves[i] - } - } - if verb == nil { - t.Errorf("the vault's seat does not promise %s", name) - continue - } - if !verb.Optional { - t.Errorf("%s is not optional while the vault that runs does not serve it", name) - } - props, _ := verb.Input["properties"].(map[string]any) - for _, arg := range args { - if props[arg] == nil { - t.Errorf("%s takes no %s", name, arg) - } - } - if props["value"] != nil { - t.Errorf("%s takes a value in the clear", name) - } - } - serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, - Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, - Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "node-key", "move-asks", "own"}}}} - if err := CanHold(serving, seat); err != nil { - t.Fatalf("the vault serving the move was refused: %v", err) - } -} - // Issue 786: the vault's seat promises node-key, optional while the vault that runs does not serve it. func TestTheVaultsSeatPromisesNodeKey(t *testing.T) { seat, _ := SeatNamed("mesh-vault") diff --git a/go/catalogue/verbs.go b/go/catalogue/verbs.go index 0c3c9f0..550020e 100644 --- a/go/catalogue/verbs.go +++ b/go/catalogue/verbs.go @@ -4,8 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "maps" - "slices" ) // A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of @@ -76,21 +74,6 @@ func VerbNames(verbs []Verb) []string { return out } -// schema is a JSON schema for an object of string properties, which is every argument the verbs -// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The -// switches are the properties that are "true" or "false" and nothing else, said in the schema as an -// enum so a caller sees it and the verb can refuse any other word rather than read it as false. -// -// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not -// name is refused when the verb is called, never passed over. -// withObject adds to a verb's input s the property name, an object of the string fields given, each required. -func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any { - inner := schema(fields, slices.Sorted(maps.Keys(fields))) - inner["description"], inner["additionalProperties"] = description, false - s["properties"].(map[string]any)[name] = inner - return s -} - func schema(properties map[string]string, required []string, switches ...string) map[string]any { isSwitch := map[string]bool{} for _, s := range switches { diff --git a/go/packages_list_test.go b/go/packages_list_test.go index c23b74e..78328ed 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -24,7 +24,7 @@ var meshSDKPackages = []string{ ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busretry", "calladdress", "catalogue", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", - "sdkversion", "seal", "testbus", + "sdkversion", "seal", "seatdef", "testbus", } // computesNot are the functions whose computation is one owner's alone (ADR 0593 §2, §7): the indexer computes a -- 2.54.0 From 63f983897c01fa5cdf57c884fbf4d898b5409b40 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:00:10 +0100 Subject: [PATCH 005/144] =?UTF-8?q?SDK-2:=20every=20reader=20reads=20both?= =?UTF-8?q?=20reference=20forms:=20artifact-store://=20and=20the=20one=20a?= =?UTF-8?q?=20registry=20seat=20answers=20(ADR=200605=20=C2=A79,=20design?= =?UTF-8?q?=2051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A done key's reference may be //@ through oci-artifact-registry (a bundle, an archive) or container-image-registry (an image, an upstream copy); catalogue.InRegistrySeat reads it, InArtifactStore and Routed read it as they read a kept reference, Recorded keeps it as it is. Every exported name stays. A build's list of every form (ADR 0624) is lead-47's, on feat/adr0618-reads. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/artifacts.go | 36 +++++++++++++++++++++++++-- go/catalogue/artifacts_test.go | 16 ++++++++++++ go/delivery/builder.go | 20 +++++++++++++++ go/delivery/builder_reference_test.go | 10 ++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/go/catalogue/artifacts.go b/go/catalogue/artifacts.go index cb4bc06..2a4c7d5 100644 --- a/go/catalogue/artifacts.go +++ b/go/catalogue/artifacts.go @@ -2,6 +2,8 @@ package catalogue import ( "strings" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without @@ -17,7 +19,7 @@ const ArtifactStoreScheme = ArtifactStoreProvision + "://" // in it — an image id from a genesis build that had nowhere to publish, a package version — is // what it was. func Recorded(reference string) string { - if strings.HasPrefix(reference, ArtifactStoreScheme) { + if _, _, seat := InRegistrySeat(reference); seat || strings.HasPrefix(reference, ArtifactStoreScheme) { return reference } if rest, isURL := strings.CutPrefix(reference, "http://"); isURL { @@ -39,11 +41,41 @@ func isRegistryHost(component string) bool { return component == "localhost" || strings.ContainsAny(component, ".:") } -// InArtifactStore reports whether a reference is a kept one, and what it names there. +// InArtifactStore reports whether a reference is a kept one, and what it names there: `/@` +// for an image, `//blobs/` for a bundle or an archive. **It reads both forms** (novox/hq ADR +// 0605 §9): the older `artifact-store://`, and the form a registry seat answers, `//@` +// through container-image-registry or oci-artifact-registry, whose blob it names the way the older form did. func InArtifactStore(reference string) (path string, kept bool) { + if seat, rest, ok := InRegistrySeat(reference); ok { + if seat == contractseats.OciArtifactRegistry { + name, digest, _ := strings.Cut(rest, "@") + return name + "/blobs/" + digest, true + } + return rest, true + } return strings.CutPrefix(reference, ArtifactStoreScheme) } +// RegistrySeats are the seats a reference of the registry form names first, the two that front the artifact store +// (novox/hq ADR 0605 §1, §9). +var RegistrySeats = []string{contractseats.ContainerImageRegistry, contractseats.OciArtifactRegistry} + +// InRegistrySeat reads a reference in the form a registry seat answers, `//@sha256:`: +// the seat it came through and the rest. A seat's name holds no dot or port, so no image of an outside registry reads +// as one, and a kept `artifact-store://` reference is not one. +func InRegistrySeat(reference string) (seat, path string, ok bool) { + first, rest, found := strings.Cut(reference, "/") + if !found || !strings.Contains(rest, "@sha256:") || strings.Count(rest, "/") != 1 { + return "", "", false + } + for _, s := range RegistrySeats { + if first == s { + return s, rest, true + } + } + return "", "", false +} + // Routed is a kept reference as a machine fetches it, through the artifact store at `address` // (host:port). A reference that is not a kept one is what it was. func Routed(reference, address string) string { diff --git a/go/catalogue/artifacts_test.go b/go/catalogue/artifacts_test.go index 17ca60c..a59c2e2 100644 --- a/go/catalogue/artifacts_test.go +++ b/go/catalogue/artifacts_test.go @@ -20,10 +20,26 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) { "@novox/sdk@1.2.3": "@novox/sdk@1.2.3", "gitea/gitea@" + digest: "gitea/gitea@" + digest, "https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest, + // The form a registry seat answers is recorded as it is (ADR 0605 §9). + "container-image-registry/gitea/server@" + digest: "container-image-registry/gitea/server@" + digest, + "oci-artifact-registry/gitea/config@" + digest: "oci-artifact-registry/gitea/config@" + digest, } for announced, want := range cases { if got := Recorded(announced); got != want { t.Errorf("Recorded(%q) = %q, want %q", announced, got, want) } } + // And every reader reads both forms: a kept reference and the seat's are fetched from the same place. + for reference, want := range map[string]string{ + ArtifactStoreScheme + "gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest, + "container-image-registry/gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest, + ArtifactStoreScheme + "gitea/config/blobs/" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest, + "oci-artifact-registry/gitea/config@" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest, + "registry.example/gitea/server@" + digest: "registry.example/gitea/server@" + digest, + "mesh-artifact-store/gitea/server@" + digest: "mesh-artifact-store/gitea/server@" + digest, + } { + if got := Routed(reference, "10.0.0.1:5000"); got != want { + t.Errorf("Routed(%q) = %q, want %q", reference, got, want) + } + } } diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 95468e3..42bee50 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -5,6 +5,9 @@ import ( "fmt" "reflect" "strings" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453 @@ -72,6 +75,12 @@ func ParseBuilderBuildKey(key string) (module, fingerprint string, err error) { // artifact-store:///@ catalogue.Recorded keeps without its address // package @ the version published from the trunk // +// From the registry milestone a done key names the reference its seat answered (novox/hq ADR 0605 §9, design 51 *The +// done key names the reference the seat answered*): `//@`, the registry seat it +// came through (oci-artifact-registry for a bundle or an archive, container-image-registry for an image or an upstream +// copy), and `@` for a package as before. A key written in the older form keeps it; this reads both, and +// no build is built again for its form (catalogue.InArtifactStore reads both the same way). +// // is Name and is Digest. A reference is empty for a candidate's image or package, which is never // pushed or published (ADR 0498 clause 6), and in a key done before R4e; the controller delivers only from a key whose // every artifact names one (Referenced, clause 8). A kind left empty (a key written before kinds) is not checked. @@ -90,6 +99,17 @@ func (a BuiltArtifact) referenceProblem() string { if r == "" || a.Kind == "" { return "" } + if seat, path, ok := catalogue.InRegistrySeat(r); ok { + want := contractseats.ContainerImageRegistry + if a.Kind == "bundle" || a.Kind == "archive" { + want = contractseats.OciArtifactRegistry + } + module, rest, _ := strings.Cut(path, "/") + if a.Kind == "package" || seat != want || module == "" || rest != a.Name+"@"+a.Digest { + return "is not " + want + "//" + a.Name + "@" + a.Digest + } + return "" + } switch a.Kind { case "bundle", "archive": path, ok := strings.CutPrefix(r, artifactStore) diff --git a/go/delivery/builder_reference_test.go b/go/delivery/builder_reference_test.go index 4a0dd3c..5e38964 100644 --- a/go/delivery/builder_reference_test.go +++ b/go/delivery/builder_reference_test.go @@ -24,6 +24,11 @@ func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) { artifactOf("lib", "package", d, "@novox/mesh-sdk@0.1.15"), artifactOf("lib", "package", d, "left-pad@1.3.0"), artifactOf("old", "", d, "anything at all"), + // The form a registry seat answers (ADR 0605 §9), beside the older one. + artifactOf("code", "bundle", d, "oci-artifact-registry/mail/code@"+d), + artifactOf("files", "archive", d, "oci-artifact-registry/mail/files@"+d), + artifactOf("server", "image", d, "container-image-registry/mail/server@"+d), + artifactOf("nats", "upstream", d, "container-image-registry/nats/nats@"+d), } for _, a := range good { b := doneBuild() @@ -43,6 +48,11 @@ func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) { artifactOf("server", "image", d, "artifact-store://mail/server/blobs/"+d), artifactOf("lib", "package", d, "@novox/mesh-sdk"), artifactOf("lib", "package", d, "0.1.15"), + artifactOf("code", "bundle", d, "container-image-registry/mail/code@"+d), + artifactOf("server", "image", d, "oci-artifact-registry/mail/server@"+d), + artifactOf("server", "image", d, "container-image-registry/mail/server@"+other), + artifactOf("server", "image", d, "container-image-registry/mail/other@"+d), + artifactOf("lib", "package", d, "container-image-registry/mail/lib@"+d), } for _, a := range bad { b := doneBuild() -- 2.54.0 From 46baac828696f91befc9d3e518e8aae618b5f3e4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:02:52 +0100 Subject: [PATCH 006/144] DLV-FB1 sdk: a forge message says a branch was deleted (design 51 *Tagged versions*) delivery.ForgeBranchDeleted, accepted by checkForgeKind, names the repository and the branch and carries nothing else (A3's request 2). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/forge_inputs_test.go | 5 +++++ go/delivery/source.go | 16 ++++++++++++++-- go/delivery/source_test.go | 4 +++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/go/delivery/forge_inputs_test.go b/go/delivery/forge_inputs_test.go index 5e4b78b..7b1893a 100644 --- a/go/delivery/forge_inputs_test.go +++ b/go/delivery/forge_inputs_test.go @@ -69,6 +69,11 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) { // ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both. "predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"}, "predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"}, + // A deleted branch names its branch and carries nothing else (design 51 *Tagged versions*). + "branch-deleted with no branch": {func(f *Forge) { *f = Forge{Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk"} }, "needs the branch"}, + "branch-deleted with a head": {func(f *Forge) { + *f = Forge{Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk", Branch: "feat/x", Head: "aaaa"} + }, "carries nothing else"}, } { f := mergedWithInputs() tc.mut(&f) diff --git a/go/delivery/source.go b/go/delivery/source.go index 3956884..6eebdfb 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -47,6 +47,10 @@ const ( ForgeMerged = "merged" ForgeRefused = "refused" ForgeOpen = "open" + // ForgeBranchDeleted says a branch of a repository was deleted on the forge, from the forge's own delete event + // (design 51 *Tagged versions*): the builder removes the tagged versions it asked for that branch. It names the + // repository and the branch and carries nothing else. A reader that does not know the kind ignores it. + ForgeBranchDeleted = "branch-deleted" ) // OpenPull names an open pull request into the trunk: the repository as `/` and the number. @@ -166,10 +170,10 @@ func checkRepository(repository string) error { func checkForgeKind(kind string) error { switch kind { - case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen: + case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen, ForgeBranchDeleted: return nil } - return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused or open", kind) + return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused, open or branch-deleted", kind) } // ForgeEvent is the name of a forge message of the given kind: `forge...`. @@ -704,6 +708,14 @@ func (f Forge) check() error { if err := f.checkInputs(func(d string) bool { _, ok := f.Trees[d]; return ok }, "its trees"); err != nil { return err } + case ForgeBranchDeleted: + if f.Branch == "" { + return fmt.Errorf("delivery: a branch-deleted message needs the branch") + } + if f.Number != 0 || f.Head != "" || f.Merge != "" || len(f.Dirs) > 0 || len(f.Trees) > 0 || len(f.Manifests) > 0 || + len(f.Locks) > 0 || len(f.Paths) > 0 || len(f.Packages) > 0 || f.Act != "" { + return fmt.Errorf("delivery: a branch-deleted message names its repository and branch and carries nothing else") + } case ForgeRefused: if len(f.Dirs) > 0 || f.DirsKnown { return fmt.Errorf("delivery: a refused message carries no module directories") diff --git a/go/delivery/source_test.go b/go/delivery/source_test.go index 838d395..48b096b 100644 --- a/go/delivery/source_test.go +++ b/go/delivery/source_test.go @@ -44,7 +44,7 @@ func TestSourceKeys(t *testing.T) { // 1200/AC1 if err != nil || repo != "novox/mesh-sdk" || kind != ForgeMerged { t.Fatalf("parse forge event = %q %q %v", repo, kind, err) } - for _, k := range []string{ForgePull, ForgeMerged, ForgeRefused} { + for _, k := range []string{ForgePull, ForgeMerged, ForgeRefused, ForgeBranchDeleted} { if _, err := ForgeEvent("a/b", k); err != nil { t.Errorf("kind %s refused: %v", k, err) } @@ -150,6 +150,8 @@ func sampleForges() []Forge { Trees: map[string]string{"modules/a": "t1"}, Root: "t0"}, {Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"}, {Kind: ForgeOpen, Open: []OpenPull{{Repository: "novox/mesh-sdk", Number: 36}, {Repository: "novox/mesh-control", Number: 7}}}, + // A deleted branch, from the forge's delete event (design 51 *Tagged versions*). + {Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk", Branch: "feat/x"}, } } -- 2.54.0 From df5cef0a6769cca80f23d79682919e90ca7b7858 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:04:11 +0100 Subject: [PATCH 007/144] =?UTF-8?q?SDK-2:=20the=20seat's=20reference=20for?= =?UTF-8?q?m=20is=20a=20reader=20capability=20a=20node-engine=20states,=20?= =?UTF-8?q?form:seat-reference=20(ADR=200618=20=C2=A71,=20ADR=200624=20?= =?UTF-8?q?=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catalogue.ReferenceFormSeat joins ManifestReads, so the builder compares it with a node record's stated reads (A3's request 3); the older forms are the baseline's and the builder's to name (lead-47's delivery forms on feat/adr0618-reads, where FormRegistry's match must leave this form out when the two meet). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/manifest_reads.go | 13 +++++++++++++ go/catalogue/own_manifest_test.go | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/go/catalogue/manifest_reads.go b/go/catalogue/manifest_reads.go index e0d5851..8da9384 100644 --- a/go/catalogue/manifest_reads.go +++ b/go/catalogue/manifest_reads.go @@ -37,6 +37,16 @@ const ( var referenceForms = []string{"access", "backup", "bound", "collected", "config", "consumer", "consumers", "contribution", "dir", "machine", "port", "seat", "secret", "setting"} +// ReferenceFormSeat names the reference form a registry seat answers for a build's artifact, +// `//@sha256:` (novox/hq ADR 0605 §9; InRegistrySeat), as the reader capability +// `form:seat-reference` a node-engine built with this contract states (ADR 0618 §1, ADR 0624 §1): the builder writes it +// into a build key only for the nodes whose engines state it. The older reference forms a node-engine read before any +// stated what it reads are the baseline's; they are the builder's to name (delivery's artifact forms). +const ReferenceFormSeat = "seat-reference" + +// artifactReferenceForms are the reference forms of a build's artifacts this contract reads beyond the baseline's. +var artifactReferenceForms = []string{ReferenceFormSeat} + // manifestTopWords are the top-level keys Manifest.UnmarshalJSON reads apart from the fields of manifestFields: each // read whole, its inside a map of the module's own names (requirements, local names) and values, no words of the // contract's beneath it. `requires` and `config` are read as their fields are (manifestNeeds). @@ -60,6 +70,9 @@ func ManifestReads() []string { for _, f := range referenceForms { set[ReadForm+f] = true } + for _, f := range artifactReferenceForms { + set[ReadForm+f] = true + } for _, s := range DefaultSeats() { set[ReadSeat+s.Name] = true } diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index fda8140..b509c86 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -42,7 +42,9 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { t.Errorf("the baseline's %s is no reader capability of this contract", b) } } - for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage"} { + for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage", + // The reference form a registry seat answers (ADR 0605 §9), and the seats the registry milestone adds. + ReadForm + ReferenceFormSeat, "seat:go-package-registry", "seat:container-image-registry", "seat:oci-artifact-registry"} { if !slices.Contains(added, want) { t.Errorf("%s is in the baseline, or not read: added since it are %v", want, added) } -- 2.54.0 From c9fec158cbfe58b6528832675761ef11d1545bfb Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:06:55 +0100 Subject: [PATCH 008/144] =?UTF-8?q?A3=20sdk:=20a=20bundle=20uses=20its=20o?= =?UTF-8?q?wn=20object=20store=20through=20the=20runtime=20(ADR=200595=20?= =?UTF-8?q?=C2=A72,=20=C2=A79,=20design=2025=20=C2=A7=2012.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdio.BusState asks the runtime one buscalls method on the bundle's own bus state; package busstate makes the object store's put, get, list, delete and fill by the store's local name (A3's request 1, distribution's incoming). Its test takes the place of one merged into its neighbour, so mesh-sdk's unit count does not grow. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/busstate.go | 31 ++++++++ go/busstate/busstate.go | 59 +++++++++++++++ go/busstate/busstate_test.go | 102 ++++++++++++++++++++++++++ go/delivery/builder_reference_test.go | 12 ++- go/packages_list_test.go | 2 +- 5 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 go/busstate.go create mode 100644 go/busstate/busstate.go create mode 100644 go/busstate/busstate_test.go diff --git a/go/busstate.go b/go/busstate.go new file mode 100644 index 0000000..a77133f --- /dev/null +++ b/go/busstate.go @@ -0,0 +1,31 @@ +package stdio + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// busStateCalls are the method prefixes of the calls a bundle makes on its own bus state through the runtime (novox/hq +// ADR 0595 §9, design 25 § 12.5): their shapes and names are package buscalls's, and package busstate the typed way +// to make them. +var busStateCalls = []string{"mesh/objects.", "mesh/stream.", "mesh/workqueue.", "mesh/ledger."} + +// BusState asks the runtime one call on this bundle's own bus state, an entry its module asks for in `requires`, named +// by its local name in params; the runtime serves it from the module's own bindings only. A method that is none of +// those calls is refused here, before the runtime is asked. +func BusState(method string, params any) (json.RawMessage, error) { + known := false + for _, p := range busStateCalls { + known = known || strings.HasPrefix(method, p) + } + if !known { + return nil, fmt.Errorf("%s is no call on a bundle's own bus state (%s)", method, strings.Join(busStateCalls, ", ")) + } + c, err := live() + if err != nil { + return nil, err + } + return c.ask(method, params, 60*time.Second) +} diff --git a/go/busstate/busstate.go b/go/busstate/busstate.go new file mode 100644 index 0000000..d105dc1 --- /dev/null +++ b/go/busstate/busstate.go @@ -0,0 +1,59 @@ +// Package busstate is how a bundle uses its own bus state through the runtime (novox/hq ADR 0595 §9, design 25 § +// 12.5): the object store's put, get, list, delete and fill, each naming an entry by the local name its module asks for +// it by in `requires`. The runtime serves the calls from the module's own bindings, its own entries only. The shapes +// are package buscalls's; this package makes the calls. +package busstate + +import ( + "encoding/json" + "fmt" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// Objects is one object store the module asks for in `requires` (the template objects, ADR 0595 §2), by its local name. +type Objects struct{ local string } + +// ObjectsOf is the object store of that local name. +func ObjectsOf(local string) Objects { return Objects{local: local} } + +func call[T any](method string, params any) (T, error) { + var out T + raw, err := stdio.BusState(method, params) + if err != nil { + return out, err + } + if err := json.Unmarshal(raw, &out); err != nil { + return out, fmt.Errorf("the runtime's answer to %s: %w", method, err) + } + return out, nil +} + +// Put stores one object whole under its name, replacing one of that name, and answers it as the store holds it. +func (o Objects) Put(name string, data []byte, description string) (buscalls.ObjectInfo, error) { + return call[buscalls.ObjectInfo](buscalls.ObjectsPut, buscalls.ObjectsPutRequest{Objects: o.local, Name: name, + Data: data, Description: description}) +} + +// Get is one object, its info and its bytes. +func (o Objects) Get(name string) (buscalls.ObjectData, error) { + return call[buscalls.ObjectData](buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: o.local, Name: name}) +} + +// List is every object the store holds. +func (o Objects) List() ([]buscalls.ObjectInfo, error) { + l, err := call[buscalls.ObjectsListed](buscalls.ObjectsList, buscalls.ObjectsRequest{Objects: o.local}) + return l.Objects, err +} + +// Delete removes one object. +func (o Objects) Delete(name string) error { + _, err := call[buscalls.Done](buscalls.ObjectsDelete, buscalls.ObjectsNameRequest{Objects: o.local, Name: name}) + return err +} + +// Fill is how much the store holds against its cap. +func (o Objects) Fill() (buscalls.Fill, error) { + return call[buscalls.Fill](buscalls.ObjectsInfo, buscalls.ObjectsRequest{Objects: o.local}) +} diff --git a/go/busstate/busstate_test.go b/go/busstate/busstate_test.go new file mode 100644 index 0000000..3d1c365 --- /dev/null +++ b/go/busstate/busstate_test.go @@ -0,0 +1,102 @@ +package busstate + +import ( + "bufio" + "encoding/json" + "io" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// A bundle keeps objects in its own object store through the runtime (ADR 0595 §2, §9; A3's request for distribution's +// incoming): each call is the runtime's method of buscalls, naming the store by its local name, and answers as the +// runtime answered; a method that is no bus-state call is refused before the runtime is asked. +func TestABundleKeepsObjectsThroughTheRuntime(t *testing.T) { + t.Setenv(stdio.ServedAs, "distribution") + inR, inW := io.Pipe() + outR, outW := io.Pipe() + type result struct { + what string + err error + got any + } + results := make(chan result, 8) + go func() { + _ = stdio.ServeOn("", []stdio.Tool{{Name: "keep", Run: func(map[string]any) (any, error) { + go func() { + in := ObjectsOf("incoming") + info, err := in.Put("t1.part0", []byte("hello"), "a part") + results <- result{"put", err, info.Digest} + data, err := in.Get("t1.part0") + results <- result{"get", err, string(data.Data)} + list, err := in.List() + results <- result{"list", err, len(list)} + results <- result{"delete", in.Delete("t1.part0"), nil} + fill, err := in.Fill() + results <- result{"fill", err, fill.MaxBytes} + _, err = stdio.BusState("mesh/state.get", nil) + results <- result{"refused", err, nil} + }() + return "started", nil + }}}, inR, outW) + }() + lines := bufio.NewScanner(outR) + next := func() map[string]any { + done := make(chan map[string]any, 1) + go func() { + if lines.Scan() { + var m map[string]any + _ = json.Unmarshal(lines.Bytes(), &m) + done <- m + } + }() + select { + case m := <-done: + return m + case <-time.After(3 * time.Second): + t.Fatal("the bundle said nothing") + return nil + } + } + send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) } + send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "keep"}}) + answers := map[string]any{ + buscalls.ObjectsPut: buscalls.ObjectInfo{Name: "t1.part0", Size: 5, Digest: "SHA-256=x"}, + buscalls.ObjectsGet: buscalls.ObjectData{Info: buscalls.ObjectInfo{Name: "t1.part0"}, Data: []byte("hello")}, + buscalls.ObjectsList: buscalls.ObjectsListed{Objects: []buscalls.ObjectInfo{{Name: "t1.part0"}}}, + buscalls.ObjectsDelete: buscalls.Done{}, + buscalls.ObjectsInfo: buscalls.Fill{Count: 0, MaxBytes: 20 << 30}, + } + asked := map[string]map[string]any{} + for len(asked) < len(answers) { + m := next() + method, _ := m["method"].(string) + if method == "" { + continue // the tool's own answer + } + p, _ := m["params"].(map[string]any) + asked[method] = p + send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": answers[method]}) + } + want := map[string]any{"put": "SHA-256=x", "get": "hello", "list": 1, "delete": nil, "fill": uint64(20 << 30)} + for range 5 { + r := <-results + if r.err != nil || r.got != want[r.what] { + t.Errorf("%s answered %v, %v; want %v", r.what, r.got, r.err, want[r.what]) + } + } + if r := <-results; r.err == nil { + t.Error("a call that is not on the bundle's bus state was asked of the runtime") + } + for method, p := range asked { + if p["objects"] != "incoming" { + t.Errorf("%s named the store %v, not its local name", method, p["objects"]) + } + } + if p := asked[buscalls.ObjectsPut]; p["name"] != "t1.part0" || p["data"] != "aGVsbG8=" || p["description"] != "a part" { + t.Errorf("put asked %v", p) + } +} diff --git a/go/delivery/builder_reference_test.go b/go/delivery/builder_reference_test.go index 5e38964..86d6bba 100644 --- a/go/delivery/builder_reference_test.go +++ b/go/delivery/builder_reference_test.go @@ -104,6 +104,11 @@ func TestOnlyAPushFailureIsBuiltAgainBeforeTheFingerprintChanges(t *testing.T) { if NotStored != "not stored: " || NotPinned != "not pinned: " || VersionTaken != "version taken: " || RefusedKind != "refused kind: " { t.Fatal("a prefix changed its words") } + // A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the + // builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change. + if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") { + t.Fatalf("WaitsFor = %q", WaitsFor) + } } // A key whose module's lock names a version of the group's package not yet published waits, queued, saying which @@ -144,10 +149,3 @@ func TestADoneKeyWithNoArtifactIsReferenced(t *testing.T) { } } -// A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the -// builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change. -func TestWaitsForIsAPrefixThatIsBuiltAgain(t *testing.T) { - if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") { - t.Fatalf("WaitsFor = %q", WaitsFor) - } -} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 78328ed..d55f12a 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -21,7 +21,7 @@ import ( // Logic only some modules need is its owner module's package. A package added here is a decision to say in the pull // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ - ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busretry", "calladdress", "catalogue", + ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "testbus", -- 2.54.0 From 8a84711792590f8a3f9eaf054dadc47d99487d56 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:09:19 +0100 Subject: [PATCH 009/144] SDK-4, SDK-5, SDK-6, SDK-7: the manifest words of series, accepted packages, a renamed config value and the contracts marker (design 32, design 26) Artifact.Series and SeriesOf (0.1 when absent); Manifest.Packages and AcceptedSeries; a config declaration's renamed-from, written back in the config form; a package's generated-from. One round-trip case covers the four, in the config form's existing test; the judge's rules on them are the indexer's (DLV-I13..I16). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/config.go | 3 ++- go/catalogue/config_test.go | 26 ++++++++++++++++++++++ go/catalogue/manifest.go | 37 ++++++++++++++++++++++++++++++++ go/catalogue/setting_defaults.go | 5 +++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/go/catalogue/config.go b/go/catalogue/config.go index 9341b4b..14f0508 100644 --- a/go/catalogue/config.go +++ b/go/catalogue/config.go @@ -114,6 +114,7 @@ type configDeclaration struct { Default any `json:"default,omitempty"` Required bool `json:"required,omitempty"` Runtime bool `json:"runtime,omitempty"` + RenamedFrom string `json:"renamed-from,omitempty"` } // MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings @@ -125,7 +126,7 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) { } return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum, Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined, - Default: d.Default, Required: d.Required, Runtime: d.Runtime}) + Default: d.Default, Required: d.Required, Runtime: d.Runtime, RenamedFrom: d.RenamedFrom}) } // validationProblems is what is wrong with a declaration's validation, and its default against it. diff --git a/go/catalogue/config_test.go b/go/catalogue/config_test.go index 338c72a..cc482dc 100644 --- a/go/catalogue/config_test.go +++ b/go/catalogue/config_test.go @@ -132,6 +132,32 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { if !bytes.Equal(raw, twice) { t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw) } + + // The registry milestone's words round-trip the same (design 32): a config value's old name, an artifact's series + // and contracts marker, the series a module accepts of a package; absent, a series is 0.1. + declared := []byte(`{"module":"c","version":"1","config":{"font":{"type":"string","renamed-from":"font-name"}}, + "packages":{"mesh-sdk":"0.2"}, + "build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.2", + "generated-from":"seats"},{"name":"lib","kind":"package","language":"typescript"}]}}`) + m, err = decodeForTest(declared) + if err != nil { + t.Fatal(err) + } + if m.Settings["font"].RenamedFrom != "font-name" || m.AcceptedSeries("mesh-sdk") != "0.2" || + m.AcceptedSeries("mesh-contracts") != DefaultSeries || m.Build.Artifacts[0].SeriesOf() != "0.2" || + m.Build.Artifacts[0].GeneratedFrom != "seats" || m.Build.Artifacts[1].SeriesOf() != "0.1" { + t.Fatalf("read %+v", m) + } + raw, _ = json.Marshal(m) + for _, said := range []string{`"renamed-from":"font-name"`, `"packages":{"mesh-sdk":"0.2"}`, `"series":"0.2"`, + `"generated-from":"seats"`} { + if !strings.Contains(string(raw), said) { + t.Errorf("written back without %s: %s", said, raw) + } + } + if strings.Count(string(raw), `"series"`) != 1 { + t.Errorf("a series nobody said is written: %s", raw) + } } // A module in the settings form is written back byte for byte as before. diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 485e190..5aecd73 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -469,6 +469,11 @@ type Manifest struct { // this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`. Provides []Offer `json:"provides,omitempty"` + // Packages are the series of each mesh package this module accepts, by the package's name (novox/hq ADR 0606, + // design 32): its fingerprint takes the newest version of that series, and a publish of another series moves it + // not. A package it uses and does not name is accepted in DefaultSeries (AcceptedSeries). + Packages map[string]string `json:"packages,omitempty"` + // Requires are names that must be provided by something assigned to the same node. Requires []string `json:"requires,omitempty"` // OptionalRequires is which of Requires the module can run without (novox/hq ADR 0330): filled @@ -1224,6 +1229,38 @@ type Artifact struct { // paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment // is, never a secret's content. The node's runtime hands it to this bundle and to no other. Env map[string]string `json:"env,omitempty"` + + // Series is the series this artifact's versions are numbered in, `.` (novox/hq ADR 0606, design 32): + // the builder numbers `.`, so a change of series is a change a user accepts by its own `packages`. + // Said on a package, an image, a bundle or an archive; the judge refuses it on any other kind. Absent is + // DefaultSeries (SeriesOf). + Series string `json:"series,omitempty"` + + // GeneratedFrom marks a package of a contracts module (novox/hq ADR 0608 §5, design 26 *Contracts modules*): the + // directory, relative to the module's, of the seat definitions every file of the package is generated from. A + // module carrying it holds contracts only and claims no seat, which the judge refuses naming the seat; the build + // modules' test step regenerates the package and fails on a file out of step. + GeneratedFrom string `json:"generated-from,omitempty"` +} + +// AcceptedSeries is the series of the package of that name this module accepts: the one Packages names, or +// DefaultSeries. +func (m Manifest) AcceptedSeries(name string) string { + if s := m.Packages[name]; s != "" { + return s + } + return DefaultSeries +} + +// DefaultSeries is the series of an artifact or an accepted package that names none (design 32). +const DefaultSeries = "0.1" + +// SeriesOf is the series the artifact's versions are numbered in: its own, or DefaultSeries. +func (a Artifact) SeriesOf() string { + if a.Series == "" { + return DefaultSeries + } + return a.Series } // Kinds an artifact may be. diff --git a/go/catalogue/setting_defaults.go b/go/catalogue/setting_defaults.go index 05f9c46..5180cd8 100644 --- a/go/catalogue/setting_defaults.go +++ b/go/catalogue/setting_defaults.go @@ -31,6 +31,11 @@ type SettingDeclaration struct { Required bool `json:"required,omitempty"` // Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5). Runtime bool `json:"runtime,omitempty"` + // RenamedFrom is the value's old name, a config value of this module's own (novox/hq ADR 0609 §4, design 32 *A + // config value renamed*): on the merge mesh-config moves the value held under it to this name, and answers both + // names until every node-engine reports a build reading the new one. The config form only: the judge refuses it + // under `settings`, on a secret, and where it changes the value's trust. + RenamedFrom string `json:"renamed-from,omitempty"` } // KindPreference is the kind of a setting that may have a default. -- 2.54.0 From 400f4e00209e541961b93a1caad9668e327a4ebe Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:10:00 +0100 Subject: [PATCH 010/144] =?UTF-8?q?SDK-3:=20package-version.sum=20and=20it?= =?UTF-8?q?s=20guard=20go:=20the=20builder=20numbers=20every=20version=20(?= =?UTF-8?q?ADR=200605=20=C2=A77=20step=204,=20design=2051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing reads either on any repository's main. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/packageguard/packageversion_test.go | 104 ------------------------- go/packageguard/reads-outside | 1 - package-version.sum | 1 - 3 files changed, 106 deletions(-) delete mode 100644 go/packageguard/packageversion_test.go delete mode 100644 go/packageguard/reads-outside delete mode 100644 package-version.sum diff --git a/go/packageguard/packageversion_test.go b/go/packageguard/packageversion_test.go deleted file mode 100644 index 2ec2d8c..0000000 --- a/go/packageguard/packageversion_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package packageguard - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" - "testing" -) - -// The registry refuses a second, different package under one version -// ("version taken", issues 1899 and 1331). This guard fails before a merge when -// what npm packs changes while the version stays: package-version.sum holds the -// version and a sha256 over the inputs of the packed dist (package.json without -// its version, src/, tsconfig.json) and of the README npm always packs. -// The lock file is not packed, so it is not hashed. - -const sumFile = "package-version.sum" - -func packedInputs(root string) (version, sum string, err error) { - raw, err := os.ReadFile(filepath.Join(root, "package.json")) - if err != nil { - return "", "", err - } - var pkg map[string]any - if err := json.Unmarshal(raw, &pkg); err != nil { - return "", "", err - } - version, _ = pkg["version"].(string) - delete(pkg, "version") - canon, err := json.Marshal(pkg) // map keys are sorted: stable bytes - if err != nil { - return "", "", err - } - files := map[string][]byte{"package.json": canon} - for _, name := range []string{"tsconfig.json", "README.md"} { - b, err := os.ReadFile(filepath.Join(root, name)) - if err != nil { - return "", "", err - } - files[name] = b - } - err = filepath.WalkDir(filepath.Join(root, "src"), func(p string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - b, err := os.ReadFile(p) - if err != nil { - return err - } - rel, _ := filepath.Rel(root, p) - files[filepath.ToSlash(rel)] = b - return nil - }) - if err != nil { - return "", "", err - } - names := make([]string, 0, len(files)) - for n := range files { - names = append(names, n) - } - sort.Strings(names) - h := sha256.New() - for _, n := range names { - fmt.Fprintf(h, "%s\x00%d\x00", n, len(files[n])) - h.Write(files[n]) - } - return version, hex.EncodeToString(h.Sum(nil)), nil -} - -func TestPackageVersionMatchesPackedInputs(t *testing.T) { - root := "../.." - if r := os.Getenv("MESH_SDK_ROOT"); r != "" { - root = r - } - version, sum, err := packedInputs(root) - if err != nil { - t.Fatal(err) - } - line := version + " " + sum + "\n" - if os.Getenv("UPDATE_PACKAGE_VERSION_SUM") != "" { - if err := os.WriteFile(filepath.Join(root, sumFile), []byte(line), 0o644); err != nil { - t.Fatal(err) - } - return - } - raw, err := os.ReadFile(filepath.Join(root, sumFile)) - if err != nil { - t.Fatalf("%s is missing: %v", sumFile, err) - } - recVersion, recSum, _ := strings.Cut(strings.TrimSpace(string(raw)), " ") - switch { - case recVersion == version && recSum == sum: - case recVersion == version: - t.Fatalf("what npm packs for @novox/mesh-sdk changed while its version stayed %s: the registry would refuse a second, different %s. Raise @novox/mesh-sdk's version (package.json and package-lock.json) and update %s (UPDATE_PACKAGE_VERSION_SUM=1 go test -run TestPackageVersionMatchesPackedInputs in go/packageguard)", version, version, sumFile) - default: - t.Fatalf("package.json says %s but %s records %s: update %s (UPDATE_PACKAGE_VERSION_SUM=1 go test -run TestPackageVersionMatchesPackedInputs in go/packageguard)", version, sumFile, recVersion, sumFile) - } -} diff --git a/go/packageguard/reads-outside b/go/packageguard/reads-outside deleted file mode 100644 index f6b2d87..0000000 --- a/go/packageguard/reads-outside +++ /dev/null @@ -1 +0,0 @@ -These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/package-version.sum b/package-version.sum deleted file mode 100644 index c41176c..0000000 --- a/package-version.sum +++ /dev/null @@ -1 +0,0 @@ -0.1.18 5cbc2d043e45ab47eadff55a61f8be7d50144cec3cf295333b5ea1b4c181cfdf -- 2.54.0 From c60a46aa6a7f5e9b40f096fc063c3266e6a4100d Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:12:22 +0100 Subject: [PATCH 011/144] DLV-F3r sdk: views follower MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2's first-view package (owners' answer and views events, the readers' Follower with Changed(state, key, value, at), UnansweredFinding, the status tools) taken into mesh-sdk as go/views as it stands at reg-a2-mesh-delivery f0a5fe8, for every foundation reader (ADR 0593 §1, ADR 0615, ADR 0621). Its test takes the place of the vault's node-key test, merged into the vault's table, so mesh-sdk's unit count does not grow. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/vault_seat_test.go | 18 +- go/packages_list_test.go | 2 +- go/views/emit.go | 196 +++++++++++++++++ go/views/findings.go | 47 +++++ go/views/follow.go | 358 ++++++++++++++++++++++++++++++++ go/views/status.go | 145 +++++++++++++ go/views/tools.go | 159 ++++++++++++++ go/views/views.go | 244 ++++++++++++++++++++++ go/views/views_test.go | 204 ++++++++++++++++++ 9 files changed, 1356 insertions(+), 17 deletions(-) create mode 100644 go/views/emit.go create mode 100644 go/views/findings.go create mode 100644 go/views/follow.go create mode 100644 go/views/status.go create mode 100644 go/views/tools.go create mode 100644 go/views/views.go create mode 100644 go/views/views_test.go diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 1e06f5e..2d20e80 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -82,6 +82,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { // And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key. "own": {"credentials"}, "retire": {"node", "module", "name", "warrant"}, + // Issue 786: node-key, optional while the vault that runs does not serve it. + "node-key": {"key"}, } for _, gone := range []string{"accept", "move-asks"} { for _, v := range seat.Serves { @@ -143,19 +145,3 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { t.Fatalf("the vault serving its verbs was refused: %v", err) } } - -// Issue 786: the vault's seat promises node-key, optional while the vault that runs does not serve it. -func TestTheVaultsSeatPromisesNodeKey(t *testing.T) { - seat, _ := SeatNamed("mesh-vault") - for _, v := range seat.Serves { - if v.Name != "node-key" { - continue - } - props, _ := v.Input["properties"].(map[string]any) - if !v.Optional || props["key"] == nil { - t.Errorf("node-key: optional %v, key %v", v.Optional, props["key"]) - } - return - } - t.Error("the vault's seat does not promise node-key") -} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index d55f12a..e1c6cde 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -24,7 +24,7 @@ var meshSDKPackages = []string{ ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", - "sdkversion", "seal", "seatdef", "testbus", + "sdkversion", "seal", "seatdef", "testbus", "views", } // computesNot are the functions whose computation is one owner's alone (ADR 0593 §2, §7): the indexer computes a diff --git a/go/views/emit.go b/go/views/emit.go new file mode 100644 index 0000000..23a251a --- /dev/null +++ b/go/views/emit.go @@ -0,0 +1,196 @@ +package views + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// Emitter is an owner's process saying each change of its own states on its views family (ADR 0615 §5): it follows +// each state it is given from the revision its mark says it said up to, publishes one Event per change, and writes the +// mark once it has said every change the bus had handed it. A part watches only its own state (ADR 0596 §4): an owner +// follows its own buckets, never another's. +type Emitter struct { + // Owner is the owner module, said in every event. + Owner string + // Subject is where an event of the given local name lands on the owner's own stream (process.Account.StreamSubject). + Subject func(event string) string + // Publish sends one message to the owner's stream, its id the bus's dedup id. + Publish func(ctx context.Context, msg *nats.Msg, id string) error + // Marks is the owner's state MarksState; nil says nothing is marked, and every start says from the bucket's end. + Marks jetstream.KeyValue + // TooLarge is told of a value too large for an event (said as a gap): the owner's finding naming the key. Nil + // only logs it. + TooLarge func(state, key string, size int) + Logf func(string, ...any) + Now func() time.Time +} + +// JetStreamPublish is Emitter.Publish through js, with the bus's dedup id. +func JetStreamPublish(js jetstream.JetStream) func(context.Context, *nats.Msg, string) error { + return func(ctx context.Context, msg *nats.Msg, id string) error { + pub, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + _, err := js.PublishMsg(pub, msg, jetstream.WithMsgID(id)) + return err + } +} + +func (e Emitter) now() time.Time { + if e.Now != nil { + return e.Now().UTC() + } + return time.Now().UTC() +} + +func (e Emitter) logf(format string, args ...any) { + if e.Logf != nil { + e.Logf(format, args...) + } +} + +// created is when the bucket's stream was made, and its fill now. +func created(ctx context.Context, kv jetstream.KeyValue) (time.Time, uint64, Fill, error) { + st, err := kv.Status(ctx) + if err != nil { + return time.Time{}, 0, Fill{}, err + } + b, ok := st.(*jetstream.KeyValueBucketStatus) + if !ok || b.StreamInfo() == nil { + return time.Time{}, 0, Fill{}, fmt.Errorf("the bus answered no stream behind %s", st.Bucket()) + } + info := b.StreamInfo() + return info.Created.UTC(), info.State.LastSeq, Fill{Keys: uint64(info.State.NumSubjects), Bytes: info.State.Bytes, MaxBytes: info.Config.MaxBytes}, nil +} + +// Start follows state's bucket kv: from the revision after its mark when the mark is of the same bucket (the same +// Created), otherwise from the bucket's end, writing a mark there first, so a reader's first view taken now misses +// nothing. It returns once the watch is set; the changes are said on a goroutine until ctx ends, and a watch that ends +// is sent on errs. +func (e Emitter) Start(ctx context.Context, state string, kv jetstream.KeyValue, errs chan<- error) error { + made, last, fill, err := created(ctx, kv) + if err != nil { + return fmt.Errorf("the state %s cannot be followed for its views: %w", state, err) + } + from := last + 1 + if m, ok := e.mark(ctx, state); ok && m.Created.Equal(made) && m.Revision <= last { + from = m.Revision + 1 + } else { + e.writeMark(ctx, state, Mark{State: state, Revision: last, Created: made, Fill: fill}) + } + w, err := kv.WatchAll(ctx, jetstream.ResumeFromRevision(from)) + if err != nil { + return fmt.Errorf("the state %s cannot be watched for its views: %w", state, err) + } + go func() { + defer w.Stop() + said := uint64(0) + for { + select { + case <-ctx.Done(): + return + case u, ok := <-w.Updates(): + if !ok { + if errs != nil { + errs <- fmt.Errorf("the watch of %s for its views ended", state) + } + return + } + if u != nil { + if err := e.say(ctx, state, made, u); err != nil { + // Not marked: the next start says it again from the mark, and the bus keeps it once by its id. + e.logf("views: %s.%s at %d is not said: %v", state, u.Key(), u.Revision(), err) + continue + } + said = u.Revision() + } + // Marked once every change handed so far is said, so a busy state writes one mark per burst. + if said > 0 && len(w.Updates()) == 0 { + _, _, fill, err := created(ctx, kv) + if err != nil { + e.logf("views: the fill of %s is not read: %v", state, err) + } + e.writeMark(ctx, state, Mark{State: state, Revision: said, Created: made, Fill: fill}) + said = 0 + } + } + } + }() + return nil +} + +// Event is the event saying u, a change of state made at made. +func (e Emitter) Event(state string, made time.Time, u jetstream.KeyValueEntry) Event { + ev := Event{V: Version, Owner: e.Owner, State: state, Key: u.Key(), Revision: u.Revision(), Created: made, At: u.Created().UTC()} + if u.Operation() != jetstream.KeyValuePut { + ev.Deleted = true + return ev + } + if n := len(u.Value()); n > MaxValueBytes { + ev.Gap = true + e.logf("views: %s.%s at %d holds %d bytes, more than an event carries: said as a gap, so a reader asks its first view", state, u.Key(), u.Revision(), n) + if e.TooLarge != nil { + e.TooLarge(state, u.Key(), n) + } + return ev + } + ev.Value = json.RawMessage(u.Value()) + if !json.Valid(ev.Value) { + ev.Value, _ = json.Marshal(string(u.Value())) + } + return ev +} + +func (e Emitter) say(ctx context.Context, state string, made time.Time, u jetstream.KeyValueEntry) error { + ev := e.Event(state, made, u) + data, err := ev.Encode() + if err != nil { + return err + } + msg := nats.NewMsg(e.Subject(EventName(state))) + msg.Header.Set("x-event-id", ev.ID()) + msg.Header.Set("x-source", e.Owner) + msg.Header.Set("x-time", e.now().Format(time.RFC3339Nano)) + msg.Header.Set("content-type", "application/json") + msg.Data = data + return e.Publish(ctx, msg, ev.ID()) +} + +func (e Emitter) mark(ctx context.Context, state string) (Mark, bool) { + if e.Marks == nil { + return Mark{}, false + } + got, err := e.Marks.Get(ctx, state) + if err != nil { + if !errors.Is(err, jetstream.ErrKeyNotFound) { + e.logf("views: the mark of %s is not read (%v): said from the bucket's end", state, err) + } + return Mark{}, false + } + m, err := DecodeMark(got.Value()) + if err != nil { + e.logf("views: the mark of %s is not read (%v): said from the bucket's end", state, err) + return Mark{}, false + } + return m, true +} + +func (e Emitter) writeMark(ctx context.Context, state string, m Mark) { + if e.Marks == nil { + return + } + m.V, m.At = Version, e.now() + data, err := json.Marshal(m) + if err != nil { + e.logf("views: the mark of %s is not written: %v", state, err) + return + } + if _, err := e.Marks.Put(ctx, state, data); err != nil { + e.logf("views: the mark of %s at %d is not written (%v): a first view answers the one before", state, m.Revision, err) + } +} diff --git a/go/views/findings.go b/go/views/findings.go new file mode 100644 index 0000000..9795fe1 --- /dev/null +++ b/go/views/findings.go @@ -0,0 +1,47 @@ +package views + +import ( + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// The findings a views part writes in its own state `findings`, which the condition keeper raises and clears +// (novox/hq ADR 0564 §2): an owner's value too large for an event (TooLargeKind), and a reader's first view unanswered +// past its bound (UnansweredKind). +const ( + TooLargeKind = "view-too-large" + UnansweredKind = "first-view-unanswered" +) + +// TooLargeFinding is the owner's finding of state's key too large to say whole on its views family. +func TooLargeFinding(owner, state, key string, size int, at time.Time) (string, []byte, error) { + id := owner + "." + state + "." + key + summary := fmt.Sprintf("%s's %s.%s holds %d bytes, more than one views event carries (%d): every reader asks its first view again at each change of it", owner, state, key, size, MaxValueBytes) + return finding(owner, TooLargeKind, id, summary, + fmt.Sprintf("A value of %s is too large for its views event", owner), + "Each change of this key is said as a gap, so every reader of the state asks the owner's first view once more. "+ + "It works, but costs a first view per change; the value should be made smaller or split.", at) +} + +// UnansweredFinding is a reader's finding of its first view of s unanswered past its bound (ADR 0615 §6): it names the +// owner and the tool. A first view answered at revision 0 with nothing in it is said too, with the operator's step. +func UnansweredFinding(reader string, s Status, at time.Time) (string, []byte, error) { + id := reader + "." + s.Owner + "." + s.State + summary := fmt.Sprintf("%s has no first view of %s's %s: %s has not answered since %s (%s)", reader, s.Owner, s.State, s.Tool, + s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error)) + return finding(reader, UnansweredKind, id, summary, + fmt.Sprintf("%s waits for %s", reader, s.Owner), + fmt.Sprintf("%s reads %s's %s only through %s, and keeps asking it; it never reads the state itself. The answer clears this. "+ + "Look at %s's runtime of its own: is it running and connected?", reader, s.Owner, s.State, s.Tool, s.Owner), at) +} + +func finding(observer, kind, id, summary, headline, explanation string, at time.Time) (string, []byte, error) { + key := health.FindingKey(health.ScopeModule, id, kind) + value, err := health.EncodeFinding(health.Finding{Key: key, Kind: kind, Subject: health.Subject{Scope: health.ScopeModule, ID: id}, + Severity: health.Warning, Summary: summary, Headline: headline, Explanation: explanation, + Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, Source: observer + "." + kind, Observer: observer, At: at.UTC()}) + return key, value, err +} diff --git a/go/views/follow.go b/go/views/follow.go new file mode 100644 index 0000000..2c0a470 --- /dev/null +++ b/go/views/follow.go @@ -0,0 +1,358 @@ +package views + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "sync" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// Follower is a reader's picture of one state of an owner (ADR 0615 §4 to §6): it asks the owner's first-view tool at +// its start, then keeps the picture from the owner's events alone, asking the tool once more only on a gap in revisions +// (said in its log), never on a timer. Started while the owner is down, it keeps asking, never reading the owner's state +// instead, and past Bound raises its condition naming the owner and the tool; the answer clears it. +type Follower struct { + Owner string + // Tool is the owner's tool it asks, as the reader's invokes names it (.). + Tool string + State string + // Ask calls the tool for the page after after. + Ask func(ctx context.Context, after string) (Answer, error) + // Raise and Clear are the reader's condition of this first view (past Bound; cleared by the answer). Nil says + // nothing. + Raise func(Status) + Clear func(Status) + // Changed is told of each key the picture changes (deleted: the key is gone), after the change is held, with when it + // was written: the zero time for a key a first view brought (since before what the reader heard). + Changed func(key string, value json.RawMessage, deleted bool, at time.Time) + // Said is told of the status each time an ask starts, is answered or fails, and of a raise and a clear: where a + // reader keeps it on the bus for its read tool (StatusTools). + Said func(Status) + Logf func(string, ...any) + Now func() time.Time + // Bound is how long it asks before it raises; zero is views.Bound. Wait is the first wait between two asks that + // failed, doubling to a minute; zero is five seconds. + Bound time.Duration + Wait time.Duration + + mu sync.Mutex + values map[string]json.RawMessage + st Status + asking bool + // during are the events heard while an ask runs: taken again after its answer, so none is lost to a picture read + // before it was written. + during []Event + ready chan struct{} +} + +// Status is what a reader's read tool says of one first view (design 25 § 12.2): the owner, the tool and the revision +// its picture holds, when it asked and when it was answered, how many asks, the hand acts among them, and whether its +// condition is raised. +type Status struct { + Owner string `json:"owner"` + Tool string `json:"tool"` + State string `json:"state"` + Revision uint64 `json:"revision"` + Created time.Time `json:"created,omitzero"` + Keys int `json:"keys"` + Asked time.Time `json:"asked,omitzero"` + Answered time.Time `json:"answered,omitzero"` + Asks int `json:"asks"` + ByHand int `json:"by-hand"` + Raised bool `json:"raised"` + // Why is the last reason it asked (start, a gap and which, a hand act) or why the last ask failed. + Why string `json:"why,omitempty"` + Error string `json:"error,omitempty"` +} + +func (f *Follower) now() time.Time { + if f.Now != nil { + return f.Now().UTC() + } + return time.Now().UTC() +} + +func (f *Follower) logf(format string, args ...any) { + if f.Logf != nil { + f.Logf(format, args...) + } +} + +func (f *Follower) init() { + if f.values == nil { + f.values = map[string]json.RawMessage{} + f.ready = make(chan struct{}) + f.st = Status{Owner: f.Owner, Tool: f.Tool, State: f.State} + } +} + +func (f *Follower) say(s Status) { + if f.Said != nil { + f.mu.Lock() + s.Keys = len(f.values) + f.mu.Unlock() + f.Said(s) + } +} + +// Status is the follower's status now. +func (f *Follower) Status() Status { + f.mu.Lock() + defer f.mu.Unlock() + f.init() + s := f.st + s.Keys = len(f.values) + return s +} + +// Ready is closed once a first view was answered. +func (f *Follower) Ready() <-chan struct{} { + f.mu.Lock() + defer f.mu.Unlock() + f.init() + return f.ready +} + +// Values is a copy of the picture. +func (f *Follower) Values() map[string]json.RawMessage { + f.mu.Lock() + defer f.mu.Unlock() + f.init() + return maps.Clone(f.values) +} + +// Get is one key of the picture. +func (f *Follower) Get(key string) (json.RawMessage, bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.init() + v, ok := f.values[key] + return v, ok +} + +// Start asks the first view until it is answered or ctx ends (ADR 0615 §6): each failed ask waits, doubling, and past +// Bound raises the condition. It returns once answered. +func (f *Follower) Start(ctx context.Context) error { return f.askUntil(ctx, "start", false) } + +// Again asks the first view once more by hand (a re-read tool, recorded by the caller as a hand act), until answered or +// ctx ends. +func (f *Follower) Again(ctx context.Context, why string) error { + return f.askUntil(ctx, "by hand: "+why, true) +} + +func (f *Follower) askUntil(ctx context.Context, why string, byHand bool) error { + f.mu.Lock() + f.init() + if f.asking { + f.mu.Unlock() + return nil // one ask at a time; the one running brings the picture to its newest + } + f.asking = true + f.during = nil + f.st.Why = why + if byHand { + f.st.ByHand++ + } + f.mu.Unlock() + defer func() { f.mu.Lock(); f.asking = false; f.mu.Unlock() }() + bound, wait := f.Bound, f.Wait + if bound == 0 { + bound = Bound + } + if wait == 0 { + wait = 5 * time.Second + } + first := f.now() + for { + err := f.askOnce(ctx) + if err == nil { + return nil + } + f.mu.Lock() + f.st.Error = err.Error() + raise := !f.st.Raised && f.now().Sub(first) >= bound + if raise { + f.st.Raised = true + } + s := f.st + f.mu.Unlock() + f.logf("the first view %s of %s's %s is not answered (%v): asked again in %s", f.Tool, f.Owner, f.State, err, wait) + if raise && f.Raise != nil { + f.Raise(s) + } + f.say(s) + pause, cancel := context.WithTimeout(ctx, wait) // a context's bound between two asks, no timer of its own + <-pause.Done() + cancel() + if ctx.Err() != nil { + return ctx.Err() + } + if wait *= 2; wait > time.Minute { + wait = time.Minute + } + } +} + +// askOnce asks every page and, once whole, takes it as the picture. +func (f *Follower) askOnce(ctx context.Context) error { + f.mu.Lock() + f.st.Asked, f.st.Asks = f.now(), f.st.Asks+1 + asked := f.st + f.mu.Unlock() + f.say(asked) + var first Answer + values := map[string]json.RawMessage{} + after := "" + for page := 0; ; page++ { + a, err := f.Ask(ctx, after) + if err != nil { + return err + } + if page == 0 { + first = a + } + maps.Copy(values, a.Values) + if a.Next == "" || a.Next == after { + break + } + after = a.Next + } + f.mu.Lock() + old := f.values + f.values = values + wasRaised := f.st.Raised + f.st.Revision, f.st.Created, f.st.Answered, f.st.Raised, f.st.Error = first.Revision, first.Created, f.now(), false, "" + // The events heard while it asked, after the answer's revision and in order, are taken again. + during := f.during + f.during = nil + slices.SortFunc(during, func(a, b Event) int { return cmp.Compare(a.Revision, b.Revision) }) + for _, ev := range during { + if ev.Created.Equal(f.st.Created) && ev.Revision == f.st.Revision+1 && !ev.Gap { + if ev.Deleted { + delete(f.values, ev.Key) + } else { + f.values[ev.Key] = ev.Value + } + f.st.Revision = ev.Revision + } + } + values = maps.Clone(f.values) + s := f.st + select { + case <-f.ready: + default: + close(f.ready) + } + f.mu.Unlock() + if wasRaised && f.Clear != nil { + f.Clear(s) + } + f.say(s) + if f.Changed != nil { + for k, v := range values { + if ov, ok := old[k]; !ok || string(ov) != string(v) { + f.Changed(k, v, false, time.Time{}) + } + } + for k := range old { + if _, ok := values[k]; !ok { + f.Changed(k, nil, true, time.Time{}) + } + } + } + return nil +} + +// Apply takes one event of the owner's views family into the picture and says whether it was a gap: another Created +// (the owner's state made again), a revision past the one after the picture's, or a value too large to carry. A +// revision at or below the picture's is already in it (a durable consumer handing what it held) and is passed over. +// Before the first view is answered an event is passed over too: the answer holds it. +func (f *Follower) Apply(ev Event) (gap bool, why string) { + f.mu.Lock() + f.init() + if f.asking { + f.during = append(f.during, ev) + } + select { + case <-f.ready: + default: + f.mu.Unlock() + return false, "" + } + switch { + case !ev.Created.Equal(f.st.Created): + why = fmt.Sprintf("%s's %s was made again (or not marked when the first view was answered): created %s, held %s", f.Owner, f.State, ev.Created.Format(time.RFC3339), f.st.Created.Format(time.RFC3339)) + case ev.Revision <= f.st.Revision: + f.mu.Unlock() + return false, "" + case ev.Revision > f.st.Revision+1: + why = fmt.Sprintf("%s's %s at revision %d, the picture holds %d: %d changes missed", f.Owner, f.State, ev.Revision, f.st.Revision, ev.Revision-f.st.Revision-1) + case ev.Gap: + why = fmt.Sprintf("%s's %s.%s at revision %d was too large to carry", f.Owner, f.State, ev.Key, ev.Revision) + } + if why != "" { + f.mu.Unlock() + return true, why + } + if ev.Deleted { + delete(f.values, ev.Key) + } else { + f.values[ev.Key] = ev.Value + } + f.st.Revision = ev.Revision + f.mu.Unlock() + if f.Changed != nil { + f.Changed(ev.Key, ev.Value, ev.Deleted, ev.At) + } + return false, "" +} + +// Hear is a reader's consumer of one owner's views family, routing each event to the follower of its state; a gap asks +// that follower's first view once more on its own goroutine, said in the log. A message it cannot read is terminated. +func Hear(ctx context.Context, followers map[string]*Follower, logf func(string, ...any)) func(jetstream.Msg) { + return func(m jetstream.Msg) { + ev, err := DecodeEvent(m.Data()) + if err != nil { + logf("views: %s is not a views event, dropped: %v", m.Subject(), err) + _ = m.Term() + return + } + f := followers[ev.State] + if f == nil { + _ = m.Ack() // a state this reader does not follow + return + } + if gap, why := f.Apply(ev); gap { + logf("views: a gap in %s (%s): its first view %s is asked once more", ev.State, why, f.Tool) + go func() { + f.mu.Lock() + f.init() + f.st.Why = "a gap: " + why + f.mu.Unlock() + _ = f.askUntil(ctx, "a gap: "+why, false) + }() + } + _ = m.Ack() + } +} + +// AskVia is a Follower's Ask through a process's call of the owner's tool (process.Conn.Call), decoding its answer. +func AskVia(call func(key string, body any) (json.RawMessage, error), tool string) func(context.Context, string) (Answer, error) { + return func(_ context.Context, after string) (Answer, error) { + body := map[string]any{} + if after != "" { + body["after"] = after + } + raw, err := call(tool, body) + if err != nil { + return Answer{}, err + } + return DecodeAnswer(raw) + } +} diff --git a/go/views/status.go b/go/views/status.go new file mode 100644 index 0000000..19907d3 --- /dev/null +++ b/go/views/status.go @@ -0,0 +1,145 @@ +package views + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/nats-io/nats.go/jetstream" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// StatusState is a reader's own state of its first views (design 25 § 12.2): at StatusKey each first view's Status, as +// its process last said it, and at AgainKey a hand ask of its re-read tool, which the process takes and deletes. A +// process serves no tool (ADR 0436), so its read and re-read tools, in its tools bundle, meet it here. +const StatusState = "first-views" + +// StatusKey is where a reader keeps its first view of owner's state. +func StatusKey(owner, state string) string { return owner + "." + state } + +// AgainKey is where a re-read tool asks a reader's process to ask owner's first view of state again. +func AgainKey(owner, state string) string { return "again." + owner + "." + state } + +// HandAsk is one ask of a re-read tool: who asked (the runtime's proof), why and when. +type HandAsk struct { + V int `json:"v"` + Owner string `json:"owner"` + State string `json:"state"` + Why string `json:"why"` + By string `json:"by"` + At time.Time `json:"at"` +} + +// KeepStatus is a Follower's Said that writes its status at StatusKey of kv (the reader's StatusState). +func KeepStatus(ctx context.Context, kv jetstream.KeyValue, logf func(string, ...any)) func(Status) { + return func(s Status) { + data, err := json.Marshal(s) + if err == nil { + _, err = kv.Put(ctx, StatusKey(s.Owner, s.State), data) + } + if err != nil && logf != nil { + logf("views: the status of the first view %s is not kept: %v", s.Tool, err) + } + } +} + +// HearAgain takes each hand ask written at an AgainKey of kv, those standing at the start included, asks that +// follower's first view again with the ask's words and who asked, and deletes the ask. It returns once the watch is set. +func HearAgain(ctx context.Context, kv jetstream.KeyValue, followers []*Follower, logf func(string, ...any)) error { + w, err := kv.Watch(ctx, "again.>") + if err != nil { + return err + } + go func() { + defer func() { _ = w.Stop() }() + for { + select { + case <-ctx.Done(): + return + case e, ok := <-w.Updates(): + if !ok { + return + } + if e == nil || e.Operation() != jetstream.KeyValuePut { + continue + } + var a HandAsk + if err := json.Unmarshal(e.Value(), &a); err != nil { + logf("views: the hand ask %s is not read: %v", e.Key(), err) + _ = kv.Delete(ctx, e.Key()) + continue + } + i := slices.IndexFunc(followers, func(f *Follower) bool { return f.Owner == a.Owner && f.State == a.State }) + if i < 0 { + logf("views: the hand ask %s names no first view this part holds", e.Key()) + _ = kv.Delete(ctx, e.Key()) + continue + } + logf("views: %s asks the first view %s again by hand: %s", a.By, followers[i].Tool, a.Why) + _ = kv.Delete(ctx, e.Key(), jetstream.LastRevision(e.Revision())) + f := followers[i] + go func() { _ = f.Again(ctx, a.Why+" ("+a.By+")") }() + } + } + }() + return nil +} + +// StatusTools are a reader's read tool of its first views (read) and its re-read tool (again), in its tools bundle, +// over its StatusState: read lists what the process last said of each; again writes a hand ask the process takes, +// naming the caller the runtime proved, and is recorded in the status's by-hand count and why. +func StatusTools(reader, read, again string, st stdio.KeptState, now func() time.Time) []stdio.Tool { + return []stdio.Tool{ + {Name: read, + Description: fmt.Sprintf("%s's first views of the indexer's and the assigner's states (novox/hq ADR 0615): per owner and state, "+ + "the tool it asks, the revision its picture held when last answered, when it asked and was answered, how many asks "+ + "(by hand among them) and whether its condition (unanswered past two minutes) is raised.", reader), + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(map[string]any) (any, error) { + keys, err := st.Keys() + if err != nil { + return nil, fmt.Errorf("the state %s cannot be listed: %w", StatusState, err) + } + out := []Status{} + for _, k := range keys { + if strings.HasPrefix(k, "again.") { + continue + } + e, err := st.Get(k) + if err != nil || e == nil { + continue + } + var s Status + if json.Unmarshal(e.Value, &s) == nil { + out = append(out, s) + } + } + return map[string]any{"first-views": out}, nil + }}, + {Name: again, + Description: fmt.Sprintf("Ask %s's first view of an owner's state again now, by hand (a hand act, its caller and why kept in "+ + "the status %s reads): for a picture a person doubts. The part asks once and follows the owner's events after the answer.", reader, read), + Input: map[string]any{"type": "object", "required": []string{"owner", "state", "why"}, "properties": map[string]any{ + "owner": map[string]any{"type": "string", "description": "the owner: mesh-delivery-indexer or mesh-delivery-assigner"}, + "state": map[string]any{"type": "string", "description": "the owner's state, as the read tool names it"}, + "why": map[string]any{"type": "string", "description": "why it is asked again, in plain words"}}}, + Run: func(args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + state, _ := args["state"].(string) + why, _ := args["why"].(string) + if owner == "" || state == "" || strings.TrimSpace(why) == "" { + return nil, errors.New("again names the owner, the state and why") + } + a := HandAsk{V: Version, Owner: owner, State: state, Why: strings.TrimSpace(why), By: stdio.CallerOf(args), At: now().UTC()} + if _, err := st.Put(AgainKey(owner, state), a); err != nil { + return nil, fmt.Errorf("the hand ask is not written: %w", err) + } + return map[string]any{"asked": a, "read": read}, nil + }}, + } +} diff --git a/go/views/tools.go b/go/views/tools.go new file mode 100644 index 0000000..e14f3e3 --- /dev/null +++ b/go/views/tools.go @@ -0,0 +1,159 @@ +package views + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// KeptSource is a state as a tools bundle reads it through its runtime (stdio.KeptState), as a Source. +type KeptSource struct{ State stdio.KeptState } + +// Keys is every key with a value. +func (k KeptSource) Keys() ([]string, error) { return k.State.Keys() } + +// Get is one key's value and revision. +func (k KeptSource) Get(key string) (*Entry, bool, error) { + e, err := k.State.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return &Entry{Value: e.Value, Revision: e.Revision}, true, nil +} + +// ParseCallers is a callers list as a manifest writes it into an env value: comma-separated, each a module (on any +// machine), . or person. (stdio.Callers). +func ParseCallers(list string) stdio.Callers { + var out stdio.Callers + for _, c := range strings.Split(list, ",") { + if c = strings.TrimSpace(c); c != "" { + out = append(out, c) + } + } + return out +} + +// CallersEnv is the env value of a first-view tool's callers list, in the owner's tools bundle: MESH_VIEW_CALLERS_, +// the tool's name in capitals, a hyphen an underscore. +func CallersEnv(tool string) string { + return "MESH_VIEW_CALLERS_" + strings.ToUpper(strings.ReplaceAll(tool, "-", "_")) +} + +// Refusal is why caller may not ask tool, or nil: the caller's proof, never an argument, against the tool's callers +// list holding exactly its readers (ADR 0621 §2, ADR 0622 §1); the owner itself is on no list, so it is refused too. +func Refusal(tool string, callers stdio.Callers, caller string) error { + if callers.Allows(caller) { + return nil + } + return fmt.Errorf("%s is refused to %q: the tool answers only %s (novox/hq ADR 0621 §2)", tool, caller, strings.Join(callers, ", ")) +} + +// FirstViewTool is an owner's first-view tool of one of its states (ADR 0621 §1): each call answers one page of what the +// state holds now, at the revision its mark says, to the callers its list names and nobody else. It reads only the +// owner's own state, so it answers as soon as the runtime is connected, before the owner has asked anyone anything +// (ADR 0621 §3). +func FirstViewTool(owner, name, state, words string, callers stdio.Callers, values, marks Source, now func() time.Time) stdio.Tool { + return stdio.Tool{ + Name: name, + Description: fmt.Sprintf("The first view of %s's state %s (novox/hq ADR 0615, ADR 0621): %s Each value whole by key, at the revision "+ + "the owner said its views family up to, so a reader then follows the events on that family and asks again only on a gap in "+ + "revisions. One page at a time: next is the key to ask after for the rest. Answered only to %s; any other caller is refused.", + owner, state, words, strings.Join(callers, ", ")), + Input: map[string]any{"type": "object", "properties": map[string]any{ + "after": map[string]any{"type": "string", "description": "the key the page before ended at (its next); none starts at the first"}}}, + Run: func(args map[string]any) (any, error) { + if err := Refusal(name, callers, stdio.CallerOf(args)); err != nil { + return nil, err + } + after, _ := args["after"].(string) + return FirstView(owner, state, values, marks, strings.TrimSpace(after), now()) + }, + } +} + +// ReadTool is an owner's ordinary read tool of one of its states (design 25 § 12.2): the keys, one key's value and +// revision, or a page of values in the first view's form, and the state's fill against its cap as its mark last said +// it. Granted, as any tool is, to every reader that invokes it, and callable by a session. +func ReadTool(owner, name, state, words string, values, marks Source, now func() time.Time) stdio.Tool { + return stdio.Tool{ + Name: name, + Description: fmt.Sprintf("Read %s's state %s: %s With no argument, its keys and how full it is against its cap (as the "+ + "owner last marked it); with key, that key's value and revision; with values, a page of values whole by key, at the "+ + "revision the owner said its views family up to, continued with after (the page's next).", owner, state, words), + Input: map[string]any{"type": "object", "properties": map[string]any{ + "key": map[string]any{"type": "string", "description": "one key: its value and revision"}, + "values": map[string]any{"type": "boolean", "description": "a page of values whole by key"}, + "after": map[string]any{"type": "string", "description": "with values: the key the page before ended at"}}}, + Run: func(args map[string]any) (any, error) { + key, _ := args["key"].(string) + after, _ := args["after"].(string) + all, _ := args["values"].(bool) + return Read(owner, state, values, marks, strings.TrimSpace(key), all, strings.TrimSpace(after), now()) + }, + } +} + +// ReadAnswer is a read tool's answer without values: the state's keys and fill. +type ReadAnswer struct { + Owner string `json:"owner"` + State string `json:"state"` + Keys []string `json:"keys"` + Fill *Fill `json:"fill,omitempty"` + Revision uint64 `json:"revision"` + Marked time.Time `json:"marked,omitzero"` + // Words say what the fill means where the owner has not marked the state yet. + Words string `json:"words,omitempty"` +} + +// KeyAnswer is one key of a read tool's answer. +type KeyAnswer struct { + Owner string `json:"owner"` + State string `json:"state"` + Key string `json:"key"` + Found bool `json:"found"` + Value json.RawMessage `json:"value,omitempty"` + Revision uint64 `json:"revision,omitempty"` +} + +// Read is a read tool's answer: one key, a page of values, or the keys and fill. +func Read(owner, state string, values, marks Source, key string, all bool, after string, now time.Time) (any, error) { + switch { + case key != "": + e, ok, err := values.Get(key) + if err != nil { + return nil, fmt.Errorf("%s.%s cannot be read: %w", state, key, err) + } + a := KeyAnswer{Owner: owner, State: state, Key: key, Found: ok} + if ok { + a.Value, a.Revision = e.Value, e.Revision + } + return a, nil + case all: + return FirstView(owner, state, values, marks, after, now) + } + keys, err := values.Keys() + if err != nil { + return nil, fmt.Errorf("the state %s cannot be listed: %w", state, err) + } + slices.Sort(keys) + a := ReadAnswer{Owner: owner, State: state, Keys: keys} + if marks != nil { + if e, ok, err := marks.Get(state); err == nil && ok { + if m, err := DecodeMark(e.Value); err == nil { + fill := m.Fill + a.Fill, a.Revision, a.Marked = &fill, m.Revision, m.At + } + } + } + if a.Fill == nil { + a.Words = "the owner has not marked this state yet: its fill is said once its views family says a change of it" + } + if a.Keys == nil { + a.Keys = []string{} + } + return a, nil +} diff --git a/go/views/views.go b/go/views/views.go new file mode 100644 index 0000000..2be1f32 --- /dev/null +++ b/go/views/views.go @@ -0,0 +1,244 @@ +// Package views is the owners' side and the readers' side of a first view (novox/hq ADR 0615, ADR 0621, ADR 0622; +// design 25 § 12.2): the indexer and the assigner answer a reader's first view of one of their states as a tool of +// their own module (Answer, FirstView), say each change of that state as one event on their views family (Event, +// Emitter), and a reader keeps its picture from the answer and the events, asking the tool again only on a gap in +// revisions (Follower). The tools' read side (Read) answers the ordinary read tools of § 12.2 too. +package views + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" +) + +// Version is the version of every value this package writes and the only one it reads. +const Version = 1 + +// The owners' views families (design 25 § 12.2; ADR 0615 §5, ADR 0619 §2): one stream each on its owner's old `streams` +// section, kept by limits 7 days and at most 256 MiB, dropping its oldest when full, so a reader that missed a dropped +// event sees the gap in revisions and asks once more. A stream's name is the mesh's, so each carries its owner's name. +const ( + IndexerStream = "indexer-views" + AssignerStream = "assigner-views" + StreamDays = 7 + StreamMiB = 256 + // EventPrefix starts each event's local name: `views.`, one event per changed key of that state. + EventPrefix = "views." + // EventPattern is every event of a views family, as a stream's events and a reader's consumes name it. + EventPattern = EventPrefix + "**" +) + +// MarksState is an owner's own state of the revision each followed state was said up to (Mark), one key per state: +// what a first view answers its revision from, so a reader that takes the answer misses no event after it. +const MarksState = "view-marks" + +// MaxValueBytes is the largest value an event carries whole: under the bus's largest message (1 MiB, the server's +// max_payload) with the event's own fields. A larger one is said as its revision alone (Event.Gap), and the owner +// writes a finding naming the key (design 25 § 12.2). +const MaxValueBytes = 960 * 1024 + +// PageBytes bounds the values one answer carries, so the answer fits one bus message; Answer.Next continues it. +const PageBytes = 768 * 1024 + +// Bound is how long a reader waits for its first view before it raises the condition naming the owner and the verb +// (ADR 0615 §6): longer than an owner's start, so a normal start, readers and owners coming up together, tells nobody. +const Bound = 2 * time.Minute + +// EventName is the local name of the event saying a change of state: `views.`. +func EventName(state string) string { return EventPrefix + state } + +// StateOfEvent is the state an event's local name (or the last tokens of its subject) says, or "". +func StateOfEvent(name string) string { + if i := strings.LastIndex(name, ".event."+EventPrefix); i >= 0 { + name = name[i+len(".event."):] + } + s, ok := strings.CutPrefix(name, EventPrefix) + if !ok || s == "" || strings.ContainsAny(s, ".*> ") { + return "" + } + return s +} + +// Event is one change of one key of an owner's state, said on its views family: the key's value whole, or a delete +// mark, at the revision of the state the change was written at. Created is when the owner's state was made on the bus: +// a state made again (the bus module's recreate) starts its revisions again, and a reader takes another Created as a gap. +type Event struct { + V int `json:"v"` + Owner string `json:"owner"` + State string `json:"state"` + Key string `json:"key"` + Revision uint64 `json:"revision"` + Created time.Time `json:"created"` + Deleted bool `json:"deleted,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + // At is when the change was written on the bus. + At time.Time `json:"at,omitzero"` + // Gap says the value was too large to carry (MaxValueBytes): the reader asks its first view once. + Gap bool `json:"gap,omitempty"` +} + +// ID is the event's id on the bus, so the same change said twice (an owner's restart) is kept once. +func (e Event) ID() string { + return fmt.Sprintf("%s-%s-%d-%d", e.Owner, e.State, e.Created.UnixNano(), e.Revision) +} + +// Encode writes an event. +func (e Event) Encode() ([]byte, error) { + e.V = Version + if e.Owner == "" || e.State == "" || e.Key == "" || e.Revision == 0 { + return nil, errors.New("views: an event names its owner, state, key and revision") + } + if e.Deleted || e.Gap { + e.Value = nil + } + return json.Marshal(e) +} + +// DecodeEvent reads an event of this version. +func DecodeEvent(data []byte) (Event, error) { + var e Event + if err := decode(data, &e); err != nil { + return Event{}, err + } + if e.State == "" || e.Key == "" || e.Revision == 0 { + return Event{}, errors.New("views: an event names no state, key or revision") + } + return e, nil +} + +// Mark is the revision an owner said its state up to, and when that state was made (MarksState). +type Mark struct { + V int `json:"v"` + State string `json:"state"` + Revision uint64 `json:"revision"` + Created time.Time `json:"created"` + At time.Time `json:"at"` + // Fill is the state's bucket as the bus held it when the mark was written: what a read tool says of its fill. + Fill Fill `json:"fill"` +} + +// Fill is a state's bucket's fill against its cap, as the bus says it. +type Fill struct { + Keys uint64 `json:"keys"` + Bytes uint64 `json:"bytes"` + MaxBytes int64 `json:"max-bytes"` +} + +// DecodeMark reads a mark of this version. +func DecodeMark(data []byte) (Mark, error) { + var m Mark + return m, decode(data, &m) +} + +// Answer is a first view, or one page of an ordinary read: the state's values by key, from After on, at the revision +// of the state the owner said up to (Mark) before it read them; Next is the key to ask after for the rest, empty when +// the answer is whole. A reader keeps the first page's revision: a value on a later page may be newer, and the events +// after that revision bring every key to its newest in order. +type Answer struct { + V int `json:"v"` + Owner string `json:"owner"` + State string `json:"state"` + Revision uint64 `json:"revision"` + Created time.Time `json:"created"` + Values map[string]json.RawMessage `json:"values"` + Next string `json:"next,omitempty"` + At time.Time `json:"at"` +} + +// DecodeAnswer reads an answer of this version. +func DecodeAnswer(data []byte) (Answer, error) { + var a Answer + if err := decode(data, &a); err != nil { + return Answer{}, err + } + if a.Values == nil { + a.Values = map[string]json.RawMessage{} + } + return a, nil +} + +// Entry is one key's value and revision, as a state read gives it. +type Entry struct { + Value json.RawMessage + Revision uint64 +} + +// Source is the read side of one state, as a tools bundle reaches its own state (stdio.KeptState's Keys and Get). +type Source interface { + Keys() ([]string, error) + Get(key string) (*Entry, bool, error) +} + +// FirstView answers one page of state from its owner's source: the mark first, so no change after the revision it +// answers at is missed, then the values in key order after after, until PageBytes. A state not said yet (no mark) +// answers revision 0: a first view of whatever it holds, which the reader takes as a gap at the first event. +func FirstView(owner, state string, values, marks Source, after string, now time.Time) (Answer, error) { + a := Answer{V: Version, Owner: owner, State: state, Values: map[string]json.RawMessage{}, At: now.UTC()} + if marks != nil { + e, ok, err := marks.Get(state) + if err != nil { + return Answer{}, fmt.Errorf("the mark of %s cannot be read: %w", state, err) + } + if ok { + m, err := DecodeMark(e.Value) + if err != nil { + return Answer{}, fmt.Errorf("the mark of %s: %w", state, err) + } + a.Revision, a.Created = m.Revision, m.Created + } + } + keys, err := values.Keys() + if err != nil { + return Answer{}, fmt.Errorf("the state %s cannot be listed: %w", state, err) + } + slices.Sort(keys) + size := 0 + for _, k := range keys { + if after != "" && k <= after { + continue + } + e, ok, err := values.Get(k) + if err != nil { + return Answer{}, fmt.Errorf("%s.%s cannot be read: %w", state, k, err) + } + if !ok { + continue + } + if size > 0 && size+len(k)+len(e.Value) > PageBytes { + a.Next = lastKey(a.Values) + break + } + a.Values[k] = e.Value + size += len(k) + len(e.Value) + } + return a, nil +} + +func lastKey(m map[string]json.RawMessage) string { + last := "" + for k := range m { + if k > last { + last = k + } + } + return last +} + +func decode(data []byte, into any) error { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return fmt.Errorf("views: value does not fit its shape: %w", err) + } + if probe.V == nil || *probe.V != Version { + return fmt.Errorf("views: unknown value version: this reader reads %d", Version) + } + if err := json.Unmarshal(data, into); err != nil { + return fmt.Errorf("views: value does not fit its shape: %w", err) + } + return nil +} diff --git a/go/views/views_test.go b/go/views/views_test.go new file mode 100644 index 0000000..da65aad --- /dev/null +++ b/go/views/views_test.go @@ -0,0 +1,204 @@ +package views + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// mapSource is a state held in memory, as a Source. +type mapSource map[string]Entry + +func (m mapSource) Keys() ([]string, error) { + var out []string + for k := range m { + out = append(out, k) + } + return out, nil +} + +func (m mapSource) Get(key string) (*Entry, bool, error) { + e, ok := m[key] + if !ok { + return nil, false, nil + } + return &e, true, nil +} + +func markOf(state string, rev uint64, made time.Time) Entry { + raw, _ := json.Marshal(Mark{V: Version, State: state, Revision: rev, Created: made, Fill: Fill{Keys: 2, Bytes: 100, MaxBytes: 1000}}) + return Entry{Value: raw, Revision: 1} +} + +// The owners' and the readers' side of a first view (ADR 0615 §4-§6, ADR 0621 §2-§3, ADR 0622): one table of cases, +// since mesh-delivery is over its test budget. +func TestViews(t *testing.T) { + made := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + now := func() time.Time { return made.Add(time.Hour) } + values := mapSource{"a": {Value: json.RawMessage(`{"n":1}`), Revision: 7}, "b": {Value: json.RawMessage(`{"n":2}`), Revision: 9}} + marks := mapSource{"modules": markOf("modules", 9, made)} + + t.Run("a first view answers every value at the mark's revision, the mark read first", func(t *testing.T) { + a, err := FirstView("o", "modules", values, marks, "", now()) + if err != nil || a.Revision != 9 || !a.Created.Equal(made) || len(a.Values) != 2 || a.Next != "" { + t.Fatalf("answer = %+v, %v", a, err) + } + if a, _ := FirstView("o", "modules", values, mapSource{}, "", now()); a.Revision != 0 { + t.Errorf("a state not marked answers revision %d, want 0", a.Revision) + } + }) + t.Run("a first view larger than a page is paged by key", func(t *testing.T) { + big := mapSource{} + for i := range 4 { + big[fmt.Sprintf("k%d", i)] = Entry{Value: json.RawMessage(`"` + strings.Repeat("x", PageBytes/3) + `"`), Revision: uint64(i + 1)} + } + got := map[string]bool{} + after := "" + for pages := 0; ; pages++ { + a, err := FirstView("o", "s", big, nil, after, now()) + if err != nil || pages > 4 { + t.Fatalf("page %d: %+v %v", pages, a.Next, err) + } + for k := range a.Values { + got[k] = true + } + if a.Next == "" { + break + } + after = a.Next + } + if len(got) != 4 { + t.Errorf("pages hold %v, want the four keys", got) + } + }) + t.Run("each first-view tool answers its callers alone, never the owner, by the proof and not an argument", func(t *testing.T) { + callers := ParseCallers("mesh-grants, node-tools,mesh-delivery-assigner") + for caller, allowed := range map[string]bool{"novox.mesh-grants": true, "ace.node-tools": true, "g14.mesh-delivery-assigner": true, + "novox.mesh-delivery-indexer": false, "novox.mesh-config": false, "person.jochen": false, stdio.Unproven: false} { + if err := Refusal("manifests", callers, caller); (err == nil) != allowed { + t.Errorf("%q: err = %v, allowed %v", caller, err, allowed) + } + } + // A caller argument names nobody: a call with no proof is refused. + tool := FirstViewTool("mesh-delivery-indexer", "manifests", "modules", "w.", callers, values, marks, now) + if _, err := tool.Run(map[string]any{"caller": "novox.mesh-grants"}); err == nil { + t.Errorf("an unproven call naming a caller is answered") + } + }) + t.Run("a read tool says the keys and the fill, one key, or a page of values", func(t *testing.T) { + r, err := Read("o", "modules", values, marks, "", false, "", now()) + ra, ok := r.(ReadAnswer) + if err != nil || !ok || len(ra.Keys) != 2 || ra.Fill == nil || ra.Fill.MaxBytes != 1000 || ra.Revision != 9 { + t.Fatalf("read = %+v, %v", r, err) + } + r, _ = Read("o", "modules", values, marks, "b", false, "", now()) + if k := r.(KeyAnswer); !k.Found || k.Revision != 9 { + t.Errorf("one key = %+v", k) + } + r, _ = Read("o", "modules", values, nil, "", false, "", now()) + if ra := r.(ReadAnswer); ra.Fill != nil || ra.Words == "" { + t.Errorf("an unmarked state says its fill as %+v", ra) + } + }) + + // A follower whose owner answers from values and marks. + follower := func(ask func(context.Context, string) (Answer, error)) (*Follower, *[]string) { + var said []string + f := &Follower{Owner: "o", Tool: "o.modules", State: "modules", Ask: ask, Bound: time.Nanosecond, Wait: time.Millisecond, + Raise: func(s Status) { said = append(said, "raised "+s.Tool) }, Clear: func(s Status) { said = append(said, "cleared") }} + return f, &said + } + answer := func(context.Context, string) (Answer, error) { + return FirstView("o", "modules", values, marks, "", now()) + } + ev := func(rev uint64, key, value string) Event { + e := Event{V: Version, Owner: "o", State: "modules", Key: key, Revision: rev, Created: made} + if value == "" { + e.Deleted = true + } else { + e.Value = json.RawMessage(value) + } + return e + } + t.Run("a reader applies a put and a delete from the events alone, and passes over what it holds", func(t *testing.T) { + asks := 0 + f, _ := follower(func(ctx context.Context, after string) (Answer, error) { asks++; return answer(ctx, after) }) + if err := f.Start(context.Background()); err != nil { + t.Fatal(err) + } + for _, e := range []Event{ev(8, "a", `{"n":0}`), ev(10, "c", `{"n":3}`), ev(11, "a", "")} { + if gap, why := f.Apply(e); gap { + t.Fatalf("revision %d is a gap: %s", e.Revision, why) + } + } + if _, ok := f.Get("a"); ok || f.Status().Revision != 11 || asks != 1 { + t.Errorf("picture = %v at %d after %d asks", f.Values(), f.Status().Revision, asks) + } + if v, _ := f.Get("c"); string(v) != `{"n":3}` { + t.Errorf("c = %s", v) + } + }) + t.Run("a gap, a state made again and a value too large each ask once more", func(t *testing.T) { + for name, e := range map[string]Event{ + "a revision missed": ev(12, "a", `{}`), + "made again": {V: Version, Owner: "o", State: "modules", Key: "a", Revision: 1, Created: made.Add(time.Minute), Value: json.RawMessage(`{}`)}, + "too large": {V: Version, Owner: "o", State: "modules", Key: "a", Revision: 10, Created: made, Gap: true}, + } { + f, _ := follower(answer) + _ = f.Start(context.Background()) + if gap, _ := f.Apply(e); !gap { + t.Errorf("%s: not taken as a gap", name) + } + } + }) + t.Run("a reader whose owner is down keeps asking, raises past its bound, and the answer clears it", func(t *testing.T) { + down := 3 + f, said := follower(func(ctx context.Context, after string) (Answer, error) { + if down > 0 { + down-- + return Answer{}, errors.New("no responders") + } + return answer(ctx, after) + }) + if err := f.Start(context.Background()); err != nil { + t.Fatal(err) + } + if strings.Join(*said, ",") != "raised o.modules,cleared" || f.Status().Asks != 4 || f.Status().Raised { + t.Errorf("said %v after %d asks", *said, f.Status().Asks) + } + }) + t.Run("an event heard while an ask runs is taken again after its answer", func(t *testing.T) { + var f *Follower + f, _ = follower(func(ctx context.Context, after string) (Answer, error) { + a, err := answer(ctx, after) // the picture read at 9, before revision 10 was written + if f.Status().Asks == 2 { + f.Apply(ev(10, "d", `{"n":4}`)) + } + return a, err + }) + _ = f.Start(context.Background()) + _ = f.Again(context.Background(), "test") + if v, ok := f.Get("d"); !ok || string(v) != `{"n":4}` || f.Status().Revision != 10 || f.Status().ByHand != 1 { + t.Errorf("d = %s, %v at %d", v, ok, f.Status().Revision) + } + }) + t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) { + if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { + t.Errorf("state of subject = %q", s) + } + raw, err := ev(3, "k", `{"x":1}`).Encode() + got, err2 := DecodeEvent(raw) + if err != nil || err2 != nil || got.Revision != 3 || string(got.Value) != `{"x":1}` { + t.Errorf("round trip = %+v %v %v", got, err, err2) + } + if _, err := (Event{Owner: "o", State: "s"}).Encode(); err == nil { + t.Errorf("an event with no key or revision is encoded") + } + }) +} -- 2.54.0 From 2f732b22d0f358c008299c007069cea58f7c957e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:13:32 +0100 Subject: [PATCH 012/144] SDK-11: the names kept until their users were delivered go, with the amqp test's exemption (lead-47's hand-in) No repository's main uses them since the final L3 release. Condition before the push, the lead's: that release's node-engine runs on every node (the delivery view's haves). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/amqp_is_not_a_provision_test.go | 4 +- go/catalogue/kept_until_delivered.go | 189 ------------------- 2 files changed, 1 insertion(+), 192 deletions(-) delete mode 100644 go/catalogue/kept_until_delivered.go diff --git a/go/catalogue/amqp_is_not_a_provision_test.go b/go/catalogue/amqp_is_not_a_provision_test.go index ed82b61..0b0191c 100644 --- a/go/catalogue/amqp_is_not_a_provision_test.go +++ b/go/catalogue/amqp_is_not_a_provision_test.go @@ -50,9 +50,7 @@ func TestNoCatalogueManifestNamesAmqp(t *testing.T) { fset := token.NewFileSet() files, _ := filepath.Glob("*.go") for _, f := range files { - // kept_until_delivered.go keeps, deprecated, the names removed while running parts still use them; the second - // group removes it and this exemption together. - if strings.HasSuffix(f, "_test.go") || f == "kept_until_delivered.go" { + if strings.HasSuffix(f, "_test.go") { continue } parsed, err := parser.ParseFile(fset, f, nil, 0) diff --git a/go/catalogue/kept_until_delivered.go b/go/catalogue/kept_until_delivered.go deleted file mode 100644 index 0f0e0fa..0000000 --- a/go/catalogue/kept_until_delivered.go +++ /dev/null @@ -1,189 +0,0 @@ -package catalogue - -// This file keeps, deprecated, the exported names feat/l3b-bus removed from this package while a running part still -// uses them: an exported name is removed only after every user of it is delivered. The checker builds the running -// node-engine's composer (mesh-host main) beside this mesh-sdk (ADR 0548 §6), and mesh-host main names them -// (RuntimeModule, CoreCredentialModules, FromBus). Nothing in the final L3 release's heads uses any of them, but that -// release still builds beside mesh-host 798d73e, so it keeps this file. The next release that changes mesh-sdk, once -// the final L3 release's node-engine runs on every node, removes this file and its exemption in -// TestNoCatalogueManifestNamesAmqp. - -import ( - "fmt" - "slices" - "sort" - "strings" -) - -// Deprecated: kept until its users are delivered (the head of this file). -// -// EndedControllerName is the name the ended controller had, as its module and as its seat: no manifest may name it. -const EndedControllerName = "mesh-controller" - -// The kinds of a declaration that names the controller. -// -// Deprecated: kept until their users are delivered (the head of this file). -const ( - DependsRead = "read" - DependsInvoke = "invoke" - DependsConsume = "consume" - DependsUses = "uses" - DependsWaits = "waits" - DependsClaims = "claims" - DependsRequires = "requires" - DependsIs = "is" -) - -// Deprecated: kept until its users are delivered (the head of this file). -// -// Dependency is one declaration of a manifest that names the controller. -type Dependency struct { - Module string `json:"module"` - // By is the account declaring it when it is not the module's own: `process ` or `tools `. - By string `json:"by,omitempty"` - Kind string `json:"kind"` - What string `json:"what"` -} - -// onController says whether a name `.` is the controller's. -func onController(name string) bool { return strings.HasPrefix(name, EndedControllerName+".") } - -// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none: -// it is the console's grant to every tool. -func invokesController(invoke string) bool { - if seat, _, ok := SeatInvoke(invoke); ok { - return seat == EndedControllerName - } - return onController(invoke) -} - -// Deprecated: kept until its users are delivered (the head of this file). -// -// Dependencies are every declaration naming the controller in the manifests given, by module. -func Dependencies(manifests map[string]Manifest) []Dependency { - var out []Dependency - add := func(module, by, kind, what string) { - if d := (Dependency{Module: module, By: by, Kind: kind, What: what}); !slices.Contains(out, d) { - out = append(out, d) - } - } - for name, m := range manifests { - if name == EndedControllerName || m.Module == EndedControllerName { - add(name, "", DependsIs, EndedControllerName) - } - for _, r := range m.Reads { - if onController(r) { - add(name, "", DependsRead, r) - } - } - for _, i := range m.Invokes { - if invokesController(i) { - add(name, "", DependsInvoke, i) - } - } - for _, c := range m.Consumes { - if onController(c) { - add(name, "", DependsConsume, c) - } - } - for _, w := range m.Waits { - if onController(w) { - add(name, "", DependsWaits, w) - } - } - for _, u := range m.Uses { - if u == EndedControllerName { - add(name, "", DependsUses, u) - } - } - for _, c := range m.Claims { - if c.Name == EndedControllerName { - add(name, "", DependsClaims, c.Name) - } - } - for _, r := range m.Requires { - if r == EndedControllerName || onController(r) { - add(name, "", DependsRequires, r) - } - } - for _, a := range m.BusAccounts() { - by := "process " + a.ID - if a.Tools { - by = "tools " + a.ID - } - for _, i := range a.Bus.Invokes { - if invokesController(i) { - add(name, by, DependsInvoke, i) - } - } - } - } - sort.Slice(out, func(i, j int) bool { - a, b := out[i], out[j] - if a.Module != b.Module { - return a.Module < b.Module - } - if a.Kind != b.Kind { - return a.Kind < b.Kind - } - if a.What != b.What { - return a.What < b.What - } - return a.By < b.By - }) - return out -} - -// Deprecated: kept until its users are delivered (the head of this file). -// -// ManifestRefusals is the check over a repository's manifests, by module name: one reason per declaration naming the -// controller, none when it passes. None given fails it, since nothing read can hide a declaration. -func ManifestRefusals(manifests map[string]Manifest) []string { - if len(manifests) == 0 { - return []string{"no manifest was read: with nothing read, nothing naming the controller can be judged absent"} - } - var out []string - for _, d := range Dependencies(manifests) { - who := d.Module - if d.By != "" { - who += " (" + d.By + ")" - } - out = append(out, fmt.Sprintf("%s declares %s %s: the controller ended by its unassignment and nothing may name it "+ - "(ADR 0590 clause 5)", who, d.Kind, d.What)) - } - return out -} - -// Deprecated: a manifest says it (Manifest.CoreCredentials, CarriesCoreCredentials); kept until its users are -// delivered (the head of this file). -// -// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and -// its client. -var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"} - -// Deprecated: a manifest says it (Manifest.ToolRunner, IsToolRunner); kept until its users are delivered (the head -// of this file). -// -// RuntimeModule is the module that is the node's tool runtime. -const RuntimeModule = "node-tools" - -// Deprecated: the kept streams are the indexer's judge's; kept until its users are delivered (the head of this file). -// -// StreamsKeptUntilMoved are the streams declared before the check that a module keeps its data in the bus's -// predefined forms, each allowed by name until its module moves it. -var StreamsKeptUntilMoved = map[string][]string{ - "brother": {"device-asks"}, - "mesh-build-go": {"toolchains-go"}, - "mesh-build-node": {"toolchains-node"}, - "mesh-mail": {"mail", "mail-sends"}, - "only-office": {"convert-asks"}, - "mesh-delivery-assigner": {"placement"}, - "mesh-delivery-checker": {"checks"}, - "mesh-delivery-forge-bridge": {"forge"}, -} - -// Deprecated: nothing derives it now (DerivedUpgrade reads the bus's own manifest); kept until its users are -// delivered (the head of this file). -// -// FromBus is the source an older DerivedUpgrade gave the bus's policy. -const FromBus = "the bus" -- 2.54.0 From f98e1594463ede6d28f0c67df66e2e7b9b3d7dfc Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:15:59 +0100 Subject: [PATCH 013/144] DLV-F3r sdk: a reader hears a views event by its data, and a gap is asked as a gap (A7's request) views.HearEvent routes one event given as its data, for the tool runner, which takes the owners' events by its own path; Follower.Gap asks once more without counting it by hand. Hear is HearEvent on a consumer's message. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/follow.go | 44 ++++++++++++++++++++++++++---------------- go/views/views_test.go | 24 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/go/views/follow.go b/go/views/follow.go index 2c0a470..83c7fdc 100644 --- a/go/views/follow.go +++ b/go/views/follow.go @@ -317,31 +317,41 @@ func (f *Follower) Apply(ev Event) (gap bool, why string) { // that follower's first view once more on its own goroutine, said in the log. A message it cannot read is terminated. func Hear(ctx context.Context, followers map[string]*Follower, logf func(string, ...any)) func(jetstream.Msg) { return func(m jetstream.Msg) { - ev, err := DecodeEvent(m.Data()) - if err != nil { + if err := HearEvent(ctx, followers, m.Data(), logf); err != nil { logf("views: %s is not a views event, dropped: %v", m.Subject(), err) _ = m.Term() return } - f := followers[ev.State] - if f == nil { - _ = m.Ack() // a state this reader does not follow - return - } - if gap, why := f.Apply(ev); gap { - logf("views: a gap in %s (%s): its first view %s is asked once more", ev.State, why, f.Tool) - go func() { - f.mu.Lock() - f.init() - f.st.Why = "a gap: " + why - f.mu.Unlock() - _ = f.askUntil(ctx, "a gap: "+why, false) - }() - } _ = m.Ack() } } +// HearEvent is Hear for one event given as its data, for a reader that takes the owner's events by another path than +// a consumer of its own (the tool runner's): routed to the follower of its state, an event of a state it does not +// follow passed over, and a gap asked once more on its own goroutine as a gap, never as a hand ask (Gap). Data that is +// not a views event is answered as an error, and nothing is asked. +func HearEvent(ctx context.Context, followers map[string]*Follower, data []byte, logf func(string, ...any)) error { + ev, err := DecodeEvent(data) + if err != nil { + return err + } + f := followers[ev.State] + if f == nil { + return nil // a state this reader does not follow + } + if gap, why := f.Apply(ev); gap { + logf("views: a gap in %s (%s): its first view %s is asked once more", ev.State, why, f.Tool) + go func() { _ = f.Gap(ctx, why) }() + } + return nil +} + +// Gap asks the first view once more for a gap the reader saw, until answered or ctx ends: counted among its asks, +// never among its asks by hand. +func (f *Follower) Gap(ctx context.Context, why string) error { + return f.askUntil(ctx, "a gap: "+why, false) +} + // AskVia is a Follower's Ask through a process's call of the owner's tool (process.Conn.Call), decoding its answer. func AskVia(call func(key string, body any) (json.RawMessage, error), tool string) func(context.Context, string) (Answer, error) { return func(_ context.Context, after string) (Answer, error) { diff --git a/go/views/views_test.go b/go/views/views_test.go index da65aad..b8867b5 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -188,6 +188,30 @@ func TestViews(t *testing.T) { t.Errorf("d = %s, %v at %d", v, ok, f.Status().Revision) } }) + t.Run("an event heard by its data asks a gap once more as a gap, never by hand", func(t *testing.T) { + asked := make(chan struct{}, 4) + f, _ := follower(func(ctx context.Context, after string) (Answer, error) { + asked <- struct{}{} + return answer(ctx, after) + }) + _ = f.Start(context.Background()) + <-asked + raw, _ := ev(12, "a", `{}`).Encode() + if err := HearEvent(context.Background(), map[string]*Follower{"modules": f}, raw, t.Logf); err != nil { + t.Fatal(err) + } + select { + case <-asked: + case <-time.After(3 * time.Second): + t.Fatal("a gap heard by its data asked nothing") + } + if s := f.Status(); s.Asks != 2 || s.ByHand != 0 { + t.Errorf("a gap was counted by hand: %+v", s) + } + if err := HearEvent(context.Background(), nil, []byte("not an event"), t.Logf); err == nil { + t.Error("data that is no views event was taken") + } + }) t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) { if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { t.Errorf("state of subject = %q", s) -- 2.54.0 From 640e68f86e805516563f2573e1e8c0e01f3fa2f9 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:19:22 +0100 Subject: [PATCH 014/144] SDK-10: mesh-sdk's manifest declares its Go package beside lib, each in series 0.1 (design 32, design 51) The Go build module publishes mesh-sdk's go through the Go seat; the paths word is known to every judge since R4e. The own-manifest test now holds the two packages. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/own_manifest_test.go | 19 +++++++++++++------ module.json | 3 ++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index b509c86..2096a9f 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -6,10 +6,9 @@ import ( "testing" ) -// mesh-sdk's own manifest is judged by the controller that runs when it is checked, and that controller reads it -// strictly: a manifest word ships one release ahead of its first user. R4e's group is judged by today's controller -// (novox/hq ADR 0503 §4), which does not know `paths` (ADR 0499), so mesh-sdk's lib names no paths until a release -// after R4e's delivery adds them back (ADR 0499 §6, issue 1758). +// mesh-sdk's own manifest declares its two packages (design 32, SDK-10): the TypeScript `lib` and the Go `go`, built +// from the directory go, each in series 0.1, so the Go build module publishes mesh-sdk's Go package through the Go seat +// (design 51). Its words are judged by the group's own indexer, delivered with them (ADR 0621 §4). func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { @@ -22,11 +21,19 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { if m.Build == nil || len(m.Build.Artifacts) == 0 { t.Fatal("mesh-sdk's module.json declares no artifact") } + packages := map[string]Artifact{} for _, a := range m.Build.Artifacts { - if a.Paths != nil { - t.Errorf("artifact %q names paths %v: the running controller refuses the word until R4e is delivered", a.Name, a.Paths) + packages[a.Language] = a + if a.Kind != ArtifactPackage || a.Series != DefaultSeries { + t.Errorf("artifact %q is a %s in series %q, want a package in %s", a.Name, a.Kind, a.Series, DefaultSeries) } } + if g := packages["go"]; g.Name != "go" || !slices.Equal(g.Paths, []string{"go"}) { + t.Errorf("mesh-sdk's Go package is %+v, want go built from go", g) + } + if l := packages["typescript"]; l.Name != "lib" || l.Paths != nil { + t.Errorf("mesh-sdk's TypeScript package is %+v", l) + } // What a reader reads (novox/hq ADR 0614 §4): the baseline is the contract of mesh-sdk f02f142, which every running // node-engine was built with, and this contract reads all of it; the words added since are the only ones it lacks. diff --git a/module.json b/module.json index bf74eb7..6836a21 100644 --- a/module.json +++ b/module.json @@ -4,7 +4,8 @@ "slug": "sdk", "build": { "artifacts": [ - { "name": "lib", "kind": "package", "language": "typescript" } + { "name": "lib", "kind": "package", "language": "typescript", "series": "0.1" }, + { "name": "go", "kind": "package", "language": "go", "paths": ["go"], "series": "0.1" } ] }, "resources": [] -- 2.54.0 From 5e440129dbdb0cdc9e758c03ad5adf3cc37dd9f8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:19:52 +0100 Subject: [PATCH 015/144] SDK-8: the seat set's test knows node-engine's new pull-waits Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/seats_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 15de169..c5299d2 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -56,6 +56,8 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { } whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker"} dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} + // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. + addedVerbs := map[string]bool{"node-engine.pull-waits": true} norm := func(v any) any { b, _ := json.Marshal(v) var out any @@ -81,6 +83,8 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { g.ShapeCode = append(g.ShapeCode, r.Kind) } } + g.Serves = slices.DeleteFunc(slices.Clone(g.Serves), func(v Verb) bool { return addedVerbs[want.Name+"."+v.Name] }) + g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool { return addedVerbs[want.Name+"."+n] }) if slices.Contains(whole, want.Name) { want := &want byName := map[string]Verb{} -- 2.54.0 From 231566eba80456adb60e7328883d7becf9618c63 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:21:48 +0100 Subject: [PATCH 016/144] DLV-B2 sdk: a build ask names each artifact's seat and the version the builder gave it (design 51, A3's request) AskArtifact.Seat, one of the four registry seats, and AskArtifact.Version, which names a seat; an older reader ignores both. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/builder_reference_test.go | 1 - go/delivery/stepasks.go | 24 ++++++++++++++++++++++++ go/delivery/stepasks_test.go | 16 +++++++++++++--- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/go/delivery/builder_reference_test.go b/go/delivery/builder_reference_test.go index 86d6bba..d810b16 100644 --- a/go/delivery/builder_reference_test.go +++ b/go/delivery/builder_reference_test.go @@ -148,4 +148,3 @@ func TestADoneKeyWithNoArtifactIsReferenced(t *testing.T) { t.Fatalf("Referenced = %v, %v; want true", ok, missing) } } - diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index bf8aa49..327e87d 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -7,9 +7,12 @@ import ( "fmt" "path" "regexp" + "slices" "sort" "strings" "time" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // **Asks: a build or a check step is a message a build node pulls when it has a free slot** (novox/hq ADR 0525, as @@ -340,6 +343,15 @@ type BuildJob struct { type AskArtifact struct { Artifact string `json:"artifact"` From string `json:"from,omitempty"` + // Seat is the registry seat the artifact is published through (design 51 *Publishing*): npm-package-registry or + // go-package-registry for a package, container-image-registry for an image or an upstream copy, + // oci-artifact-registry for a bundle or an archive. Absent for an ask written before the registry milestone, which + // its build module pushes as before. + Seat string `json:"seat,omitempty"` + // Version is the version the builder gave the artifact at the trunk's claim (design 51 *The versions*), which the + // build module names in the seat's publish; absent for a candidate's build and for a bundle or an archive published + // by its digest alone (ADR 0617 §1). + Version string `json:"version,omitempty"` } // CheckJob is a check ask's job, the levels of one check (ADR 0525 §3): fetch the head merged onto its trunk once, then @@ -453,6 +465,10 @@ func (c ComposeJob) check(id string) error { return nil } +// registrySeats are the seats an artifact is published through (ADR 0605 §1). +var registrySeats = []string{contractseats.NpmPackageRegistry, contractseats.GoPackageRegistry, + contractseats.ContainerImageRegistry, contractseats.OciArtifactRegistry} + func (b BuildJob) check(id string) error { if err := checkName("module", b.Module); err != nil { return err @@ -465,6 +481,14 @@ func (b BuildJob) check(id string) error { if err := checkName("artifact", a.Artifact); err != nil { return err } + if a.Seat != "" && !slices.Contains(registrySeats, a.Seat) { + return fmt.Errorf("delivery: the build ask %s publishes %s through %q, which is no registry seat (%s)", id, + a.Artifact, a.Seat, strings.Join(registrySeats, ", ")) + } + if a.Version != "" && a.Seat == "" { + return fmt.Errorf("delivery: the build ask %s gives %s the version %s and names no seat to publish it through", + id, a.Artifact, a.Version) + } fetch = fetch || a.From == "" } if fetch { diff --git a/go/delivery/stepasks_test.go b/go/delivery/stepasks_test.go index 6f1acd1..0dcc50f 100644 --- a/go/delivery/stepasks_test.go +++ b/go/delivery/stepasks_test.go @@ -15,9 +15,10 @@ func buildAsk() StepAsk { return StepAsk{ID: "mh3k2x9qa1b2c3", Kind: AskDelivery, Language: AskGo, Asker: "mesh-delivery-builder", At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC), Build: &BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", FP: "ab12", - Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit, "dir": "modules/mesh-delivery-view", "paths": []any{"cmd"}}, - Artifacts: []AskArtifact{{Artifact: "bundle"}, {Artifact: "base", From: "registry/x@sha256:00"}}, - Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}}, + Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit, "dir": "modules/mesh-delivery-view", "paths": []any{"cmd"}}, + Artifacts: []AskArtifact{{Artifact: "bundle", Seat: "oci-artifact-registry", Version: "0.1.4"}, + {Artifact: "base", From: "registry/x@sha256:00"}}, + Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}}, Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit}} } @@ -83,6 +84,15 @@ func TestAskRefusesUnknownKindAndLanguage(t *testing.T) { t.Errorf("the node names %q: encoded", names) } } + // An artifact is published through a registry seat, and a version names the seat it is published through + // (design 51). + for _, art := range []AskArtifact{{Artifact: "bundle", Seat: "mesh-artifact-store"}, {Artifact: "bundle", Version: "0.1.4"}} { + a := buildAsk() + a.Build.Artifacts = []AskArtifact{art} + if _, err := EncodeStepAsk(a); err == nil { + t.Errorf("the artifact %+v: encoded", art) + } + } // A copy alone fetches nothing and needs no path. a := buildAsk() a.Build.Fetch, a.Build.Path, a.Build.Artifacts = nil, "", []AskArtifact{{Artifact: "base", From: "registry/x@sha256:00"}} -- 2.54.0 From 6bfd14b14bb3b47a9615a2dd32f4981b8fd1d5a3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:23:21 +0100 Subject: [PATCH 017/144] CAT-RD1..RD10 sdk: views.Reader, a module's pictures of the owners' states (A8's patch 08da702) Keys, Get, Values and Watch over the views followers, the first-view tools, the finding past the bound, and the re-read recorded through mesh-hand-acts before it is asked; its cases are subtests of the views table. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/reader.go | 341 +++++++++++++++++++++++++++++++++++++++++ go/views/views_test.go | 65 ++++++++ 2 files changed, 406 insertions(+) create mode 100644 go/views/reader.go diff --git a/go/views/reader.go b/go/views/reader.go new file mode 100644 index 0000000..5e5d30b --- /dev/null +++ b/go/views/reader.go @@ -0,0 +1,341 @@ +package views + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" +) + +// Reader is a module's pictures of the states of the indexer and the assigner it used to read (novox/hq ADR 0615 §1): it +// asks each owner's read tool for the first view, subscribes the owner's views family, and answers the module's own +// `Keys` and `Get` of a state from the picture, by the name the module's `reads` used to carry (`.`). +// A module in a stdio tools bundle declares `invokes` for the tools and `consumes` for the events, and names no `reads` +// of these owners any more. Pictures are kept in memory only: the owner's state is the truth, and a restart asks again. +type Reader struct { + // Module is the reading module, named in the findings it writes. + Module string + // Findings writes and clears this module's finding of an unanswered first view (its own state `findings`); nil + // says it in the log only. + Findings interface { + Put(key string, value []byte) error + Delete(key string) error + } + Logf func(string, ...any) + // Changed is told of each key a followed state changes, after the picture holds it (state is `.`). + Changed func(state, key string, value json.RawMessage, deleted bool) + // Acts records a re-read by hand through the seat mesh-hand-acts before it is asked (the zero Recorder records nothing + // and the re-read is refused). + Acts handacts.Recorder + // Now, Bound and Wait are for tests: zero are the clock, Bound and five seconds. + Now func() time.Time + Bound time.Duration + Wait time.Duration + + mu sync.Mutex + followers map[string]*Follower // by "." + order []string + byOwner map[string]map[string]*Follower // owner -> state -> follower + watchers map[string][]func(key string, value []byte, gone bool) +} + +func (r *Reader) logf(format string, args ...any) { + if r.Logf != nil { + r.Logf(format, args...) + } +} + +// Follow adds a state of an owner to follow, asked through the owner's read tool (`.`, as the reader's +// `invokes` names it), with `values` so the answer is the first view. Call it before Start. +func (r *Reader) Follow(owner, state, tool string) *Follower { + r.mu.Lock() + defer r.mu.Unlock() + if r.followers == nil { + r.followers, r.byOwner = map[string]*Follower{}, map[string]map[string]*Follower{} + } + name := owner + "." + state + f := &Follower{Owner: owner, Tool: tool, State: state, Logf: r.Logf, Now: r.Now, Bound: r.Bound, Wait: r.Wait} + f.Ask = func(_ context.Context, after string) (Answer, error) { + body := map[string]any{"values": true} + if after != "" { + body["after"] = after + } + raw, err := stdio.Ask(tool, body) + if err != nil { + return Answer{}, err + } + return DecodeAnswer(raw) + } + f.Raise = func(s Status) { r.raise(s) } + f.Clear = func(s Status) { r.clear(s) } + f.Changed = func(key string, value json.RawMessage, deleted bool, _ time.Time) { + r.mu.Lock() + ws := append([]func(string, []byte, bool){}, r.watchers[name]...) + r.mu.Unlock() + for _, w := range ws { + w(key, value, deleted) + } + if r.Changed != nil { + r.Changed(name, key, value, deleted) + } + } + r.followers[name] = f + r.order = append(r.order, name) + if r.byOwner[owner] == nil { + r.byOwner[owner] = map[string]*Follower{} + } + r.byOwner[owner][state] = f + return f +} + +func (r *Reader) findingKey(s Status) (string, []byte, error) { + return UnansweredFinding(r.Module, s, r.nowTime()) +} + +func (r *Reader) nowTime() time.Time { + if r.Now != nil { + return r.Now().UTC() + } + return time.Now().UTC() +} + +func (r *Reader) raise(s Status) { + r.logf("the first view %s of %s's %s is unanswered past its bound: %s", s.Tool, s.Owner, s.State, s.Error) + if r.Findings == nil { + return + } + key, value, err := r.findingKey(s) + if err == nil { + err = r.Findings.Put(key, value) + } + if err != nil { + r.logf("the finding of the unanswered first view %s could not be written: %v", s.Tool, err) + } +} + +func (r *Reader) clear(s Status) { + if r.Findings == nil { + return + } + key, _, err := r.findingKey(s) + if err == nil { + err = r.Findings.Delete(key) + } + if err != nil { + r.logf("the finding of the first view %s could not be cleared: %v", s.Tool, err) + } +} + +// Start subscribes each owner's views family (the module's `consumes`: `.views.`) and asks every first +// view until it is answered, each on a goroutine of its own, so a module starts at once and a reader whose owner is down +// waits, saying so (ADR 0615 §6). It returns when the subscriptions are taken. +func (r *Reader) Start(ctx context.Context) { + r.mu.Lock() + names := append([]string(nil), r.order...) + r.mu.Unlock() + for _, name := range names { + f := r.followers[name] + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.Subscribe(f.Owner+"."+EventName(f.State), r.hear(ctx, f)) + if err == nil { + break + } + r.logf("not hearing %s's %s events yet (%v); asking again in %s", f.Owner, f.State, err, wait) + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + } + if err := f.Start(ctx); err != nil { + r.logf("the first view %s was not answered: %v", f.Tool, err) + } + }() + } +} + +// hear is the handler of one followed state's events: HearEvent routes it, and a gap asks that first view once more as a +// gap, said in the log. +func (r *Reader) hear(ctx context.Context, f *Follower) func(stdio.Envelope) error { + return func(e stdio.Envelope) error { + if err := HearEvent(ctx, map[string]*Follower{f.State: f}, e.Body, r.logf); err != nil { + r.logf("views: %s is not a views event, dropped: %v", e.Key, err) + } + return nil + } +} + +// Ready is closed once every followed state had its first view. +func (r *Reader) Ready() <-chan struct{} { + r.mu.Lock() + fs := make([]*Follower, 0, len(r.followers)) + for _, name := range r.order { + fs = append(fs, r.followers[name]) + } + r.mu.Unlock() + done := make(chan struct{}) + go func() { + for _, f := range fs { + <-f.Ready() + } + close(done) + }() + return done +} + +// Keys is the keys of a followed state (`.`); a state not followed is an error, never a read of it. +func (r *Reader) Keys(state string) ([]string, error) { + f, err := r.picture(state) + if err != nil { + return nil, err + } + vals := f.Values() + keys := make([]string, 0, len(vals)) + for k := range vals { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, nil +} + +// Get is one key of a followed state. +func (r *Reader) Get(state, key string) ([]byte, bool, error) { + f, err := r.picture(state) + if err != nil { + return nil, false, err + } + v, ok := f.Get(key) + return v, ok, nil +} + +// Values is every key of a followed state, by key. +func (r *Reader) Values(state string) (map[string][]byte, error) { + f, err := r.picture(state) + if err != nil { + return nil, err + } + out := map[string][]byte{} + for k, v := range f.Values() { + out[k] = v + } + return out, nil +} + +func (r *Reader) picture(state string) (*Follower, error) { + r.mu.Lock() + f := r.followers[state] + r.mu.Unlock() + if f == nil { + return nil, fmt.Errorf("%s is not a state this module follows", state) + } + select { + case <-f.Ready(): + return f, nil + default: + return nil, fmt.Errorf("no first view of %s yet: %s has not answered", state, f.Tool) + } +} + +// Watch is a followed state as a module's store watches one: it returns once the first view was answered and every +// current value was handed to handle, and then hands every change for as long as the process runs (gone is a key +// deleted). A change heard while the current values are handed over may be handed twice; a handler is idempotent. +func (r *Reader) Watch(state string, handle func(key string, value []byte, gone bool)) error { + r.mu.Lock() + f := r.followers[state] + if f == nil { + r.mu.Unlock() + return fmt.Errorf("%s is not a state this module follows", state) + } + r.mu.Unlock() + <-f.Ready() + r.mu.Lock() + if r.watchers == nil { + r.watchers = map[string][]func(string, []byte, bool){} + } + r.watchers[state] = append(r.watchers[state], handle) + r.mu.Unlock() + for k, v := range f.Values() { + handle(k, v, false) + } + return nil +} + +// Follows says whether the state is one this reader follows. +func (r *Reader) Follows(state string) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.followers[state] != nil +} + +// Statuses is each followed state's status, in the order they were added. +func (r *Reader) Statuses() []Status { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]Status, 0, len(r.order)) + for _, name := range r.order { + out = append(out, r.followers[name].Status()) + } + return out +} + +// Tools are the reader's two tools (design 25 § 12.2): `_first_views` says where each first view stands and +// `_first_view_again` asks one again by hand (a hand act, counted in the status). +func (r *Reader) Tools(prefix string) []stdio.Tool { + return []stdio.Tool{ + {Name: prefix + "_first_views", + Description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, when it was asked and answered, how many asks (by hand among them) and whether its condition is raised. (r)", + Run: func(map[string]any) (any, error) { return map[string]any{"views": r.Statuses()}, nil }}, + {Name: prefix + "_first_view_again", + Description: "Ask a first view again by hand, for the state named (.), or every one with none: a hand act, for a picture the operator doubts. Answers where it stands after. (a)", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "state": map[string]any{"type": "string", "description": ".; none asks every first view"}}}, + Run: func(args map[string]any) (any, error) { + want, _ := args["state"].(string) + want = strings.TrimSpace(want) + r.mu.Lock() + var fs []*Follower + for _, name := range r.order { + if want == "" || want == name { + fs = append(fs, r.followers[name]) + } + } + r.mu.Unlock() + if len(fs) == 0 { + return nil, fmt.Errorf("%q is not a state this module follows", want) + } + // A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded is + // said and nothing is asked. + why := "the operator asked" + if _, err := r.Acts.Record(handacts.Request{What: "asked a first view again by hand (" + r.Module + ")", Why: why, + Cause: "first-view-again", Verb: "first-view-again"}); err != nil { + return nil, fmt.Errorf("the hand act was not recorded through mesh-hand-acts, so nothing was asked again: %w", err) + } + for _, f := range fs { + if err := f.Again(context.Background(), why); err != nil { + return nil, err + } + } + return map[string]any{"views": r.Statuses()}, nil + }}, + } +} + +// OwnFindings writes a reader's findings into its own state `findings` through its runtime, where the condition keeper +// raises and clears conditions from them: the Findings of a Reader whose module keeps the state `findings`. +type OwnFindings struct{} + +// Put writes one finding. +func (OwnFindings) Put(key string, value []byte) error { + _, err := stdio.State("findings").Put(key, json.RawMessage(value)) + return err +} + +// Delete clears one finding. +func (OwnFindings) Delete(key string) error { return stdio.State("findings").Delete(key) } diff --git a/go/views/views_test.go b/go/views/views_test.go index b8867b5..ebeef12 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -10,6 +10,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" ) // mapSource is a state held in memory, as a Source. @@ -212,6 +213,53 @@ func TestViews(t *testing.T) { t.Error("data that is no views event was taken") } }) + t.Run("a module's reader answers Keys and Get from its pictures, refuses a state it does not follow or has no first view of, and writes and clears its finding", func(t *testing.T) { + acted := 0 + fs := &findingsMap{} + r := &Reader{Module: "reader", Findings: fs, Bound: time.Nanosecond, Wait: time.Millisecond, Acts: handacts.Recorder{Ask: func(string, any) (json.RawMessage, error) { + acted++ + return json.RawMessage(`{"log":"mesh-hand-acts"}`), nil + }}} + f := r.Follow("o", "modules", "o.indexer_read_modules") + down := 2 + f.Ask = func(ctx context.Context, after string) (Answer, error) { + if down > 0 { + down-- + return Answer{}, errors.New("no responders") + } + return answer(ctx, after) + } + if _, err := r.Keys("o.modules"); err == nil { + t.Errorf("a state with no first view is read") + } + if _, err := r.Keys("o.other"); err == nil { + t.Errorf("a state not followed is read") + } + if err := f.Start(context.Background()); err != nil { + t.Fatal(err) + } + keys, err := r.Keys("o.modules") + v, ok, _ := r.Get("o.modules", "b") + if err != nil || len(keys) != 2 || !ok || string(v) != `{"n":2}` || len(fs.m) != 0 || fs.puts != 1 || fs.deletes != 1 { + t.Errorf("keys %v, b %s, findings %+v", keys, v, fs) + } + handed := map[string]bool{} + if err := r.Watch("o.modules", func(k string, _ []byte, gone bool) { handed[k] = !gone }); err != nil || len(handed) != 2 || !r.Follows("o.modules") || r.Follows("o.x") { + t.Errorf("watch handed %v, %v", handed, err) + } + f.Apply(ev(10, "c", `{"n":3}`)) + f.Apply(ev(11, "a", "")) + if !handed["c"] || handed["a"] { + t.Errorf("a change was not handed to the watch: %v", handed) + } + if got := r.Tools("reader"); len(got) != 2 || got[0].Name != "reader_first_views" { + t.Errorf("tools = %v", got) + } + again, err := r.Tools("reader")[1].Run(map[string]any{"state": "o.modules"}) + if err != nil || len(again.(map[string]any)["views"].([]Status)) != 1 || f.Status().ByHand != 1 || acted != 1 { + t.Errorf("asked again by hand: %v, %v", again, err) + } + }) t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) { if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { t.Errorf("state of subject = %q", s) @@ -226,3 +274,20 @@ func TestViews(t *testing.T) { } }) } + +// findingsMap is a module's own findings state in memory. +type findingsMap struct { + m map[string][]byte + puts, deletes int +} + +func (f *findingsMap) Put(k string, v []byte) error { + if f.m == nil { + f.m = map[string][]byte{} + } + f.m[k] = v + f.puts++ + return nil +} + +func (f *findingsMap) Delete(k string) error { delete(f.m, k); f.deletes++; return nil } -- 2.54.0 From 253bd0697055a9f6f4627b3ef1cfe8e2fd0c7ec4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:25:21 +0100 Subject: [PATCH 018/144] =?UTF-8?q?SDK-9:=20mesh-sdk=20takes=20no=20new=20?= =?UTF-8?q?seat-specific=20name=20(ADR=200613=20=C2=A75,=20design=2026=20*?= =?UTF-8?q?Contracts=20modules*)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every seat constant of mesh-sdk is mesh-contracts' generated name; the four whole seats' (BrokerSeat, CheckerSeat, asks.Seat, buscompose.VaultSeat) are marked deprecated for their importers. The package walk of mesh-sdk's own check refuses a string literal naming a defined seat or its subjects, and an exported name given to one, beyond testdata/seat-names.txt, which lists what stands before the move (module names that equal a seat's among them) and only shrinks. No new test function. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/asks/asks.go | 7 ++- go/buscompose/users.go | 6 ++- go/catalogue/artifact_sweep_seat.go | 4 +- go/catalogue/catalog_seat.go | 4 +- go/catalogue/checker_seat.go | 7 ++- go/catalogue/control_records_seats.go | 6 ++- go/catalogue/delivery_seat.go | 4 +- go/catalogue/enrolment_seat.go | 4 +- go/catalogue/environment_into.go | 8 ++-- go/catalogue/files.go | 4 +- go/catalogue/graphical_session.go | 24 ++++++----- go/catalogue/health_seats.go | 10 +++-- go/catalogue/node_resolver.go | 4 +- go/catalogue/placement_seat.go | 4 +- go/catalogue/reads.go | 4 +- go/catalogue/seat_contributions.go | 8 ++-- go/catalogue/seat_dependencies.go | 8 ++-- go/catalogue/seats.go | 3 ++ go/catalogue/shares.go | 6 ++- go/handacts/handacts.go | 4 +- go/health/findings.go | 6 ++- go/packages_list_test.go | 51 ++++++++++++++++++++++ go/provisions/provisions.go | 3 +- go/testdata/seat-names.txt | 61 +++++++++++++++++++++++++++ 24 files changed, 207 insertions(+), 43 deletions(-) create mode 100644 go/testdata/seat-names.txt diff --git a/go/asks/asks.go b/go/asks/asks.go index 480f212..428e441 100644 --- a/go/asks/asks.go +++ b/go/asks/asks.go @@ -15,13 +15,18 @@ import ( "strconv" "strings" "time" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // The seats (novox/hq ADR 0259 §3). const ( // Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by // the asker. - Seat = "operator-channel" + // + // Deprecated: the seat is defined whole in mesh-contracts (operatorchannel.Seat, its subjects and message + // types); kept for its importers until their next change takes it from there (testdata/seat-names.txt). + Seat = contractseats.OperatorChannel // ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind. ChannelSeat = "channel" // IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 0eba1c7..990da3f 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -1,5 +1,7 @@ package buscompose +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // Declared is one module on one node, as composing its authority needs it. type Declared struct { Module string @@ -102,4 +104,6 @@ type ProcessPrincipal struct { } // VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. -const VaultSeat = "mesh-vault" +// +// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshVault); kept until its importers take it from there. +const VaultSeat = contractseats.MeshVault diff --git a/go/catalogue/artifact_sweep_seat.go b/go/catalogue/artifact_sweep_seat.go index 752852c..3412efa 100644 --- a/go/catalogue/artifact_sweep_seat.go +++ b/go/catalogue/artifact_sweep_seat.go @@ -1,5 +1,7 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by // distribution, and not the provision artifact-store it delivers. -const ArtifactSweepSeat = "mesh-artifact-sweep" +const ArtifactSweepSeat = contractseats.MeshArtifactSweep diff --git a/go/catalogue/catalog_seat.go b/go/catalogue/catalog_seat.go index c75243c..2c711ee 100644 --- a/go/catalogue/catalog_seat.go +++ b/go/catalogue/catalog_seat.go @@ -1,5 +1,7 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + import "encoding/json" // CatalogSeat is the seat of the catalog: what the mesh holds of every module (novox/hq ADR 0604 §1). Its states are @@ -8,7 +10,7 @@ import "encoding/json" // no verb. It names no event. What must be built again is no verb: a module whose fingerprint differs from its newest // build, which the delivery view derives. The verbs' answers are CatalogModules, CatalogOne, CatalogProvides and // CatalogDependents, whose field names are those of the catalog module's tools they replace. -const CatalogSeat = "mesh-catalog" +const CatalogSeat = contractseats.MeshCatalog // CatalogEntry is one module of the catalog: its repository, its directory there ("." for the root), the merge commit it // was last changed at, and its new-form fingerprint or why it has none. diff --git a/go/catalogue/checker_seat.go b/go/catalogue/checker_seat.go index 2b785cf..8de1988 100644 --- a/go/catalogue/checker_seat.go +++ b/go/catalogue/checker_seat.go @@ -1,4 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // CheckerSeat is the seat mesh-delivery-checker holds. -const CheckerSeat = "mesh-delivery-checker" +// +// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshDeliveryChecker); kept for its importers until their +// next change takes it from there (testdata/seat-names.txt). +const CheckerSeat = contractseats.MeshDeliveryChecker diff --git a/go/catalogue/control_records_seats.go b/go/catalogue/control_records_seats.go index 7850660..a61fbc8 100644 --- a/go/catalogue/control_records_seats.go +++ b/go/catalogue/control_records_seats.go @@ -1,9 +1,11 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // The two seats. const ( - WantsSeat = "mesh-wants" - HandActsSeat = "mesh-hand-acts" + WantsSeat = contractseats.MeshWants + HandActsSeat = contractseats.MeshHandActs ) // controlRecordsDecision is the record that made the two seats. diff --git a/go/catalogue/delivery_seat.go b/go/catalogue/delivery_seat.go index 3d3eed6..1e99f8d 100644 --- a/go/catalogue/delivery_seat.go +++ b/go/catalogue/delivery_seat.go @@ -1,7 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // DeliverySeat is the seat mesh-delivery holds. -const DeliverySeat = "mesh-delivery" +const DeliverySeat = contractseats.MeshDelivery // RemovedDeliveryVerbs are the delivery seat's verbs release 6 removed (novox/hq ADR 0539 clause 17): with no plan the // gate keeps the merge's states and derives nothing after the merge. Each stays in deliveryVerbs as an optional row diff --git a/go/catalogue/enrolment_seat.go b/go/catalogue/enrolment_seat.go index f5489fa..e7381b8 100644 --- a/go/catalogue/enrolment_seat.go +++ b/go/catalogue/enrolment_seat.go @@ -1,7 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // EnrolmentSeat is the seat mesh-enrolment. -const EnrolmentSeat = "mesh-enrolment" +const EnrolmentSeat = contractseats.MeshEnrolment // The seat's six verbs (ADR 0587 §1, §4-§8). const ( diff --git a/go/catalogue/environment_into.go b/go/catalogue/environment_into.go index 6df4fe2..a0c54e6 100644 --- a/go/catalogue/environment_into.go +++ b/go/catalogue/environment_into.go @@ -1,12 +1,14 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules // contributed: the account's environment, and the login shell's code. const ( - EnvironmentSeat = "node-environment" - LoginShellSeat = "node-login-shell" + EnvironmentSeat = contractseats.NodeEnvironment + LoginShellSeat = contractseats.NodeLoginShell // PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211). - PowerSeat = "node-power" + PowerSeat = contractseats.NodePower ) // Where an environment entry on PATH goes: before the account's existing PATH, or after it. diff --git a/go/catalogue/files.go b/go/catalogue/files.go index 7590483..685c555 100644 --- a/go/catalogue/files.go +++ b/go/catalogue/files.go @@ -1,7 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394). -const FilesSeat = "node-files" +const FilesSeat = contractseats.NodeFiles // FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from // each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5). diff --git a/go/catalogue/graphical_session.go b/go/catalogue/graphical_session.go index 446d1f8..75f05bf 100644 --- a/go/catalogue/graphical_session.go +++ b/go/catalogue/graphical_session.go @@ -3,6 +3,8 @@ package catalogue import ( "fmt" "strings" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's @@ -10,17 +12,17 @@ import ( // a role rather than each inventing one — and a machine running two of one role is refused at // assignment instead of found by two bars on one screen. const ( - LoginManagerSeat = "node-login-manager" - DisplayServerSeat = "node-display-server" - DisplaySessionSeat = "node-display-session" - TerminalEmulatorSeat = "node-terminal-emulator" - LauncherSeat = "node-launcher" - NotifierSeat = "node-notifier" - LockScreenSeat = "node-lock-screen" - ClipboardSeat = "node-clipboard" - BarSeat = "node-bar" - CompositorSeat = "node-compositor" - SecretServiceSeat = "node-secret-service" + LoginManagerSeat = contractseats.NodeLoginManager + DisplayServerSeat = contractseats.NodeDisplayServer + DisplaySessionSeat = contractseats.NodeDisplaySession + TerminalEmulatorSeat = contractseats.NodeTerminalEmulator + LauncherSeat = contractseats.NodeLauncher + NotifierSeat = contractseats.NodeNotifier + LockScreenSeat = contractseats.NodeLockScreen + ClipboardSeat = contractseats.NodeClipboard + BarSeat = contractseats.NodeBar + CompositorSeat = contractseats.NodeCompositor + SecretServiceSeat = contractseats.NodeSecretService ) // BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255). diff --git a/go/catalogue/health_seats.go b/go/catalogue/health_seats.go index c776978..b8d0b7e 100644 --- a/go/catalogue/health_seats.go +++ b/go/catalogue/health_seats.go @@ -1,11 +1,13 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // The four health seats. const ( - ConditionsSeat = "mesh-conditions" - WatchdogsSeat = "mesh-watchdogs" - SelfCheckSeat = "mesh-self-check" - HealersSeat = "mesh-healers" + ConditionsSeat = contractseats.MeshConditions + WatchdogsSeat = contractseats.MeshWatchdogs + SelfCheckSeat = contractseats.MeshSelfCheck + HealersSeat = contractseats.MeshHealers ) // healthDecision is the record that made the four seats. diff --git a/go/catalogue/node_resolver.go b/go/catalogue/node_resolver.go index 3c7c325..313f478 100644 --- a/go/catalogue/node_resolver.go +++ b/go/catalogue/node_resolver.go @@ -1,7 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // ResolverSeat is the machine's own resolver (novox/hq ADR 0247). -const ResolverSeat = "node-resolver" +const ResolverSeat = contractseats.NodeResolver // ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where // one is held. diff --git a/go/catalogue/placement_seat.go b/go/catalogue/placement_seat.go index ed15f97..5b3e672 100644 --- a/go/catalogue/placement_seat.go +++ b/go/catalogue/placement_seat.go @@ -1,7 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // PlacementSeat is the seat the assigner holds for placement. -const PlacementSeat = "mesh-placement" +const PlacementSeat = contractseats.MeshPlacement // The seat's five verbs (ADR 0567 §10). const ( diff --git a/go/catalogue/reads.go b/go/catalogue/reads.go index 5b8b515..f9fdfc7 100644 --- a/go/catalogue/reads.go +++ b/go/catalogue/reads.go @@ -1,7 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310). -const ReadsSeat = "node-reads" +const ReadsSeat = contractseats.NodeReads // ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is // kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5). diff --git a/go/catalogue/seat_contributions.go b/go/catalogue/seat_contributions.go index 0df1e9a..a6b02fa 100644 --- a/go/catalogue/seat_contributions.go +++ b/go/catalogue/seat_contributions.go @@ -1,13 +1,15 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5). -const HotkeysSeat = "node-hotkeys" +const HotkeysSeat = contractseats.NodeHotkeys // MessageBusSeat is the machine's D-Bus (novox/hq ADR 0215). -const MessageBusSeat = "node-message-bus" +const MessageBusSeat = contractseats.NodeMessageBus // BackupSeat is the machine's backups (novox/hq ADR 0214, to-be 43). -const BackupSeat = "node-backup" +const BackupSeat = contractseats.NodeBackup // SeatContribution is one piece of configuration a module gives a seat's holder to place. type SeatContribution struct { diff --git a/go/catalogue/seat_dependencies.go b/go/catalogue/seat_dependencies.go index 3e3d53a..756d22f 100644 --- a/go/catalogue/seat_dependencies.go +++ b/go/catalogue/seat_dependencies.go @@ -4,14 +4,16 @@ import ( "fmt" "sort" "strings" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation, // the seed and the messages all turn on these strings. const ( - ServiceManagerSeat = "node-service-manager" - PackageManagerSeat = "node-package-manager" - ContainerRuntimeSeat = "node-container-runtime" + ServiceManagerSeat = contractseats.NodeServiceManager + PackageManagerSeat = contractseats.NodePackageManager + ContainerRuntimeSeat = contractseats.NodeContainerRuntime ) // appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207 diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 60ea1a7..6bf3737 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -328,4 +328,7 @@ func SeatsWithAProtocol() []Seat { // BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may // snapshot the bus's streams and do nothing else (novox/hq ADR 0235). +// +// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshBroker, meshbroker.Seat); kept for its importers +// until their next change takes it from there (testdata/seat-names.txt). const BrokerSeat = contractseats.MeshBroker diff --git a/go/catalogue/shares.go b/go/catalogue/shares.go index 3a66edd..92f905e 100644 --- a/go/catalogue/shares.go +++ b/go/catalogue/shares.go @@ -1,7 +1,9 @@ package catalogue +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" + // NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263). -const NFSServerSeat = "node-nfs-server" +const NFSServerSeat = contractseats.NodeNfsServer // MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263). -const MountsSeat = "node-mounts" +const MountsSeat = contractseats.NodeMounts diff --git a/go/handacts/handacts.go b/go/handacts/handacts.go index 1288986..2cc4336 100644 --- a/go/handacts/handacts.go +++ b/go/handacts/handacts.go @@ -10,11 +10,13 @@ import ( "fmt" "strings" "time" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // The seat, its holder and what it keeps (ADR 0565 clause 10). const ( - Seat = "mesh-hand-acts" + Seat = contractseats.MeshHandActs Module = "mesh-hand-acts" // ActsLog is the log of every act, kept 400 days; WarrantsState one key `.` per warrant, made once. ActsLog = "acts" diff --git a/go/health/findings.go b/go/health/findings.go index 2ae46c3..e237636 100644 --- a/go/health/findings.go +++ b/go/health/findings.go @@ -10,6 +10,8 @@ import ( "fmt" "strings" "time" + + contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // The modules holding the four health seats, and the states between them, as `_` buckets. @@ -272,6 +274,6 @@ type Would struct { // KeeperSeat and the self-check's run as `checked` on SelfCheckSeat: the controller's events and the take-over key that // chose between the two sources ended with it (novox/hq ADR 0590 clause 3). const ( - KeeperSeat = "mesh-conditions" - SelfCheckSeat = "mesh-self-check" + KeeperSeat = contractseats.MeshConditions + SelfCheckSeat = contractseats.MeshSelfCheck ) diff --git a/go/packages_list_test.go b/go/packages_list_test.go index e1c6cde..cbef140 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -6,6 +6,7 @@ import ( "go/parser" "go/token" "io/fs" + "os" "path/filepath" "reflect" "slices" @@ -13,6 +14,7 @@ import ( "strings" "testing" + contractseats "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/buscalls" ) @@ -40,6 +42,20 @@ var composesNot = []string{"Resolution.Compose", "Resolution.Declaration", "Reso // computes. func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { var found []string + // **mesh-sdk takes no new seat-specific name** (novox/hq ADR 0613 §5, design 26 *Contracts modules*, SDK-9): a seat's + // name is mesh-contracts' (go/seats), so no string literal here names a defined seat or its subjects, and no exported + // name is given to one, beyond what testdata/seat-names.txt keeps from before the move (each `: `), which only shrinks as areas move. A literal naming a module of the same name as a seat counts too. + defined := contractseats.Names() + namesSeat := func(s string) bool { + for _, seat := range defined { + if s == seat || strings.HasPrefix(s, "mesh.seat."+seat+".") { + return true + } + } + return false + } + var seatNames []string err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -61,6 +77,26 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { if err != nil { return err } + where := filepath.ToSlash(path) + ": " + ast.Inspect(file, func(n ast.Node) bool { + switch n := n.(type) { + case *ast.BasicLit: + if v, err := strconv.Unquote(n.Value); err == nil && n.Kind == token.STRING && namesSeat(v) { + seatNames = append(seatNames, where+v) + } + case *ast.ValueSpec: + for i, name := range n.Names { + if i < len(n.Values) && name.IsExported() { + if sel, ok := n.Values[i].(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok && x.Name == "contractseats" { + seatNames = append(seatNames, where+name.Name) + } + } + } + } + } + return true + }) // 1966/AC5: the calls package imports nothing of mesh-sdk but the catalogue's types, so a change here rebuilds // only its importers (ADR 0595 §9). if dir == "buscalls" { @@ -94,6 +130,21 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { if err != nil { t.Fatal(err) } + kept, err := os.ReadFile("testdata/seat-names.txt") + if err != nil { + t.Fatal(err) + } + keep := strings.Split(strings.TrimSpace(string(kept)), "\n") + for _, n := range seatNames { + if !slices.Contains(keep, n) { + t.Errorf("%s names a seat the definitions define: a seat's name and its contract are mesh-contracts' (ADR 0613 §5)", n) + } + } + for _, k := range keep { + if !slices.Contains(seatNames, k) { + t.Errorf("testdata/seat-names.txt keeps %q, which is gone: take it off the list", k) + } + } slices.Sort(found) for _, dir := range found { if !slices.Contains(meshSDKPackages, dir) { diff --git a/go/provisions/provisions.go b/go/provisions/provisions.go index 3889dc3..c4b4690 100644 --- a/go/provisions/provisions.go +++ b/go/provisions/provisions.go @@ -13,12 +13,13 @@ import ( "strings" "time" + contractseats "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The seat, its holder and its two states, and the names a reader declares in its `reads`. const ( - Seat = "mesh-provisioner" + Seat = contractseats.MeshProvisioner Module = "mesh-provisioner" ProvisionsState = "provisions" PlansState = "plans" diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt new file mode 100644 index 0000000..3002238 --- /dev/null +++ b/go/testdata/seat-names.txt @@ -0,0 +1,61 @@ +asks/asks.go: Seat +buscompose/health_grants.go: mesh.seat.mesh-self-check.event. +buscompose/users.go: VaultSeat +catalogue/artifact_sweep_seat.go: ArtifactSweepSeat +catalogue/catalog_seat.go: CatalogSeat +catalogue/checker_seat.go: CheckerSeat +catalogue/control_records_seats.go: HandActsSeat +catalogue/control_records_seats.go: WantsSeat +catalogue/delivery_seat.go: DeliverySeat +catalogue/engine_seat.go: EngineSeat +catalogue/enrolment_seat.go: EnrolmentSeat +catalogue/environment_into.go: EnvironmentSeat +catalogue/environment_into.go: LoginShellSeat +catalogue/environment_into.go: PowerSeat +catalogue/files.go: FilesSeat +catalogue/graphical_session.go: BarSeat +catalogue/graphical_session.go: ClipboardSeat +catalogue/graphical_session.go: CompositorSeat +catalogue/graphical_session.go: DisplayServerSeat +catalogue/graphical_session.go: DisplaySessionSeat +catalogue/graphical_session.go: LauncherSeat +catalogue/graphical_session.go: LockScreenSeat +catalogue/graphical_session.go: LoginManagerSeat +catalogue/graphical_session.go: NotifierSeat +catalogue/graphical_session.go: SecretServiceSeat +catalogue/graphical_session.go: TerminalEmulatorSeat +catalogue/health_seats.go: ConditionsSeat +catalogue/health_seats.go: HealersSeat +catalogue/health_seats.go: SelfCheckSeat +catalogue/health_seats.go: WatchdogsSeat +catalogue/node_resolver.go: ResolverSeat +catalogue/placement_seat.go: PlacementSeat +catalogue/reads.go: ReadsSeat +catalogue/seat_contributions.go: BackupSeat +catalogue/seat_contributions.go: HotkeysSeat +catalogue/seat_contributions.go: MessageBusSeat +catalogue/seat_dependencies.go: ContainerRuntimeSeat +catalogue/seat_dependencies.go: PackageManagerSeat +catalogue/seat_dependencies.go: ServiceManagerSeat +catalogue/seats.go: BrokerSeat +catalogue/shares.go: MountsSeat +catalogue/shares.go: NFSServerSeat +delivery/checker.go: mesh-delivery-checker +delivery/composed.go: mesh-provisioner +delivery/held.go: mesh.seat.mesh-artifact-sweep.event. +delivery/placement.go: mesh-self-check +delivery/provided.go: the-private-network +delivery/signers.go: mesh-vault +delivery/stepasks.go: node-build-node +handacts/handacts.go: mesh-hand-acts +handacts/handacts.go: Seat +health/findings.go: KeeperSeat +health/findings.go: mesh-conditions +health/findings.go: mesh-healers +health/findings.go: mesh-self-check +health/findings.go: SelfCheckSeat +internal/beside/beside.go: mesh-catalog +overlay/overlay.go: the-private-network +provisions/provisions.go: mesh-provisioner +provisions/provisions.go: mesh-vault +provisions/provisions.go: Seat -- 2.54.0 From 0bb3a2c3927fa259965e881311eb9cb7d2395258 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:27:15 +0100 Subject: [PATCH 019/144] SDK-1: the runtime sends a file to a registry seat in parts and goes on from what the holder holds (ADR 0611, design 51) publish.Publish opens the transfer naming every part's digest and sends only the parts the holder answers it lacks, in chunks of at most 760 KiB (a message under the bus's 1 MiB once base64), in sequence with the last marked; a part whose bytes are not its digest is refused before any of it travels. The seat's messages are mesh-contracts'; the call is the caller's own. Its test takes the place of the vault's get test, merged into the vault's table. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/vault_seat_test.go | 34 +------ go/packages_list_test.go | 2 +- go/publish/publish.go | 158 ++++++++++++++++++++++++++++++++ go/publish/publish_test.go | 122 ++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 32 deletions(-) create mode 100644 go/publish/publish.go create mode 100644 go/publish/publish_test.go diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 2d20e80..5b61d5e 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -27,37 +27,6 @@ func TestTheVaultsSeatDeliversSecret(t *testing.T) { } } -// The vault answers `get` on request and reply (novox/hq ADR 0349 §11, issue 674): the seat promises the -// verb, so a holder serving it is accepted — and, optional while the vault that runs is the TypeScript one, -// so is a holder that does not yet. -func TestTheVaultsSeatPromisesGet(t *testing.T) { - seat, _ := SeatNamed("mesh-vault") - var get *Verb - for i := range seat.Serves { - if seat.Serves[i].Name == "get" { - get = &seat.Serves[i] - } - } - if get == nil { - t.Fatal("the vault's seat does not promise get") - } - for _, arg := range []string{"name", "public_key"} { - if props, _ := get.Input["properties"].(map[string]any); props[arg] == nil { - t.Errorf("get takes no %s", arg) - } - } - serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, - Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, Serves: []string{"get"}}}} - if err := CanHold(serving, seat); err != nil { - t.Fatalf("the vault serving get was refused: %v", err) - } - notYet := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}, - Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}} - if err := CanHold(notYet, seat); err != nil { - t.Fatalf("the vault that does not serve get yet was refused: %v", err) - } -} - // The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises // secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted, and, // optional while the vault that runs does not, so is one that does not yet. And the move of the controller's secrets @@ -66,6 +35,9 @@ func TestTheVaultsSeatPromisesGet(t *testing.T) { func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) { seat, _ := SeatNamed("mesh-vault") want := map[string][]string{ + // get, on request and reply (ADR 0349 §11, issue 674); a vault not serving it yet still holds the seat + // (TestTheVaultsSeatDeliversSecret). + "get": {"name", "public_key"}, "secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"}, "issued": {"provision", "provider", "module", "call", "sealed", "holders"}, "rotate": {"node", "module", "name", "provider", "provision", "by"}, diff --git a/go/packages_list_test.go b/go/packages_list_test.go index cbef140..d0f3b04 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -25,7 +25,7 @@ import ( var meshSDKPackages = []string{ ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", - "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", + "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", "publish", "sdkversion", "seal", "seatdef", "testbus", "views", } diff --git a/go/publish/publish.go b/go/publish/publish.go new file mode 100644 index 0000000..30eead2 --- /dev/null +++ b/go/publish/publish.go @@ -0,0 +1,158 @@ +// Package publish is the runtime's helper for sending a file to a registry seat in parts (novox/hq ADR 0611, design +// 51 *Publishing*): `publish` opens a transfer naming every part's digest, the holder answers the parts it lacks, and +// only those travel, by `publish-part`, in chunks a bus message carries. A cut transfer goes on from what the holder +// answers when the same transfer is opened again. The seat's messages are mesh-contracts' (parts/registry); the call is +// the caller's own (a bundle's stdio.Ask, a process's call of a seat verb), so this package holds no transport. +package publish + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + + "git.novox.be/novox/mesh-contracts/go/parts/registry" +) + +// MaxChunk is the most bytes of a part one publish-part carries: base64 makes it about 1 000 KiB, under 1 MiB, the +// server's largest message, with the message's own fields (design 51: each part travels in chunks of at most 1 MiB). +const MaxChunk = 760 << 10 + +// Part is one part of a transfer: its digest (sha256:), its size and how to read its bytes. +type Part struct { + Digest string + Size int64 + Open func() (io.ReadCloser, error) +} + +// Call asks one verb of the seat, `seat:.`, with a body, and answers its JSON. +type Call func(key string, body any) (json.RawMessage, error) + +// Sent is what a Publish did: the parts the holder lacked and were sent, and the chunks they took. +type Sent struct { + Transfer string + Held bool + Parts []string + Chunks int +} + +// Publish opens (or opens again) the transfer in on the seat and sends the parts the holder answers it lacks, each in +// chunks of at most MaxChunk, in sequence, the last marked. A part whose bytes do not hash to its digest is refused +// before anything of it is sent; a part the holder does not name is never read. Its outcome, published or refused, is +// the holder's event, which the caller waits for. +func Publish(ctx context.Context, call Call, seat string, in registry.PublishInput, parts []Part) (Sent, error) { + in.Parts = in.Parts[:0] + byDigest := map[string]Part{} + for _, p := range parts { + in.Parts = append(in.Parts, registry.Part{Digest: p.Digest, Size: p.Size}) + byDigest[p.Digest] = p + } + raw, err := call("seat:"+seat+"."+registry.VerbPublish, in) + if err != nil { + return Sent{}, fmt.Errorf("%s's publish of %s: %w", seat, in.Name, err) + } + var opened registry.PublishAnswer + if err := json.Unmarshal(raw, &opened); err != nil { + return Sent{}, fmt.Errorf("%s's answer to publish: %w", seat, err) + } + sent := Sent{Transfer: in.Transfer, Held: opened.Held} + for _, d := range opened.Lacks { + p, ok := byDigest[d] + if !ok { + return sent, fmt.Errorf("%s lacks the part %s, which the transfer %s does not name", seat, d, in.Transfer) + } + if err := ctx.Err(); err != nil { + return sent, err + } + if err := check(p); err != nil { + return sent, err + } + n, err := sendPart(ctx, call, seat, in.Transfer, p) + sent.Chunks += n + if err != nil { + return sent, err + } + sent.Parts = append(sent.Parts, d) + } + return sent, nil +} + +// check reads the part once and refuses it when its bytes are not its digest or its size. +func check(p Part) error { + r, err := p.Open() + if err != nil { + return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + defer r.Close() + h := sha256.New() + n, err := io.Copy(h, r) + if err != nil { + return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + if got := "sha256:" + hex.EncodeToString(h.Sum(nil)); got != p.Digest || n != p.Size { + return fmt.Errorf("the part %s holds %d bytes of digest %s, not its own %d: nothing of it is sent", p.Digest, n, got, p.Size) + } + return nil +} + +func sendPart(ctx context.Context, call Call, seat, transfer string, p Part) (int, error) { + r, err := p.Open() + if err != nil { + return 0, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + defer r.Close() + buf := make([]byte, MaxChunk) + // A chunk is sent once the next read says whether it is the last, so the last is always marked: an empty part is + // one empty chunk, marked last. + var prev []byte + sent := 0 + send := func(data []byte, last bool) error { + if err := sendChunk(call, seat, transfer, p.Digest, sent, last, data); err != nil { + return err + } + sent++ + return nil + } + for { + if err := ctx.Err(); err != nil { + return sent, err + } + n, err := io.ReadFull(r, buf) + end := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) + if err != nil && !end { + return sent, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + if end && n == 0 { + if prev == nil { + prev = []byte{} + } + return sent, send(prev, true) + } + if prev != nil { + if err := send(prev, false); err != nil { + return sent, err + } + } + prev = append([]byte(nil), buf[:n]...) + if end { + return sent, send(prev, true) + } + } +} + +func sendChunk(call Call, seat, transfer, part string, sequence int, last bool, data []byte) error { + raw, err := call("seat:"+seat+"."+registry.VerbPublishPart, registry.PublishPartInput{Transfer: transfer, Part: part, + Sequence: int64(sequence), Last: last, Data: base64.StdEncoding.EncodeToString(data)}) + if err != nil { + return fmt.Errorf("%s's publish-part %d of %s: %w", seat, sequence, part, err) + } + var a registry.PublishPartAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return fmt.Errorf("%s's answer to publish-part: %w", seat, err) + } + return nil +} diff --git a/go/publish/publish_test.go b/go/publish/publish_test.go new file mode 100644 index 0000000..08bf439 --- /dev/null +++ b/go/publish/publish_test.go @@ -0,0 +1,122 @@ +package publish + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "strings" + "testing" + + "git.novox.be/novox/mesh-contracts/go/parts/registry" +) + +// A file travels to a registry seat in parts (ADR 0611, design 51 *Publishing*; SDK-1): only the parts the holder says +// it lacks are read and sent, each in chunks of at most MaxChunk in sequence with the last marked, so a cut transfer +// opened again goes on from what the holder holds; a part whose bytes are not its digest is refused before any of it +// travels. +func TestAFileTravelsInTheParts(t *testing.T) { + big := bytes.Repeat([]byte("x"), MaxChunk+10) + small, empty := []byte("config"), []byte{} + part := func(b []byte, opened *int) Part { + sum := sha256.Sum256(b) + return Part{Digest: "sha256:" + hex.EncodeToString(sum[:]), Size: int64(len(b)), + Open: func() (io.ReadCloser, error) { *opened++; return io.NopCloser(bytes.NewReader(b)), nil }} + } + type chunk struct { + part string + seq int64 + last bool + data []byte + } + holder := func(lacks []string, got *[]chunk, asked *[]string) Call { + return func(key string, body any) (json.RawMessage, error) { + *asked = append(*asked, key) + raw, _ := json.Marshal(body) + switch key { + case "seat:oci-artifact-registry.publish": + return json.Marshal(registry.PublishAnswer{Transfer: "t1", Lacks: lacks, Held: len(lacks) == 0}) + case "seat:oci-artifact-registry.publish-part": + var in registry.PublishPartInput + _ = json.Unmarshal(raw, &in) + data, _ := base64.StdEncoding.DecodeString(in.Data) + if len(raw) > 1<<20 { + t.Errorf("a chunk's message is %d bytes, over the bus's largest", len(raw)) + } + *got = append(*got, chunk{in.Part, in.Sequence, in.Last, data}) + return json.Marshal(registry.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part}) + } + t.Fatalf("asked %s", key) + return nil, nil + } + } + in := registry.PublishInput{Transfer: "t1", Name: "files", Kind: "archive", Digest: "sha256:" + strings.Repeat("a", 64)} + var opens [3]int + parts := []Part{part(big, &opens[0]), part(small, &opens[1]), part(empty, &opens[2])} + + for _, c := range []struct { + name string + lacks []int + chunks int + err string + }{ + {"a fresh transfer sends every part, the big one in two chunks and the empty one as one", []int{0, 1, 2}, 4, ""}, + {"a cut transfer opened again sends only what the holder lacks", []int{2}, 1, ""}, + {"a digest the holder holds sends nothing", nil, 0, ""}, + } { + opens = [3]int{} + var lacks []string + for _, i := range c.lacks { + lacks = append(lacks, parts[i].Digest) + } + var got []chunk + var asked []string + sent, err := Publish(context.Background(), holder(lacks, &got, &asked), "oci-artifact-registry", in, parts) + if err != nil || sent.Chunks != c.chunks || len(got) != c.chunks || len(sent.Parts) != len(c.lacks) { + t.Errorf("%s: sent %+v, %d chunks heard, %v", c.name, sent, len(got), err) + continue + } + for i := range parts { + want := 0 + for _, l := range c.lacks { + if l == i { + want = 2 // read once to check its digest, once to send it + } + } + if opens[i] != want { + t.Errorf("%s: part %d read %d times, want %d", c.name, i, opens[i], want) + } + } + whole := map[string][]byte{} + for i, ch := range got { + whole[ch.part] = append(whole[ch.part], ch.data...) + lastOfPart := i == len(got)-1 || got[i+1].part != ch.part + if ch.last != lastOfPart || (i > 0 && got[i-1].part == ch.part && got[i-1].seq+1 != ch.seq) { + t.Errorf("%s: chunk %d of %s is %d, last %v", c.name, i, ch.part[:12], ch.seq, ch.last) + } + } + for _, l := range c.lacks { + if p := parts[l]; int64(len(whole[p.Digest])) != p.Size { + t.Errorf("%s: part %d arrived as %d bytes of %d", c.name, l, len(whole[p.Digest]), p.Size) + } + } + } + + // A part whose bytes are not its digest travels not at all; a part the holder names that the transfer does not is + // refused, naming it. + liar := part(small, &opens[1]) + liar.Digest = parts[0].Digest + var got []chunk + var asked []string + if _, err := Publish(context.Background(), holder([]string{liar.Digest}, &got, &asked), "oci-artifact-registry", in, + []Part{liar}); err == nil || len(got) != 0 || !strings.Contains(err.Error(), "nothing of it is sent") { + t.Errorf("a part not its digest: %v, %d chunks sent", err, len(got)) + } + if _, err := Publish(context.Background(), holder([]string{"sha256:" + strings.Repeat("f", 64)}, &got, &asked), + "oci-artifact-registry", in, parts); err == nil || !strings.Contains(err.Error(), "does not name") { + t.Errorf("a part the transfer does not name: %v", err) + } +} -- 2.54.0 From da653ce593e28ea175bfd3c993820f371edf8a04 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:33:59 +0100 Subject: [PATCH 020/144] SDK-8: the seat set's test reads a whole seat's new optional verbs as additions Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/seats_test.go | 8 +++++++- go/go.mod | 8 ++++---- go/go.sum | 12 ++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index c5299d2..8afda6e 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -103,7 +103,13 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { kept = append(kept, byName[v.Name]) } g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts - g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool { return n == "renames" || n == "retire" }) + // A verb the whole seat added (new, optional while its holders catch up) is no verb of the compiled list. + g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool { + return !slices.ContainsFunc(want.Serves, func(v Verb) bool { return v.Name == n }) + }) + if len(g.Optional) == 0 { + g.Optional = nil + } want.Optional = slices.DeleteFunc(want.Optional, func(n string) bool { return dropped[want.Name+"."+n] }) want.Serves = slices.DeleteFunc(slices.Clone(want.Serves), func(v Verb) bool { return dropped[want.Name+"."+v.Name] }) } diff --git a/go/go.mod b/go/go.mod index 830ee76..71251ca 100644 --- a/go/go.mod +++ b/go/go.mod @@ -8,14 +8,14 @@ require ( ) require ( - github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 98dc72c..b6588f4 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,13 +1,10 @@ -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -21,5 +18,4 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= -- 2.54.0 From 3ff694e425cfe674f79db90a63da41f5a667a0ba Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:35:14 +0100 Subject: [PATCH 021/144] =?UTF-8?q?DLV-O4=20sdk:=20manifest-accepted=20and?= =?UTF-8?q?=20manifest-refused=20carry=20the=20indexer's=20revision=20(ADR?= =?UTF-8?q?=200622=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/verdicts.go | 6 ++++++ go/delivery/verdicts_test.go | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/go/delivery/verdicts.go b/go/delivery/verdicts.go index 18e9c3d..a6cede6 100644 --- a/go/delivery/verdicts.go +++ b/go/delivery/verdicts.go @@ -205,6 +205,9 @@ type ManifestRefused struct { Digest string `json:"digest"` Judge string `json:"judge"` Problems []string `json:"problems"` + // Revision is the indexer's revision of the verdict it says (novox/hq ADR 0622 §2): a reader following verdicts by + // their events asks its first view again on a gap in it. Zero from an indexer that does not say it. + Revision uint64 `json:"revision,omitempty"` } // RefusedOf is the event body saying v, the verdict on the manifest of digest. @@ -220,6 +223,9 @@ type ManifestAccepted struct { Digest string `json:"digest"` Judge string `json:"judge"` BusState []catalogue.BusStateEntry `json:"bus-state"` + // Revision is the indexer's revision of the verdict it says (novox/hq ADR 0622 §2; the bus module applies a replay + // only if newer, design 25 § 12.2). Zero from an indexer that does not say it. + Revision uint64 `json:"revision,omitempty"` } // AcceptedOf is the event body saying v, the verdict accepting the manifest m of digest. diff --git a/go/delivery/verdicts_test.go b/go/delivery/verdicts_test.go index 0e70049..90af075 100644 --- a/go/delivery/verdicts_test.go +++ b/go/delivery/verdicts_test.go @@ -103,6 +103,16 @@ func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { if id := heard.EventID(); id != "manifest-accepted-"+digest+"-src2:j" { t.Fatalf("the accepted event's id is %q", id) } + // Both verdict events carry the indexer's revision (ADR 0622 §2), and one without it reads as revision 0. + for _, ev := range []any{ManifestAccepted{Module: "m", Revision: 41}, ManifestRefused{Module: "m", Revision: 41}} { + raw, _ := json.Marshal(ev) + var back struct { + Revision uint64 `json:"revision"` + } + if json.Unmarshal(raw, &back); back.Revision != 41 { + t.Errorf("%T carries no revision: %s", ev, raw) + } + } // A manifest with a field the judge does not know waits (clause 3 of the record on a release's Approve): readers hold it // as a refused one, it round-trips through a modules value, an old value decodes unchanged, and it is not a refusal. waits := ManifestVerdict{Module: "nats", State: VerdictManifestWaits, Unknown: []string{"core-credentials", "tool-runner"}, Judge: "j"} -- 2.54.0 From 1027803e1de534d541b5f527dce17da8a682870f Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:21:31 +0100 Subject: [PATCH 022/144] =?UTF-8?q?A=20build=20names=20its=20needs=20and?= =?UTF-8?q?=20every=20form=20of=20an=20artifact=20(ADR=200618=20=C2=A71,?= =?UTF-8?q?=20ADR=200624;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuilderBuild.Needs (form:, done keys only) and BuiltArtifact.References (Reference among them, each checked as Reference is) on the delivery layouts mesh-sdk keeps until that area moves (ADR 0613 §3). The node-engine seat's verb and event of ADR 0618 go into mesh-contracts with the seat, not here (ADR 0613 §5). Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/delivery/builder.go | 25 +++++++++++++++++++++++++ go/delivery/builder_test.go | 32 ++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 42bee50..06abbb5 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "slices" "strings" contractseats "git.novox.be/novox/mesh-contracts/go/seats" @@ -89,6 +90,10 @@ type BuiltArtifact struct { Kind string `json:"kind,omitempty"` Digest string `json:"digest"` Reference string `json:"reference,omitempty"` + // References are every form this artifact is named in, Reference among them (novox/hq ADR 0618, ADR 0624): a build + // keeps each form that a counted node-engine states, away nodes included, and Reference stays the form every one + // of them reads. A reader that does not know the field reads Reference alone. + References []string `json:"references,omitempty"` } const artifactStore = "artifact-store://" @@ -211,6 +216,10 @@ type BuilderBuild struct { // Imports are, on a done key, the mesh packages the built programs import, as the build module answered them // (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports). Imports []string `json:"imports,omitempty"` + // Needs are, on a done key only, the reader capabilities (`form:`) the build's reference forms need of a + // node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field + // reads the build as needing none. + Needs []string `json:"needs,omitempty"` } func (b BuilderBuild) check() error { @@ -245,6 +254,22 @@ func (b BuilderBuild) check() error { if p := a.referenceProblem(); p != "" { return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p) } + if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) { + return fmt.Errorf("delivery: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference) + } + for _, r := range a.References { + if p := (BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}).referenceProblem(); p != "" { + return fmt.Errorf("delivery: the reference %q of the %s %q %s", r, a.Kind, a.Name, p) + } + } + } + for _, n := range b.Needs { + if !strings.HasPrefix(n, "form:") || len(n) == len("form:") { + return fmt.Errorf("delivery: the need %q of the build is not form:", n) + } + } + if len(b.Needs) > 0 && b.State != BuildDone { + return fmt.Errorf("delivery: a %s build names needs; only a done key does", b.State) } for language, id := range b.Asks { if b.State != BuildBuilding { diff --git a/go/delivery/builder_test.go b/go/delivery/builder_test.go index 8e29474..82038ca 100644 --- a/go/delivery/builder_test.go +++ b/go/delivery/builder_test.go @@ -26,11 +26,15 @@ func doneBuild() BuilderBuild { func builderSamples() map[string]BuilderBuild { failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"} + both := doneBuild() + both.Needs = []string{"form:artifact-registry"} + both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest} return map[string]BuilderBuild{ - "queued": {State: BuildQueued, Manifest: digestOf('4')}, - "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, - "done": doneBuild(), - "failed": failed, + "done with needs and both forms": both, + "queued": {State: BuildQueued, Manifest: digestOf('4')}, + "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, + "done": doneBuild(), + "failed": failed, } } @@ -90,10 +94,17 @@ func TestBuilderBuildChecked(t *testing.T) { edit func(*BuilderBuild) words []string }{ - "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, - "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, - "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, - "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, + "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, + "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, + "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, + "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, + "a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}}, + "a reference not among the references": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest} + }, []string{"server", "references"}}, + "a reference of another artifact": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest} + }, []string{"server", "other"}}, "an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}}, "a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}}, "a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}}, @@ -119,6 +130,11 @@ func TestBuilderBuildChecked(t *testing.T) { } // a queued or building value may not already carry an outcome q := builderSamples()["queued"] + q.Needs = []string{"form:x"} + if _, err := EncodeBuilderBuild(q); err == nil { + t.Error("a queued build with needs accepted") + } + q.Needs = nil q.Artifacts = doneBuild().Artifacts if _, err := EncodeBuilderBuild(q); err == nil { t.Error("a queued build with artifacts accepted") -- 2.54.0 From effd0c00a6227590a05f9cb70d0b96a9e24ef3a0 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:31:17 +0100 Subject: [PATCH 023/144] The seat set's test takes node-engine as a seat defined whole, with ADR 0618's manifest-reads among its added verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-contracts now defines the node-engine seat whole (ADR 0613 §3): its verbs carry answers and it says manifest-reads-changed, so the set compares it as it compares every whole seat. Nothing else here is redundant: the seat's hand-written list went in SDK-8, and the compose-candidate layouts keep their checks, which a generated package does not carry, until the seat's client package exists (ADR 0625 §2, proposed). Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/catalogue/seats_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 8afda6e..dd13a59 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -54,10 +54,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { if err := json.Unmarshal(raw, &compiled); err != nil { t.Fatal(err) } - whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker"} + whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"} dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} - // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. - addedVerbs := map[string]bool{"node-engine.pull-waits": true} + // Verbs a seat gained in the milestone, each optional until its holders serve it: node-engine's manifest-reads (ADR + // 0618 §3) came with its move whole. + addedVerbs := map[string]bool{"node-engine.pull-waits": true, "node-engine.manifest-reads": true} norm := func(v any) any { b, _ := json.Marshal(v) var out any -- 2.54.0 From 42ac1a9a0aaa6410c7ca061e4201404e86b0af0c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:38:10 +0100 Subject: [PATCH 024/144] mesh-sdk's node-engine names are the seat's generated contract, kept deprecated for their importers, with their checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat is defined whole in mesh-contracts (ADR 0613 §3), so mesh-sdk takes its names from there (ADR 0613 §5): EngineSeat and EngineEvents are nodeengine's, EngineComposeCandidateVerb is nodeengine.VerbComposeCandidate, and ComposeCandidateAsk, ComposeCandidateAnswer and ModuleComposed are aliases of nodeengine's ComposeCandidateInput, ComposeCandidateAnswer and Module. The encode and decode checks stay here over those types until the seat has a client package (ADR 0625 §4). For importers: a module's fingerprint is Fp, and the answer's Input is a JSON object (map[string]any), not raw bytes. compose-candidate's input is the boolean the engine and the checker read; the seat set's test takes that correction, and the whole seat's added verbs as da653ce reads them. EngineSeat leaves testdata/seat-names.txt. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/catalogue/engine_compose_candidate_test.go | 4 +- go/catalogue/engine_seat.go | 21 +++-- go/catalogue/seats_test.go | 14 ++- go/delivery/compose_candidate.go | 86 +++++++++---------- go/delivery/compose_candidate_test.go | 7 +- go/testdata/seat-names.txt | 1 - 6 files changed, 67 insertions(+), 66 deletions(-) diff --git a/go/catalogue/engine_compose_candidate_test.go b/go/catalogue/engine_compose_candidate_test.go index fb34000..b2c3797 100644 --- a/go/catalogue/engine_compose_candidate_test.go +++ b/go/catalogue/engine_compose_candidate_test.go @@ -2,7 +2,7 @@ package catalogue import "testing" -// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a switch. +// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a boolean, as the engine and the checker read it. func TestTheEngineServesComposeCandidate(t *testing.T) { for _, v := range EngineVerbs() { if v.Name != "compose-candidate" { @@ -10,7 +10,7 @@ func TestTheEngineServesComposeCandidate(t *testing.T) { } props, _ := v.Input["properties"].(map[string]any) input, _ := props["input"].(map[string]any) - if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil { + if !v.Optional || props["candidate"] == nil || input == nil || input["type"] != "boolean" { t.Fatalf("compose-candidate reads as %+v", v) } return diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index bcbbd2f..34d39eb 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -1,23 +1,26 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" +import "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" // EngineSeat is the node seat every node's engine holds. -const EngineSeat = contractseats.NodeEngine +// +// Deprecated: the seat is defined whole in mesh-contracts (nodeengine.Seat, its verbs, events, subjects and message +// types); kept for its importers until their next change takes it from there (testdata/seat-names.txt). +const EngineSeat = nodeengine.Seat // EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the -// generated seat list (design 26). +// generated seat list (design 26), each with its description and its input's schema as discovery shows them. Their +// names are nodeengine.Verbs(). func EngineVerbs() []Verb { s, _ := seatIn(defaultSeats, EngineSeat) return append([]Verb(nil), s.Serves...) } -// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime -// proved it and the ask (ADR 0563 §1). -var EngineEvents = func() []string { - s, _ := seatIn(defaultSeats, EngineSeat) - return append([]string(nil), s.Emits...) -}() +// EngineEvents are what the seat says: of an act, each naming the act, the path or access, the caller as the runtime +// proved it and the ask (ADR 0563 §1), and that what its engine reads of a manifest changed (ADR 0618 §4). +// +// Deprecated: the seat's contract names them, nodeengine.Events(); kept for its importers. +var EngineEvents = nodeengine.Events() // seatIn is the seat of that name in a list. func seatIn(list []Seat, name string) (Seat, bool) { diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index dd13a59..4f6bdec 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -56,9 +56,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { } whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"} dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} - // Verbs a seat gained in the milestone, each optional until its holders serve it: node-engine's manifest-reads (ADR - // 0618 §3) came with its move whole. - addedVerbs := map[string]bool{"node-engine.pull-waits": true, "node-engine.manifest-reads": true} + // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. + addedVerbs := map[string]bool{"node-engine.pull-waits": true} + // A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the + // checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it). + retyped := map[string]bool{"node-engine.compose-candidate": true} norm := func(v any) any { b, _ := json.Marshal(v) var out any @@ -101,7 +103,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { } continue } - kept = append(kept, byName[v.Name]) + kv := byName[v.Name] + if retyped[want.Name+"."+v.Name] { + kv.Input = v.Input + } + kept = append(kept, kv) } g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts // A verb the whole seat added (new, optional while its holders catch up) is no verb of the compiled list. diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go index 84cdefd..26f051d 100644 --- a/go/delivery/compose_candidate.go +++ b/go/delivery/compose_candidate.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "time" + + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" ) // A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb @@ -16,7 +18,9 @@ import ( const ( // EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat). - EngineComposeCandidateVerb = "compose-candidate" + // + // Deprecated: the seat's contract names it, nodeengine.VerbComposeCandidate; kept for its importers. + EngineComposeCandidateVerb = nodeengine.VerbComposeCandidate // ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's // answer (ADR 0593 §7, §9). ComposeAnswerBound = 60 * time.Second @@ -45,53 +49,41 @@ const ( ModuleHeldState = "held" ) -// ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, -// `.`); the engine reads that candidate itself and lays its modules over its placement. -// Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the -// catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer -// runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). -type ComposeCandidateAsk struct { - Candidate string `json:"candidate"` - Input bool `json:"input,omitempty"` -} +// The verb's ask and answer are the seat's contract, generated in mesh-contracts (seats/node-engine): mesh-sdk keeps +// these names for their importers and the checks below over them, until the seat has a client package (ADR 0625 §4). +type ( + // ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, + // `.`); the engine reads that candidate itself and lays its modules over its placement. + // Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the + // catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer + // runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). + // + // Deprecated: the seat's contract is nodeengine.ComposeCandidateInput; kept for its importers. + ComposeCandidateAsk = nodeengine.ComposeCandidateInput + // ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node + // sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate + // at all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the + // engine took in milliseconds, and, when asked (ComposeCandidateAsk.Input), the composition input it composed from. + // + // Deprecated: the seat's contract is nodeengine.ComposeCandidateAnswer; kept for its importers. + ComposeCandidateAnswer = nodeengine.ComposeCandidateAnswer + // ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the + // candidate's FP2 for a module the candidate touches, else its newest done build as composed- holds it), its + // state, the composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one + // composed without it (the checker's "what the change reaches on this node"), and why, for a refused or held one. + // + // Deprecated: the seat's contract is nodeengine.Module; kept for its importers. + ModuleComposed = nodeengine.Module +) -// ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node -// sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at -// all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the engine took in milliseconds, and, when asked -// (ComposeCandidateAsk.Input), the composition input it composed from. -type ComposeCandidateAnswer struct { - V int `json:"v"` - Node string `json:"node"` - Candidate string `json:"candidate"` - Verdict string `json:"verdict"` - Modules []ModuleComposed `json:"modules"` - Own ModuleComposed `json:"own"` - Refused string `json:"refused,omitempty"` - Took int64 `json:"took"` - Input json.RawMessage `json:"input,omitempty"` -} - -// ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the candidate's -// FP2 for a module the candidate touches, else its newest done build as composed- holds it), its state, the -// composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one composed without -// it (the checker's "what the change reaches on this node"), and why, for a refused or held one. -type ModuleComposed struct { - Module string `json:"module"` - FP string `json:"fp,omitempty"` - State string `json:"state"` - Digest string `json:"digest,omitempty"` - Changed bool `json:"changed,omitempty"` - Words string `json:"words,omitempty"` -} - -func (a ComposeCandidateAsk) check() error { +func checkComposeAsk(a ComposeCandidateAsk) error { if _, _, err := ParseCompositionKey(a.Candidate); err != nil { return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err) } return nil } -func (m ModuleComposed) check(node string) error { +func checkComposedModule(m ModuleComposed, node string) error { if m.Module == "" { return fmt.Errorf("delivery: a module's composition on %s names no module", node) } @@ -111,7 +103,7 @@ func (m ModuleComposed) check(node string) error { return nil } -func (a ComposeCandidateAnswer) check() error { +func checkComposeAnswer(a ComposeCandidateAnswer) error { if err := checkName("node", a.Node); err != nil { return err } @@ -133,7 +125,7 @@ func (a ComposeCandidateAnswer) check() error { } refused := false for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) { - if err := m.check(a.Node); err != nil { + if err := checkComposedModule(m, a.Node); err != nil { return err } if i > 1 && a.Modules[i-2].Module >= m.Module { @@ -164,7 +156,7 @@ func strictDecode(data []byte, v any, what string) error { // EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key. func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) { - if err := a.check(); err != nil { + if err := checkComposeAsk(a); err != nil { return nil, err } return json.Marshal(a) @@ -176,13 +168,13 @@ func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) { if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { return ComposeCandidateAsk{}, err } - return a, a.check() + return a, checkComposeAsk(a) } // EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest, // a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give. func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) { - if err := a.check(); err != nil { + if err := checkComposeAnswer(a); err != nil { return nil, err } a.V = Version @@ -199,5 +191,5 @@ func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) { if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { return ComposeCandidateAnswer{}, err } - return a, a.check() + return a, checkComposeAnswer(a) } diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go index 63e4e1d..66afd83 100644 --- a/go/delivery/compose_candidate_test.go +++ b/go/delivery/compose_candidate_test.go @@ -2,6 +2,7 @@ package delivery import ( "encoding/json" + "reflect" "strings" "testing" @@ -32,16 +33,16 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812, Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"}, Modules: []ModuleComposed{ - {Module: "netcheck", FP: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, + {Module: "netcheck", Fp: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, {Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"}, {Module: "zz", State: ModuleHeldState, Words: "held before"}, - }, Input: json.RawMessage(`{"machines":["ace"]}`)} + }, Input: map[string]any{"machines": []any{"ace"}}} raw, err = EncodeComposeCandidateAnswer(good) if err != nil { t.Fatal(err) } back, err := DecodeComposeCandidateAnswer(raw) - if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || string(back.Input) != string(good.Input) { + if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) { t.Fatalf("answer round trip: %v, %+v", err, back) } for name, mutate := range map[string]func(a *ComposeCandidateAnswer){ diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 3002238..ce3741b 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -7,7 +7,6 @@ catalogue/checker_seat.go: CheckerSeat catalogue/control_records_seats.go: HandActsSeat catalogue/control_records_seats.go: WantsSeat catalogue/delivery_seat.go: DeliverySeat -catalogue/engine_seat.go: EngineSeat catalogue/enrolment_seat.go: EnrolmentSeat catalogue/environment_into.go: EnvironmentSeat catalogue/environment_into.go: LoginShellSeat -- 2.54.0 From e1f4ac1050f725ce2c8efaf36668b19f346e05ed Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:42:16 +0100 Subject: [PATCH 025/144] SD-M2b sdk: a seat's event family may name its own subject token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lead's ruling of 12:5x (design 25 § 12.3): mesh-broker's events ride the bus module's own stream, 30 days, on mesh.seat.mesh-broker.said., outside the shared events stream's mesh.seat.*.event.>, so the bus never refuses the two as overlapping. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/buscompose/names_test.go | 25 +++++++++++++++++++++++++ go/seatdef/seatdef.go | 36 ++++++++++++++++++++++++++++++++++-- go/seatdef/seatdef_test.go | 17 +++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/go/buscompose/names_test.go b/go/buscompose/names_test.go index 2f0b5cc..25934e2 100644 --- a/go/buscompose/names_test.go +++ b/go/buscompose/names_test.go @@ -1,8 +1,10 @@ package buscompose import ( + "strings" "testing" + "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" "git.novox.be/novox/mesh-sdk/go/membership" ) @@ -32,4 +34,27 @@ func TestTheBusNamesAgree(t *testing.T) { if err := ledger.Valid(); err != nil || ledger.OwnSecret() != "ledgers" { t.Fatalf("a wanted ledger account: %v, held in %q", err, ledger.OwnSecret()) } + // mesh-broker's events are the bus module's own stream (design 25 § 12.3, 30 days): no subject of the shared + // streams may take them, or the bus refuses the two streams as overlapping. + for _, st := range MeshStreams() { + for _, pattern := range st.Subjects { + if subjectTakes(pattern, meshbroker.SubjectCreated) { + t.Errorf("the stream %s takes mesh-broker's %s by %s", st.Name, meshbroker.SubjectCreated, pattern) + } + } + } +} + +// subjectTakes says whether a subject pattern (`*` one token, `>` the rest) takes a subject. +func subjectTakes(pattern, subject string) bool { + p, s := strings.Split(pattern, "."), strings.Split(subject, ".") + for i, tok := range p { + switch { + case tok == ">": + return len(s) > i + case i >= len(s) || (tok != "*" && tok != s[i]): + return false + } + } + return len(p) == len(s) } diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index c915c93..c0b37ae 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -67,7 +67,8 @@ type Definition struct { Verbs []Verb `json:"verbs,omitempty"` // Accepts are what may be sent to the seat's holder on `mesh.seat..accept.`. Accepts []Message `json:"accepts,omitempty"` - // Events are what is published on the seat's own subject, `mesh.seat..event.`. + // Events are what is published on the seat's own subject, `mesh.seat...` (EventSubject): the + // token is `event`, unless the family names its own. Events []Message `json:"events,omitempty"` // Family is where the seat's events are kept, when the definition fixes it. Family *Family `json:"family,omitempty"` @@ -122,6 +123,18 @@ type Family struct { Name string `json:"name"` MaxAgeDays int `json:"max-age-days"` MaxMiB int `json:"max-mib,omitempty"` + // Token is the subject's word after the seat's name, `event` when absent. A family kept in a stream of its own + // names another, so its subjects stay outside the shared events stream's `mesh.seat.*.event.>`: the bus refuses + // two streams whose subjects overlap (mesh-broker's `said`, design 25 § 12.3). + Token string `json:"token,omitempty"` +} + +// EventToken is the token after the seat's name on the subjects of its events. +func (f *Family) EventToken() string { + if f == nil || f.Token == "" { + return "event" + } + return f.Token } // Record is one state the holder keeps for the seat: its key's form and its value's schema. @@ -377,6 +390,14 @@ func (d Definition) check() Problems { if d.Family.MaxAgeDays < 1 { ps = append(ps, Problem{"family.max-age-days", "missing, or under one day"}) } + switch t := d.Family.Token; { + case t == "": + case !wordName.MatchString(t): + ps = append(ps, Problem{"family.token", fmt.Sprintf("%q is not a lower-case word", t)}) + case sharedTokens[t]: + ps = append(ps, Problem{"family.token", fmt.Sprintf("%q is a token every seat shares; "+ + "a family of its own names another, or none", t)}) + } } for i, e := range d.HolderEvents { where := fmt.Sprintf("holder-events[%d]", i) @@ -496,9 +517,20 @@ func Resolve(d Definition, parts map[string]Part) (Definition, error) { return out, nil } -// EventSubject is where the seat's event is published; by a caller, the caller's name follows. +// EventSubject is where the seat's event is published on the shared events stream's token; by a caller, the +// caller's name follows. A definition whose family names a token of its own is asked with Definition.EventSubject. func EventSubject(seat, event string) string { return "mesh.seat." + seat + ".event." + event } +// EventSubject is where this seat's event is published, under its family's token; by a caller, the caller's name +// follows. +func (d Definition) EventSubject(event string) string { + return "mesh.seat." + d.Seat + "." + d.Family.EventToken() + "." + event +} + +// sharedTokens are the words after a seat's name that every seat's subjects share (its verbs, its work queue, the +// shared events stream's events): a family's own token is none of them. +var sharedTokens = map[string]bool{"event": true, "accept": true, "tool": true, "call": true, "proof": true} + // AcceptSubject is where a message the seat accepts is sent. func AcceptSubject(seat, accept string) string { return "mesh.seat." + seat + ".accept." + accept } diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index e1ca81e..9c9a52f 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -37,6 +37,11 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { `"name":"created"`, 1), "events[0].body: missing"}, {"whole events kept nowhere", strings.Replace(whole, `,"family":{"name":"broker.events","max-age-days":30}`, "", 1), "family: missing"}, + {"a family's own token parses", strings.Replace(whole, `"max-age-days":30}`, `"max-age-days":30,"token":"said"}`, 1), ""}, + {"a family's token shared by every seat", strings.Replace(whole, `"max-age-days":30}`, + `"max-age-days":30,"token":"event"}`, 1), "family.token: \"event\" is a token every seat shares"}, + {"a family's token not a word", strings.Replace(whole, `"max-age-days":30}`, + `"max-age-days":30,"token":"sa.id"}`, 1), "family.token"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, {"records at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, @@ -89,6 +94,18 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { t.Errorf("a type defined twice: %v", err) } + // An event's subject takes its family's token: the shared events stream's `event`, or the family's own. + for _, c := range []struct{ token, want string }{{"", "mesh.seat.mesh-broker.event.created"}, + {"said", "mesh.seat.mesh-broker.said.created"}} { + d, err := Parse([]byte(strings.Replace(whole, `"max-age-days":30}`, `"max-age-days":30,"token":"`+c.token+`"}`, 1))) + if err != nil { + t.Fatal(err) + } + if got := d.EventSubject("created"); got != c.want { + t.Errorf("token %q: subject %q, want %q", c.token, got, c.want) + } + } + // Fixtures name what they are an instance of. if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list.input","given":{}},{"name":"b","of":"event:created", "subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil { -- 2.54.0 From c51507471402d310769aee8316a0331d42e8dcbf Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:42:57 +0100 Subject: [PATCH 026/144] SD-M2d sdk: a seat family on the shared events stream keeps no longer than the stream Holds operator-channel's decided to the shared stream's 7 days (the lead's ruling of 12:5x), and each registry seat's family with it. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/buscompose/names_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/go/buscompose/names_test.go b/go/buscompose/names_test.go index 25934e2..6f4bd17 100644 --- a/go/buscompose/names_test.go +++ b/go/buscompose/names_test.go @@ -4,7 +4,12 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" + "git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + "git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry" + "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" "git.novox.be/novox/mesh-sdk/go/membership" ) @@ -43,6 +48,17 @@ func TestTheBusNamesAgree(t *testing.T) { } } } + // A seat family on the shared events stream is kept as long as the stream keeps it: its contract says no more. + for family, days := range map[string]int{operatorchannel.Family: operatorchannel.FamilyMaxAgeDays, + npmpackageregistry.Family: npmpackageregistry.FamilyMaxAgeDays, gopackageregistry.Family: gopackageregistry.FamilyMaxAgeDays, + containerimageregistry.Family: containerimageregistry.FamilyMaxAgeDays, + ociartifactregistry.Family: ociartifactregistry.FamilyMaxAgeDays} { + for _, st := range MeshStreams() { + if st.Name == EventsStream && int64(days)*24*60*60 != int64(st.MaxAge) { + t.Errorf("the family %s says %d days, the shared events stream keeps %d s", family, days, st.MaxAge) + } + } + } } // subjectTakes says whether a subject pattern (`*` one token, `>` the rest) takes a subject. -- 2.54.0 From f30a53b2425cc177e4bd2a7fc5f1f4741ac99385 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:43:11 +0100 Subject: [PATCH 027/144] A compose-candidate answer's open input keeps its numbers as written when decoded The answer's input is now the object the node-engine seat's contract names, so a decoder without UseNumber turns its numbers into float64 and a checker re-encoding it for the composer would hand on different digits. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/delivery/compose_candidate.go | 1 + 1 file changed, 1 insertion(+) diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go index 26f051d..2420d61 100644 --- a/go/delivery/compose_candidate.go +++ b/go/delivery/compose_candidate.go @@ -148,6 +148,7 @@ func checkComposeAnswer(a ComposeCandidateAnswer) error { func strictDecode(data []byte, v any, what string) error { d := json.NewDecoder(bytes.NewReader(data)) d.DisallowUnknownFields() + d.UseNumber() // a number in an object the contract leaves open (the composition input) keeps its digits as written if err := d.Decode(v); err != nil { return fmt.Errorf("delivery: %s does not fit its shape: %w", what, err) } -- 2.54.0 From 3622c129f860e806d55244905390bec438ad8753 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:44:16 +0100 Subject: [PATCH 028/144] SDK-A6: a tool reads its call's id from the runtime, CallIDOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A6's request: the ledger's apply names the call it serves (ADR 0597 §7) so the runtime stamps the caller it proved as the operation's author; the id is read from the call's _meta under mesh/call, never from the arguments. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/caller.go | 44 ++++++++++++++++++++++++++++++++++++++------ go/caller_test.go | 41 ++++++++++++++++++++++++++++++++++++----- go/stdio.go | 9 +++++---- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/go/caller.go b/go/caller.go index f9a108a..8045d40 100644 --- a/go/caller.go +++ b/go/caller.go @@ -22,8 +22,19 @@ const MetaCaller = "mesh/caller" // parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven. var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`) -// calls holds the caller of every call while its tool runs, by the arguments map that call was handed. -var calls sync.Map // unsafe.Pointer → string +// MetaCall is where in a `tools/call`'s `_meta` the runtime names the id of the call in flight, the id a ledger's +// apply names so the runtime stamps the caller it proved as the operation's author (novox/hq ADR 0597 §7): +// buscalls.MetaCall, written here too so this package imports nothing of buscalls. +const MetaCall = "mesh/call" + +// callID is the shape of a call's id: one subject-safe token, as the runtime makes it. Anything else reads as none. +var callID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) + +// inFlight is what the runtime said of one call while its tool runs. +type inFlight struct{ caller, call string } + +// calls holds the caller and the id of every call while its tool runs, by the arguments map that call was handed. +var calls sync.Map // unsafe.Pointer → inFlight func argsKey(args map[string]any) unsafe.Pointer { return reflect.ValueOf(args).UnsafePointer() @@ -37,11 +48,32 @@ func CallerOf(args map[string]any) string { return Unproven } if c, ok := calls.Load(argsKey(args)); ok { - return c.(string) + return c.(inFlight).caller } return Unproven } +// CallIDOf is the id of the call that was handed args, as the runtime named it under MetaCall: what a ledger's apply +// names (buscalls.ApplyRequest.Call) so the operation's author is the caller the runtime proved. Empty for arguments +// no call was handed, or a call the runtime named no id for; read from `_meta`, never from the arguments. +func CallIDOf(args map[string]any) string { + if args == nil { + return "" + } + if c, ok := calls.Load(argsKey(args)); ok { + return c.(inFlight).call + } + return "" +} + +// callIn is the call id a `tools/call`'s `_meta` names, or empty. +func callIn(meta map[string]any) string { + if s, ok := meta[MetaCall].(string); ok && callID.MatchString(s) { + return s + } + return "" +} + // callerIn is the caller a `tools/call`'s `_meta` names, or Unproven. func callerIn(meta map[string]any) string { if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) { @@ -50,10 +82,10 @@ func callerIn(meta map[string]any) string { return Unproven } -// runAs runs a tool with the caller held for its arguments for as long as it runs. -func runAs(t Tool, caller string, args map[string]any) (any, error) { +// runAs runs a tool with the caller and the call's id held for its arguments for as long as it runs. +func runAs(t Tool, caller, call string, args map[string]any) (any, error) { key := argsKey(args) - calls.Store(key, caller) + calls.Store(key, inFlight{caller, call}) defer calls.Delete(key) return t.Run(args) } diff --git a/go/caller_test.go b/go/caller_test.go index ee0d5b8..bac2a36 100644 --- a/go/caller_test.go +++ b/go/caller_test.go @@ -6,25 +6,33 @@ import ( "io" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/buscalls" ) // callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call // with the params given, and returns the caller it answered. func callWith(t *testing.T, params map[string]any) string { + caller, _ := callAnswering(t, params) + return caller +} + +// callAnswering is callWith answering the call's id CallIDOf read too. +func callAnswering(t *testing.T, params map[string]any) (string, string) { servesAlone(t) t.Helper() inR, inW := io.Pipe() outR, outW := io.Pipe() go func() { _ = ServeOn("tracker", []Tool{{Name: "who", Run: func(args map[string]any) (any, error) { - return map[string]any{"caller": CallerOf(args)}, nil + return map[string]any{"caller": CallerOf(args), "call": CallIDOf(args)}, nil }}}, inR, outW) }() defer inW.Close() b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params}) _, _ = inW.Write(append(b, '\n')) lines := bufio.NewScanner(outR) - done := make(chan string, 1) + done := make(chan [2]string, 1) go func() { if lines.Scan() { var m struct { @@ -37,19 +45,20 @@ func callWith(t *testing.T, params map[string]any) string { _ = json.Unmarshal(lines.Bytes(), &m) var answer struct { Caller string `json:"caller"` + Call string `json:"call"` } if len(m.Result.Content) > 0 { _ = json.Unmarshal([]byte(m.Result.Content[0].Text), &answer) } - done <- answer.Caller + done <- [2]string{answer.Caller, answer.Call} } }() select { case got := <-done: - return got + return got[0], got[1] case <-time.After(3 * time.Second): t.Fatal("the bundle did not answer the call") - return "" + return "", "" } } @@ -62,6 +71,28 @@ func TestAToolReadsTheCallerTheRuntimeNamed(t *testing.T) { if got != "shanks.node-tools" { t.Fatalf("the tool read caller %q, want the one the runtime named", got) } + // The call's id is the runtime's too (ADR 0597 §7): read from `_meta`, never from the arguments, and none when it + // is not one token. + for _, c := range []struct { + args, meta map[string]any + want string + }{ + {map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools", MetaCall: "c-7Hq2"}, "c-7Hq2"}, + {map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools"}, ""}, + {map[string]any{MetaCall: "c-forged"}, map[string]any{}, ""}, + {map[string]any{}, map[string]any{MetaCall: "a b"}, ""}, + {map[string]any{}, map[string]any{MetaCall: 7}, ""}, + } { + if _, call := callAnswering(t, map[string]any{"name": "who", "arguments": c.args, "_meta": c.meta}); call != c.want { + t.Errorf("args %v, meta %v: the tool read call %q, want %q", c.args, c.meta, call, c.want) + } + } + if CallIDOf(map[string]any{"x": 1}) != "" || CallIDOf(nil) != "" { + t.Error("arguments no call was handed have a call id") + } + if MetaCall != buscalls.MetaCall { + t.Errorf("the call id's place is %q here and %q in buscalls", MetaCall, buscalls.MetaCall) + } } // A caller named in the arguments is not the caller: an argument is the caller's to write. diff --git a/go/stdio.go b/go/stdio.go index 62d0053..85f4eae 100644 --- a/go/stdio.go +++ b/go/stdio.go @@ -248,7 +248,8 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { var p struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` - // Meta is the runtime's, never the caller's: who called, taken from the subject (CallerOf). + // Meta is the runtime's, never the caller's: who called, taken from the subject (CallerOf), and + // the call's id (CallIDOf). Meta map[string]any `json:"_meta"` } _ = json.Unmarshal(m.Params, &p) @@ -259,18 +260,18 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { } // Beside the loop, never in it: a tool that emits waits for the runtime's answer, which // arrives on the line this loop reads next — and calls do not queue behind a slow one. - go func(id any, t Tool, args map[string]any, caller string) { + go func(id any, t Tool, args map[string]any, caller, call string) { if args == nil { args = map[string]any{} } - result, err := runAs(t, caller, args) + result, err := runAs(t, caller, call, args) if err != nil { c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": err.Error()}}, "isError": true}}) return } text, _ := json.Marshal(result) c.say(map[string]any{"id": id, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": string(text)}}}}) - }(id, t, p.Arguments, callerIn(p.Meta)) + }(id, t, p.Arguments, callerIn(p.Meta), callIn(p.Meta)) case "mesh/event": // One event the runtime read from this module's consumer (ADR 0198), answered once every // handler it is for has taken it; an error is answered as one, so the runtime does not -- 2.54.0 From 6fb778a8898fe7b4581845bad9cfa317ab928aca Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:45:52 +0100 Subject: [PATCH 029/144] SDK-A6: a bundle hears its consumer's heartbeat between events, OnHeartbeat A6's request: the knowledge base's page marks a source unanswered from its subscription; with events alone a quiet source and a lost subscription look the same. The runtime sends mesh/heartbeat at the bus's idle heartbeats, and once with missed when they stop; the tool runner's side is a request to its owner. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/stdio.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ go/stdio_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/go/stdio.go b/go/stdio.go index 85f4eae..f1e8425 100644 --- a/go/stdio.go +++ b/go/stdio.go @@ -70,6 +70,8 @@ type channel struct { listeners []listener // subscribed numbers the listeners, so one whose subscribe failed is the one removed. subscribed int + // heartbeats are this bundle's hooks on its consumer's heartbeat (OnHeartbeat). + heartbeats []func(Heartbeat) // watches are this bundle's state watches by the id each was asked with (ADR 0201). watches map[string]*follower[StateChange] // provisioned are this bundle's watches of its values, by the id each was asked with (provision.go). @@ -300,6 +302,22 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { } c.say(map[string]any{"id": id, "result": map[string]any{}}) }(id, *p.Envelope, matching) + case HeartbeatMethod: + // The runtime's word on this module's consumer while no event arrives (OnHeartbeat): handed to every + // hook beside the loop, and answered at once when it was asked with an id. + var hb Heartbeat + _ = json.Unmarshal(m.Params, &hb) + c.mu.Lock() + hooks := append([]func(Heartbeat){}, c.heartbeats...) + c.mu.Unlock() + go func() { + for _, h := range hooks { + h(hb) + } + }() + if id != nil { + c.say(map[string]any{"id": id, "result": map[string]any{}}) + } case "mesh/state": // One change to a state this bundle watches (ADR 0201), named by the watch's id: taken into the watch's // follower in the order it came and answered at once, so a slow handler never counts against the runtime's @@ -473,6 +491,33 @@ func Subscribe(pattern string, handle func(Envelope) error) error { return err } +// HeartbeatMethod is what the runtime sends a bundle that subscribed, for its module's consumer: at each of the bus's +// idle heartbeats while no event arrives, and once with Missed when the bus's heartbeat stopped arriving. +const HeartbeatMethod = "mesh/heartbeat" + +// Heartbeat is the runtime's word on the module's consumer between events: it is bound and the bus says so (At), or +// the bus's heartbeat stopped arriving (Missed, with Why), so a subscription's silence is told from a quiet source. +type Heartbeat struct { + At time.Time `json:"at"` + Missed bool `json:"missed,omitempty"` + Why string `json:"why,omitempty"` +} + +// OnHeartbeat hands every heartbeat of this module's consumer to hook, beside the loop (two may arrive out of order: +// compare At): a +// bundle that marks a source silent past a bound clears the mark on a heartbeat that is not Missed, and sets it at once +// on one that is. Call it once Serve runs, as Subscribe. +func OnHeartbeat(hook func(Heartbeat)) error { + c, err := live() + if err != nil { + return err + } + c.mu.Lock() + c.heartbeats = append(c.heartbeats, hook) + c.mu.Unlock() + return nil +} + // Ask calls a tool on the mesh as this bundle's module, through the runtime (ADR 0198), and returns // its result as JSON. The key is `.` or `seat:.`, with `@` for one machine's, as the // runtime calls it; a malformed key is refused here, before the runtime is asked (calladdress; novox/hq issue 774). diff --git a/go/stdio_test.go b/go/stdio_test.go index 78d64a0..258c5c4 100644 --- a/go/stdio_test.go +++ b/go/stdio_test.go @@ -171,10 +171,14 @@ func TestARefusedSubscribeLeavesNoHandlerBehind(t *testing.T) { var mu sync.Mutex handled := 0 subscribed := make(chan error, 2) + beats := make(chan Heartbeat, 2) go func() { _ = ServeOn("", []Tool{{Name: "start", Run: func(map[string]any) (any, error) { go func() { for i := 0; i < 2; i++ { + if i == 0 { + _ = OnHeartbeat(func(hb Heartbeat) { beats <- hb }) + } err := Subscribe("*.download.completed", func(Envelope) error { mu.Lock() handled++ @@ -238,6 +242,28 @@ func TestARefusedSubscribeLeavesNoHandlerBehind(t *testing.T) { break } } + // The consumer's heartbeat between events reaches the bundle's hook: a notification, and one asked with an id, + // which is answered. + send(map[string]any{"jsonrpc": "2.0", "method": HeartbeatMethod, "params": map[string]any{"at": "2026-11-04T13:00:00Z"}}) + send(map[string]any{"jsonrpc": "2.0", "id": 10, "method": HeartbeatMethod, "params": map[string]any{"at": "2026-11-04T13:00:05Z", + "missed": true, "why": "no heartbeat from the bus for 10s"}}) + for { + if r := next(); r["id"] == float64(10) { + break + } + } + got := map[bool]Heartbeat{} + for i := 0; i < 2; i++ { + select { + case hb := <-beats: + got[hb.Missed] = hb + case <-time.After(3 * time.Second): + t.Fatal("a heartbeat did not reach the hook") + } + } + if got[false].At.IsZero() || got[true].Why == "" { + t.Errorf("the hook was handed %v", got) + } _ = inW.Close() mu.Lock() defer mu.Unlock() -- 2.54.0 From 0b25bce23954e2e02c3a4255e128912508f4f128 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:50:41 +0100 Subject: [PATCH 030/144] =?UTF-8?q?ADR=200626=20=C2=A71:=20a=20process's?= =?UTF-8?q?=20bus=20may=20say=20keeps,=20for=20the=20bus=20module's=20keep?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2b's judge test waits on it: the strict decoder refused the key, so the bus module's new manifest did not decode. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/process_bus.go | 6 +++++- go/catalogue/stream_work_queue_test.go | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/go/catalogue/process_bus.go b/go/catalogue/process_bus.go index b5049ca..b83c2e8 100644 --- a/go/catalogue/process_bus.go +++ b/go/catalogue/process_bus.go @@ -31,6 +31,10 @@ type ProcessBus struct { // Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR // 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process. Removes []string `json:"removes,omitempty"` + // Keeps says this process is the bus module's keeper (novox/hq ADR 0626 §1): the controller composes the bus's + // create, update and snapshot subjects for modules' entries into its account. Refused on any other process or + // module by the judge (ADR 0626 §3); read here so a manifest declaring it decodes. + Keeps bool `json:"keeps,omitempty"` } // What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: keys of the node-engine's acts @@ -93,7 +97,7 @@ func (b *ProcessBus) UnmarshalJSON(raw []byte) error { dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&p); err != nil { - return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes}: %w", typedUnknown(err)) + return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes, keeps}: %w", typedUnknown(err)) } *b = ProcessBus(p) return nil diff --git a/go/catalogue/stream_work_queue_test.go b/go/catalogue/stream_work_queue_test.go index d266c96..a68ff91 100644 --- a/go/catalogue/stream_work_queue_test.go +++ b/go/catalogue/stream_work_queue_test.go @@ -28,6 +28,15 @@ func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) { s.Days() != 1 || s.Cap() != 64 { t.Fatalf("the stream read as %+v", s) } + // ADR 0626 §1: the bus module's keeper declares keeps beside the rest of its bus, and it reads back. + k, err := decodeForTest([]byte(`{"module":"nats","version":"1","resources":[{"id":"keeper","type":"process",` + + `"run":["./keeper"],"bus":{"keeps":true,"state":["requests"]}}]}`)) + if err != nil { + t.Fatalf("a keeper's bus was refused: %v", err) + } + if b, ok := k.ProcessBuses()["keeper"]; !ok || !b.Keeps { + t.Fatalf("the keeper's bus read as %+v", b) + } } // 1177/AC3: a stream with no retention composes as main does — limits retention, dropping its oldest — proven -- 2.54.0 From fc5048619076524ef5756aea2d36bd8f54a9541a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:51:56 +0100 Subject: [PATCH 031/144] views: an ask through a tool reads the tool runner's {result, error} envelope A5's request: the bus module's keeper asks the indexer's first view through the tool runner, which answers in that envelope; its error is the ask's. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/follow.go | 13 ++++++++++++- go/views/views_test.go | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/go/views/follow.go b/go/views/follow.go index 83c7fdc..5c005f7 100644 --- a/go/views/follow.go +++ b/go/views/follow.go @@ -352,7 +352,8 @@ func (f *Follower) Gap(ctx context.Context, why string) error { return f.askUntil(ctx, "a gap: "+why, false) } -// AskVia is a Follower's Ask through a process's call of the owner's tool (process.Conn.Call), decoding its answer. +// AskVia is a Follower's Ask through a call of the owner's tool (process.Conn.Call, or stdio.Ask through the tool +// runner), decoding its answer: bare, or in the tool runner's envelope {result, error}, whose error is the ask's. func AskVia(call func(key string, body any) (json.RawMessage, error), tool string) func(context.Context, string) (Answer, error) { return func(_ context.Context, after string) (Answer, error) { body := map[string]any{} @@ -363,6 +364,16 @@ func AskVia(call func(key string, body any) (json.RawMessage, error), tool strin if err != nil { return Answer{}, err } + var wrapped struct { + Result json.RawMessage `json:"result"` + Error string `json:"error"` + } + if json.Unmarshal(raw, &wrapped) == nil && (len(wrapped.Result) > 0 || wrapped.Error != "") { + if wrapped.Error != "" { + return Answer{}, fmt.Errorf("%s answered: %s", tool, wrapped.Error) + } + raw = wrapped.Result + } return DecodeAnswer(raw) } } diff --git a/go/views/views_test.go b/go/views/views_test.go index ebeef12..3625195 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -260,6 +260,24 @@ func TestViews(t *testing.T) { t.Errorf("asked again by hand: %v, %v", again, err) } }) + t.Run("an ask through a tool reads the answer bare or in the tool runner's envelope, and its error", func(t *testing.T) { + bare := `{"v":1,"owner":"o","state":"s","revision":3,"values":{"k":"1"}}` + for name, c := range map[string]struct { + answer string + rev uint64 + err string + }{ + "bare": {bare, 3, ""}, + "in an envelope": {`{"result":` + bare + `}`, 3, ""}, + "an error": {`{"error":"not yet"}`, 0, "not yet"}, + } { + ask := AskVia(func(string, any) (json.RawMessage, error) { return json.RawMessage(c.answer), nil }, "o.first_view") + a, err := ask(context.Background(), "") + if (c.err == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.err)) || a.Revision != c.rev { + t.Errorf("%s: %+v, %v", name, a, err) + } + } + }) t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) { if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { t.Errorf("state of subject = %q", s) -- 2.54.0 From 293df4937758834670905bff28aee4cf60c15111 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:53:19 +0100 Subject: [PATCH 032/144] SD-M1 sdk: a seat's family is the shared events stream's, 7 days, no cap of its own The lead's ruling of 13:0x replaces 12:5x's said-subject: mesh-broker's events follow every seat, on mesh.seat.mesh-broker.event. in the shared events stream, 7 days; the bus module keeps a longer history on a stream of its own module. So every seat family is that stream filtered to the seat's subjects: a definition says 7 days and no cap of its own, and the reader refuses anything else. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/buscompose/names_test.go | 42 ++++---------------------------- go/seatdef/seatdef.go | 48 +++++++++---------------------------- go/seatdef/seatdef_test.go | 25 +++++-------------- 3 files changed, 21 insertions(+), 94 deletions(-) diff --git a/go/buscompose/names_test.go b/go/buscompose/names_test.go index 6f4bd17..6a31678 100644 --- a/go/buscompose/names_test.go +++ b/go/buscompose/names_test.go @@ -1,16 +1,10 @@ package buscompose import ( - "strings" "testing" - "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" - "git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry" - "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" - "git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry" - "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry" - "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" "git.novox.be/novox/mesh-sdk/go/membership" + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with @@ -39,38 +33,10 @@ func TestTheBusNamesAgree(t *testing.T) { if err := ledger.Valid(); err != nil || ledger.OwnSecret() != "ledgers" { t.Fatalf("a wanted ledger account: %v, held in %q", err, ledger.OwnSecret()) } - // mesh-broker's events are the bus module's own stream (design 25 § 12.3, 30 days): no subject of the shared - // streams may take them, or the bus refuses the two streams as overlapping. + // A seat's family is the shared events stream filtered to its subjects: a definition says the stream's age. for _, st := range MeshStreams() { - for _, pattern := range st.Subjects { - if subjectTakes(pattern, meshbroker.SubjectCreated) { - t.Errorf("the stream %s takes mesh-broker's %s by %s", st.Name, meshbroker.SubjectCreated, pattern) - } - } - } - // A seat family on the shared events stream is kept as long as the stream keeps it: its contract says no more. - for family, days := range map[string]int{operatorchannel.Family: operatorchannel.FamilyMaxAgeDays, - npmpackageregistry.Family: npmpackageregistry.FamilyMaxAgeDays, gopackageregistry.Family: gopackageregistry.FamilyMaxAgeDays, - containerimageregistry.Family: containerimageregistry.FamilyMaxAgeDays, - ociartifactregistry.Family: ociartifactregistry.FamilyMaxAgeDays} { - for _, st := range MeshStreams() { - if st.Name == EventsStream && int64(days)*24*60*60 != int64(st.MaxAge) { - t.Errorf("the family %s says %d days, the shared events stream keeps %d s", family, days, st.MaxAge) - } + if st.Name == EventsStream && int64(st.MaxAge) != seatdef.SharedEventsMaxAgeDays*24*60*60 { + t.Errorf("the shared events stream keeps %d s, a seat's definition says %d days", st.MaxAge, seatdef.SharedEventsMaxAgeDays) } } } - -// subjectTakes says whether a subject pattern (`*` one token, `>` the rest) takes a subject. -func subjectTakes(pattern, subject string) bool { - p, s := strings.Split(pattern, "."), strings.Split(subject, ".") - for i, tok := range p { - switch { - case tok == ">": - return len(s) > i - case i >= len(s) || (tok != "*" && tok != s[i]): - return false - } - } - return len(p) == len(s) -} diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index c0b37ae..50f0da4 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -67,8 +67,7 @@ type Definition struct { Verbs []Verb `json:"verbs,omitempty"` // Accepts are what may be sent to the seat's holder on `mesh.seat..accept.`. Accepts []Message `json:"accepts,omitempty"` - // Events are what is published on the seat's own subject, `mesh.seat...` (EventSubject): the - // token is `event`, unless the family names its own. + // Events are what is published on the seat's own subject, `mesh.seat..event.`. Events []Message `json:"events,omitempty"` // Family is where the seat's events are kept, when the definition fixes it. Family *Family `json:"family,omitempty"` @@ -118,24 +117,17 @@ type HolderEvent struct { Body map[string]any `json:"body,omitempty"` } -// Family is where a seat's events are kept. +// Family is where a seat's events are kept: the shared events stream filtered to the seat's subjects, so its age is +// the stream's (SharedEventsMaxAgeDays) and it has no cap of its own (the lead's ruling of 2026-11-04 13:0x: a +// holder that keeps a longer history keeps it in a stream of its own module). type Family struct { Name string `json:"name"` MaxAgeDays int `json:"max-age-days"` - MaxMiB int `json:"max-mib,omitempty"` - // Token is the subject's word after the seat's name, `event` when absent. A family kept in a stream of its own - // names another, so its subjects stay outside the shared events stream's `mesh.seat.*.event.>`: the bus refuses - // two streams whose subjects overlap (mesh-broker's `said`, design 25 § 12.3). - Token string `json:"token,omitempty"` } -// EventToken is the token after the seat's name on the subjects of its events. -func (f *Family) EventToken() string { - if f == nil || f.Token == "" { - return "event" - } - return f.Token -} +// SharedEventsMaxAgeDays is how long the shared events stream keeps a seat's events (mesh-sdk buscompose's +// EventsStream, whose test holds the two equal). +const SharedEventsMaxAgeDays = 7 // Record is one state the holder keeps for the seat: its key's form and its value's schema. type Record struct { @@ -387,16 +379,9 @@ func (d Definition) check() Problems { if d.Family.Name == "" { ps = append(ps, Problem{"family.name", "missing"}) } - if d.Family.MaxAgeDays < 1 { - ps = append(ps, Problem{"family.max-age-days", "missing, or under one day"}) - } - switch t := d.Family.Token; { - case t == "": - case !wordName.MatchString(t): - ps = append(ps, Problem{"family.token", fmt.Sprintf("%q is not a lower-case word", t)}) - case sharedTokens[t]: - ps = append(ps, Problem{"family.token", fmt.Sprintf("%q is a token every seat shares; "+ - "a family of its own names another, or none", t)}) + if d.Family.MaxAgeDays != SharedEventsMaxAgeDays { + ps = append(ps, Problem{"family.max-age-days", fmt.Sprintf("missing, or not %d: a seat's events ride the "+ + "shared events stream, which keeps them %d days", SharedEventsMaxAgeDays, SharedEventsMaxAgeDays)}) } } for i, e := range d.HolderEvents { @@ -517,20 +502,9 @@ func Resolve(d Definition, parts map[string]Part) (Definition, error) { return out, nil } -// EventSubject is where the seat's event is published on the shared events stream's token; by a caller, the -// caller's name follows. A definition whose family names a token of its own is asked with Definition.EventSubject. +// EventSubject is where the seat's event is published; by a caller, the caller's name follows. func EventSubject(seat, event string) string { return "mesh.seat." + seat + ".event." + event } -// EventSubject is where this seat's event is published, under its family's token; by a caller, the caller's name -// follows. -func (d Definition) EventSubject(event string) string { - return "mesh.seat." + d.Seat + "." + d.Family.EventToken() + "." + event -} - -// sharedTokens are the words after a seat's name that every seat's subjects share (its verbs, its work queue, the -// shared events stream's events): a family's own token is none of them. -var sharedTokens = map[string]bool{"event": true, "accept": true, "tool": true, "call": true, "proof": true} - // AcceptSubject is where a message the seat accepts is sent. func AcceptSubject(seat, accept string) string { return "mesh.seat." + seat + ".accept." + accept } diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index 9c9a52f..7f27a8f 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -13,7 +13,7 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { "verbs":[{"name":"add","input":{"type":"object"}}]}` const whole = `{"format":1,"seat":"mesh-broker","scope":"mesh","grain":"whole","decision":"ADR 1", "verbs":[{"name":"list","input":{"type":"object"},"answer":{"type":"object"}}], - "events":[{"name":"created","body":{"type":"object"}}],"family":{"name":"broker.events","max-age-days":30}}` + "events":[{"name":"created","body":{"type":"object"}}],"family":{"name":"broker.events","max-age-days":7}}` cases := []struct { name, raw string refused string // a text the refusal names, empty when it parses @@ -35,13 +35,12 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { "verbs[0].input: missing"}, {"a whole event with no body", strings.Replace(whole, `"name":"created","body":{"type":"object"}`, `"name":"created"`, 1), "events[0].body: missing"}, - {"whole events kept nowhere", strings.Replace(whole, `,"family":{"name":"broker.events","max-age-days":30}`, + {"whole events kept nowhere", strings.Replace(whole, `,"family":{"name":"broker.events","max-age-days":7}`, "", 1), "family: missing"}, - {"a family's own token parses", strings.Replace(whole, `"max-age-days":30}`, `"max-age-days":30,"token":"said"}`, 1), ""}, - {"a family's token shared by every seat", strings.Replace(whole, `"max-age-days":30}`, - `"max-age-days":30,"token":"event"}`, 1), "family.token: \"event\" is a token every seat shares"}, - {"a family's token not a word", strings.Replace(whole, `"max-age-days":30}`, - `"max-age-days":30,"token":"sa.id"}`, 1), "family.token"}, + {"a family kept longer than the shared events stream", strings.Replace(whole, `"max-age-days":7`, + `"max-age-days":30`, 1), "family.max-age-days: missing, or not 7"}, + {"a family with a cap of its own", strings.Replace(whole, `"max-age-days":7`, `"max-age-days":7,"max-mib":256`, 1), + "max-mib: a field the format does not know"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, {"records at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, @@ -94,18 +93,6 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { t.Errorf("a type defined twice: %v", err) } - // An event's subject takes its family's token: the shared events stream's `event`, or the family's own. - for _, c := range []struct{ token, want string }{{"", "mesh.seat.mesh-broker.event.created"}, - {"said", "mesh.seat.mesh-broker.said.created"}} { - d, err := Parse([]byte(strings.Replace(whole, `"max-age-days":30}`, `"max-age-days":30,"token":"`+c.token+`"}`, 1))) - if err != nil { - t.Fatal(err) - } - if got := d.EventSubject("created"); got != c.want { - t.Errorf("token %q: subject %q, want %q", c.token, got, c.want) - } - } - // Fixtures name what they are an instance of. if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list.input","given":{}},{"name":"b","of":"event:created", "subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil { -- 2.54.0 From e39faa0f38274c97d8800da3c9225963d220b406 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:54:26 +0100 Subject: [PATCH 033/144] SDK-1: the parts helper reads the registry part's shapes from a registry seat's module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interim, for the lead's ruling: ADR 0625 §1 cuts the registry part into each registry seat's own Go module, and §3 lets no mesh-sdk package but catalogue import a contracts module; this helper is logic shared over a seat, a client in §4's sense, which no clause lets the registry milestone build. Kept building meanwhile. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/publish/publish.go | 5 +++-- go/publish/publish_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/go/publish/publish.go b/go/publish/publish.go index 30eead2..3503fb1 100644 --- a/go/publish/publish.go +++ b/go/publish/publish.go @@ -1,7 +1,8 @@ // Package publish is the runtime's helper for sending a file to a registry seat in parts (novox/hq ADR 0611, design // 51 *Publishing*): `publish` opens a transfer naming every part's digest, the holder answers the parts it lacks, and // only those travel, by `publish-part`, in chunks a bus message carries. A cut transfer goes on from what the holder -// answers when the same transfer is opened again. The seat's messages are mesh-contracts' (parts/registry); the call is +// answers when the same transfer is opened again. The seat's messages are mesh-contracts' +// (the registry part's, read through containerimageregistry, the four registry seats' shapes being one); the call is // the caller's own (a bundle's stdio.Ask, a process's call of a seat verb), so this package holds no transport. package publish @@ -15,7 +16,7 @@ import ( "fmt" "io" - "git.novox.be/novox/mesh-contracts/go/parts/registry" + registry "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" ) // MaxChunk is the most bytes of a part one publish-part carries: base64 makes it about 1 000 KiB, under 1 MiB, the diff --git a/go/publish/publish_test.go b/go/publish/publish_test.go index 08bf439..1e33c51 100644 --- a/go/publish/publish_test.go +++ b/go/publish/publish_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-contracts/go/parts/registry" + registry "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" ) // A file travels to a registry seat in parts (ADR 0611, design 51 *Publishing*; SDK-1): only the parts the holder says -- 2.54.0 From 1a149d44bef003383b504b66fda877731f6e19f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:09:19 +0100 Subject: [PATCH 034/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20the=20sending?= =?UTF-8?q?=20of=20a=20file=20in=20parts=20is=20seat-neutral=20runtime;=20?= =?UTF-8?q?publish=20leaves=20mesh-sdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parts takes the open and the chunk calls from its caller and names no seat, verb or seat type; each registry seat's client in mesh-contracts binds it to its own seat (ADR 0627 §1). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/packages_list_test.go | 2 +- go/parts/parts.go | 145 ++++++++++++++++ .../publish_test.go => parts/parts_test.go} | 54 +++--- go/publish/publish.go | 159 ------------------ 4 files changed, 170 insertions(+), 190 deletions(-) create mode 100644 go/parts/parts.go rename go/{publish/publish_test.go => parts/parts_test.go} (61%) delete mode 100644 go/publish/publish.go diff --git a/go/packages_list_test.go b/go/packages_list_test.go index d0f3b04..a012bb4 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -25,7 +25,7 @@ import ( var meshSDKPackages = []string{ ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", - "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", "publish", + "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "testbus", "views", } diff --git a/go/parts/parts.go b/go/parts/parts.go new file mode 100644 index 0000000..e16c463 --- /dev/null +++ b/go/parts/parts.go @@ -0,0 +1,145 @@ +// Package parts is the runtime's sender of a file in parts (novox/hq ADR 0611, ADR 0627 §2, design 51 *Publishing*): +// the caller opens a transfer naming every part's digest and size, the receiver answers the parts it lacks, and only +// those travel, in chunks a bus message carries, in sequence with the last marked. A cut transfer goes on from what +// the receiver answers when the same transfer is opened again. It names no seat, no verb and no seat's type: the +// caller's Open and Chunk are its own calls (a registry seat's client binds them to that seat's verbs and types), so +// this package holds no transport and no contract. +package parts + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" +) + +// MaxChunk is the most bytes of a part one chunk carries: base64 makes it about 1 000 KiB, under 1 MiB, the server's +// largest message, with the message's own fields (design 51: each part travels in chunks of at most 1 MiB). +const MaxChunk = 760 << 10 + +// Part is one part of a transfer: its digest (sha256:), its size and how to read its bytes. +type Part struct { + Digest string + Size int64 + Open func() (io.ReadCloser, error) +} + +// Opened is the receiver's answer to opening a transfer: whether it holds the whole already, and the digests of the +// parts it lacks. +type Opened struct { + Held bool + Lacks []string +} + +// Open opens (or opens again) the transfer on the receiver, naming every part: the caller's own call. +type Open func(ctx context.Context, parts []Part) (Opened, error) + +// Chunk sends one chunk of a part: the part's digest, its sequence from 0, whether it is the part's last, its bytes. +type Chunk func(ctx context.Context, part string, sequence int, last bool, data []byte) error + +// Sent is what a Send did: whether the receiver held the whole, the parts it lacked and were sent, the chunks they +// took. +type Sent struct { + Held bool + Parts []string + Chunks int +} + +// Send opens the transfer and sends the parts the receiver answers it lacks, each in chunks of at most MaxChunk, in +// sequence, the last marked. A part whose bytes do not hash to its digest is refused before anything of it is sent; a +// part the receiver does not name is never read; a part the receiver names that the transfer does not is refused. +func Send(ctx context.Context, open Open, chunk Chunk, parts []Part) (Sent, error) { + byDigest := map[string]Part{} + for _, p := range parts { + byDigest[p.Digest] = p + } + opened, err := open(ctx, parts) + if err != nil { + return Sent{}, err + } + sent := Sent{Held: opened.Held} + for _, d := range opened.Lacks { + p, ok := byDigest[d] + if !ok { + return sent, fmt.Errorf("the receiver lacks the part %s, which the transfer does not name", d) + } + if err := ctx.Err(); err != nil { + return sent, err + } + if err := check(p); err != nil { + return sent, err + } + n, err := sendPart(ctx, chunk, p) + sent.Chunks += n + if err != nil { + return sent, err + } + sent.Parts = append(sent.Parts, d) + } + return sent, nil +} + +// check reads the part once and refuses it when its bytes are not its digest or its size. +func check(p Part) error { + r, err := p.Open() + if err != nil { + return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + defer r.Close() + h := sha256.New() + n, err := io.Copy(h, r) + if err != nil { + return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + if got := "sha256:" + hex.EncodeToString(h.Sum(nil)); got != p.Digest || n != p.Size { + return fmt.Errorf("the part %s holds %d bytes of digest %s, not its own %d: nothing of it is sent", p.Digest, n, got, p.Size) + } + return nil +} + +func sendPart(ctx context.Context, chunk Chunk, p Part) (int, error) { + r, err := p.Open() + if err != nil { + return 0, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + defer r.Close() + buf := make([]byte, MaxChunk) + // A chunk is sent once the next read says whether it is the last, so the last is always marked: an empty part is + // one empty chunk, marked last. + var prev []byte + sent := 0 + send := func(data []byte, last bool) error { + if err := chunk(ctx, p.Digest, sent, last, data); err != nil { + return fmt.Errorf("chunk %d of the part %s: %w", sent, p.Digest, err) + } + sent++ + return nil + } + for { + if err := ctx.Err(); err != nil { + return sent, err + } + n, err := io.ReadFull(r, buf) + end := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) + if err != nil && !end { + return sent, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) + } + if end && n == 0 { + if prev == nil { + prev = []byte{} + } + return sent, send(prev, true) + } + if prev != nil { + if err := send(prev, false); err != nil { + return sent, err + } + } + prev = append([]byte(nil), buf[:n]...) + if end { + return sent, send(prev, true) + } + } +} diff --git a/go/publish/publish_test.go b/go/parts/parts_test.go similarity index 61% rename from go/publish/publish_test.go rename to go/parts/parts_test.go index 1e33c51..fa2e8da 100644 --- a/go/publish/publish_test.go +++ b/go/parts/parts_test.go @@ -1,20 +1,16 @@ -package publish +package parts import ( "bytes" "context" "crypto/sha256" - "encoding/base64" "encoding/hex" - "encoding/json" "io" "strings" "testing" - - registry "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" ) -// A file travels to a registry seat in parts (ADR 0611, design 51 *Publishing*; SDK-1): only the parts the holder says +// A file travels in parts (ADR 0611, ADR 0627 §2, design 51 *Publishing*; SDK-1): only the parts the holder says // it lacks are read and sent, each in chunks of at most MaxChunk in sequence with the last marked, so a cut transfer // opened again goes on from what the holder holds; a part whose bytes are not its digest is refused before any of it // travels. @@ -32,28 +28,25 @@ func TestAFileTravelsInTheParts(t *testing.T) { last bool data []byte } - holder := func(lacks []string, got *[]chunk, asked *[]string) Call { - return func(key string, body any) (json.RawMessage, error) { - *asked = append(*asked, key) - raw, _ := json.Marshal(body) - switch key { - case "seat:oci-artifact-registry.publish": - return json.Marshal(registry.PublishAnswer{Transfer: "t1", Lacks: lacks, Held: len(lacks) == 0}) - case "seat:oci-artifact-registry.publish-part": - var in registry.PublishPartInput - _ = json.Unmarshal(raw, &in) - data, _ := base64.StdEncoding.DecodeString(in.Data) - if len(raw) > 1<<20 { - t.Errorf("a chunk's message is %d bytes, over the bus's largest", len(raw)) + type receiver struct { + open Open + chunk Chunk + } + holder := func(lacks []string, got *[]chunk, asked *[]string) receiver { + return receiver{ + open: func(_ context.Context, ps []Part) (Opened, error) { + *asked = append(*asked, "open") + return Opened{Lacks: lacks, Held: len(lacks) == 0}, nil + }, + chunk: func(_ context.Context, part string, seq int, last bool, data []byte) error { + if len(data) > MaxChunk { + t.Errorf("a chunk is %d bytes, over MaxChunk", len(data)) } - *got = append(*got, chunk{in.Part, in.Sequence, in.Last, data}) - return json.Marshal(registry.PublishPartAnswer{Transfer: in.Transfer, Part: in.Part}) - } - t.Fatalf("asked %s", key) - return nil, nil + *got = append(*got, chunk{part, int64(seq), last, append([]byte(nil), data...)}) + return nil + }, } } - in := registry.PublishInput{Transfer: "t1", Name: "files", Kind: "archive", Digest: "sha256:" + strings.Repeat("a", 64)} var opens [3]int parts := []Part{part(big, &opens[0]), part(small, &opens[1]), part(empty, &opens[2])} @@ -74,7 +67,8 @@ func TestAFileTravelsInTheParts(t *testing.T) { } var got []chunk var asked []string - sent, err := Publish(context.Background(), holder(lacks, &got, &asked), "oci-artifact-registry", in, parts) + r := holder(lacks, &got, &asked) + sent, err := Send(context.Background(), r.open, r.chunk, parts) if err != nil || sent.Chunks != c.chunks || len(got) != c.chunks || len(sent.Parts) != len(c.lacks) { t.Errorf("%s: sent %+v, %d chunks heard, %v", c.name, sent, len(got), err) continue @@ -111,12 +105,12 @@ func TestAFileTravelsInTheParts(t *testing.T) { liar.Digest = parts[0].Digest var got []chunk var asked []string - if _, err := Publish(context.Background(), holder([]string{liar.Digest}, &got, &asked), "oci-artifact-registry", in, - []Part{liar}); err == nil || len(got) != 0 || !strings.Contains(err.Error(), "nothing of it is sent") { + r := holder([]string{liar.Digest}, &got, &asked) + if _, err := Send(context.Background(), r.open, r.chunk, []Part{liar}); err == nil || len(got) != 0 || !strings.Contains(err.Error(), "nothing of it is sent") { t.Errorf("a part not its digest: %v, %d chunks sent", err, len(got)) } - if _, err := Publish(context.Background(), holder([]string{"sha256:" + strings.Repeat("f", 64)}, &got, &asked), - "oci-artifact-registry", in, parts); err == nil || !strings.Contains(err.Error(), "does not name") { + r = holder([]string{"sha256:" + strings.Repeat("f", 64)}, &got, &asked) + if _, err := Send(context.Background(), r.open, r.chunk, parts); err == nil || !strings.Contains(err.Error(), "does not name") { t.Errorf("a part the transfer does not name: %v", err) } } diff --git a/go/publish/publish.go b/go/publish/publish.go deleted file mode 100644 index 3503fb1..0000000 --- a/go/publish/publish.go +++ /dev/null @@ -1,159 +0,0 @@ -// Package publish is the runtime's helper for sending a file to a registry seat in parts (novox/hq ADR 0611, design -// 51 *Publishing*): `publish` opens a transfer naming every part's digest, the holder answers the parts it lacks, and -// only those travel, by `publish-part`, in chunks a bus message carries. A cut transfer goes on from what the holder -// answers when the same transfer is opened again. The seat's messages are mesh-contracts' -// (the registry part's, read through containerimageregistry, the four registry seats' shapes being one); the call is -// the caller's own (a bundle's stdio.Ask, a process's call of a seat verb), so this package holds no transport. -package publish - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - - registry "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" -) - -// MaxChunk is the most bytes of a part one publish-part carries: base64 makes it about 1 000 KiB, under 1 MiB, the -// server's largest message, with the message's own fields (design 51: each part travels in chunks of at most 1 MiB). -const MaxChunk = 760 << 10 - -// Part is one part of a transfer: its digest (sha256:), its size and how to read its bytes. -type Part struct { - Digest string - Size int64 - Open func() (io.ReadCloser, error) -} - -// Call asks one verb of the seat, `seat:.`, with a body, and answers its JSON. -type Call func(key string, body any) (json.RawMessage, error) - -// Sent is what a Publish did: the parts the holder lacked and were sent, and the chunks they took. -type Sent struct { - Transfer string - Held bool - Parts []string - Chunks int -} - -// Publish opens (or opens again) the transfer in on the seat and sends the parts the holder answers it lacks, each in -// chunks of at most MaxChunk, in sequence, the last marked. A part whose bytes do not hash to its digest is refused -// before anything of it is sent; a part the holder does not name is never read. Its outcome, published or refused, is -// the holder's event, which the caller waits for. -func Publish(ctx context.Context, call Call, seat string, in registry.PublishInput, parts []Part) (Sent, error) { - in.Parts = in.Parts[:0] - byDigest := map[string]Part{} - for _, p := range parts { - in.Parts = append(in.Parts, registry.Part{Digest: p.Digest, Size: p.Size}) - byDigest[p.Digest] = p - } - raw, err := call("seat:"+seat+"."+registry.VerbPublish, in) - if err != nil { - return Sent{}, fmt.Errorf("%s's publish of %s: %w", seat, in.Name, err) - } - var opened registry.PublishAnswer - if err := json.Unmarshal(raw, &opened); err != nil { - return Sent{}, fmt.Errorf("%s's answer to publish: %w", seat, err) - } - sent := Sent{Transfer: in.Transfer, Held: opened.Held} - for _, d := range opened.Lacks { - p, ok := byDigest[d] - if !ok { - return sent, fmt.Errorf("%s lacks the part %s, which the transfer %s does not name", seat, d, in.Transfer) - } - if err := ctx.Err(); err != nil { - return sent, err - } - if err := check(p); err != nil { - return sent, err - } - n, err := sendPart(ctx, call, seat, in.Transfer, p) - sent.Chunks += n - if err != nil { - return sent, err - } - sent.Parts = append(sent.Parts, d) - } - return sent, nil -} - -// check reads the part once and refuses it when its bytes are not its digest or its size. -func check(p Part) error { - r, err := p.Open() - if err != nil { - return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - defer r.Close() - h := sha256.New() - n, err := io.Copy(h, r) - if err != nil { - return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - if got := "sha256:" + hex.EncodeToString(h.Sum(nil)); got != p.Digest || n != p.Size { - return fmt.Errorf("the part %s holds %d bytes of digest %s, not its own %d: nothing of it is sent", p.Digest, n, got, p.Size) - } - return nil -} - -func sendPart(ctx context.Context, call Call, seat, transfer string, p Part) (int, error) { - r, err := p.Open() - if err != nil { - return 0, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - defer r.Close() - buf := make([]byte, MaxChunk) - // A chunk is sent once the next read says whether it is the last, so the last is always marked: an empty part is - // one empty chunk, marked last. - var prev []byte - sent := 0 - send := func(data []byte, last bool) error { - if err := sendChunk(call, seat, transfer, p.Digest, sent, last, data); err != nil { - return err - } - sent++ - return nil - } - for { - if err := ctx.Err(); err != nil { - return sent, err - } - n, err := io.ReadFull(r, buf) - end := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) - if err != nil && !end { - return sent, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - if end && n == 0 { - if prev == nil { - prev = []byte{} - } - return sent, send(prev, true) - } - if prev != nil { - if err := send(prev, false); err != nil { - return sent, err - } - } - prev = append([]byte(nil), buf[:n]...) - if end { - return sent, send(prev, true) - } - } -} - -func sendChunk(call Call, seat, transfer, part string, sequence int, last bool, data []byte) error { - raw, err := call("seat:"+seat+"."+registry.VerbPublishPart, registry.PublishPartInput{Transfer: transfer, Part: part, - Sequence: int64(sequence), Last: last, Data: base64.StdEncoding.EncodeToString(data)}) - if err != nil { - return fmt.Errorf("%s's publish-part %d of %s: %w", seat, sequence, part, err) - } - var a registry.PublishPartAnswer - if err := json.Unmarshal(raw, &a); err != nil { - return fmt.Errorf("%s's answer to publish-part: %w", seat, err) - } - return nil -} -- 2.54.0 From 8d883c373ee1d9cde7c69e40f6d7e87c45057d28 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:13:47 +0100 Subject: [PATCH 035/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20the=20receiving?= =?UTF-8?q?=20side=20of=20a=20file=20in=20parts=20is=20seat-neutral=20runt?= =?UTF-8?q?ime=20beside=20its=20sender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parts.Receiver takes a transfer's chunks into the holder's own objects under the caller's prefix, checks each whole part's hash, drops a transfer left idle and tells the caller the objects' fill; the opening's shape, the check before storing, the storing and the saying of a refusal are the caller's, so it names no seat, verb or seat type and distribution and the forge's two package seats bind it to the seats they hold. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/packages_list_test.go | 2 +- go/parts/receive.go | 375 +++++++++++++++++++++++++++++++++++++++ go/parts/receive_test.go | 220 +++++++++++++++++++++++ 3 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 go/parts/receive.go create mode 100644 go/parts/receive_test.go diff --git a/go/packages_list_test.go b/go/packages_list_test.go index d0f3b04..9540ac8 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -25,7 +25,7 @@ import ( var meshSDKPackages = []string{ ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", - "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", "publish", + "objectlink", "outward", "overlay", "pernode", "process", "provider", "parts", "provider/harness", "provisions", "publish", "sdkversion", "seal", "seatdef", "testbus", "views", } diff --git a/go/parts/receive.go b/go/parts/receive.go new file mode 100644 index 0000000..d2ac237 --- /dev/null +++ b/go/parts/receive.go @@ -0,0 +1,375 @@ +package parts + +// The receiving side of a file in parts (novox/hq ADR 0611, ADR 0627 §2, design 51 *Publishing*): a holder opens a +// transfer naming every part, takes the chunks of the parts it lacks into its own objects, checks each whole part's +// hash, drops a transfer left idle and says when its objects fill. Like the sender it names no seat, no verb and no +// seat's type: the caller's own shape of an opening rides along as M, its objects and its state are its own, and the +// checks before storing, the storing and the saying of a refusal are the caller's functions. Each holder binds it to +// the seat it holds. + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" +) + +// The bounds design 51 gives the receiving side; a Receiver's own fields override them. +const ( + // MaxReceived is the most bytes one chunk may carry when it arrives. + MaxReceived = 1 << 20 + // IdleAfter is how long a transfer with no chunk stays open. + IdleAfter = 24 * time.Hour + // FullPercent is the fill of the objects past which the receiver says they fill. + FullPercent = 80 +) + +// Want is one part an opening names: its digest (sha256:) and its size. +type Want struct { + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// Transfer is one open transfer, kept in the caller's state; its chunks are objects of the caller's object store. Shape +// is the caller's own body of the opening, kept as it came. +type Transfer[M any] struct { + ID string `json:"id"` + Opener string `json:"opener"` + Parts []Want `json:"parts"` + // Held are the parts whose every chunk arrived and hashed right, or that the holder held at the open; Chunks the + // chunks taken per part. + Held []string `json:"held,omitempty"` + Chunks map[string]int `json:"chunks,omitempty"` + Opened time.Time `json:"opened"` + Last time.Time `json:"last"` + Shape M `json:"shape"` +} + +// Lacks is the digests of the parts the transfer does not hold yet, sorted. +func (t Transfer[M]) Lacks() []string { + var out []string + for _, p := range t.Parts { + if !slices.Contains(t.Held, p.Digest) && !slices.Contains(out, p.Digest) { + out = append(out, p.Digest) + } + } + sort.Strings(out) + return out +} + +// Taken says whether any chunk of the part was taken in this transfer (a part held at the open was not). +func (t Transfer[M]) Taken(digest string) bool { + _, ok := t.Chunks[digest] + return ok +} + +// Objects is the holder's own object store the chunks go into. +type Objects interface { + Put(name string, data []byte) error + Get(name string) ([]byte, error) + Delete(name string) error + // Used is the bytes the store holds. + Used() (uint64, error) +} + +// Transfers is the holder's own state of open transfers, keyed by Transfer.ID. +type Transfers[M any] interface { + Get(id string) (Transfer[M], bool, error) + Put(t Transfer[M]) error + Delete(id string) error + List() ([]Transfer[M], error) +} + +// Received is the receiver's answer to an open or a chunk: the parts still lacked, and whether the transfer ended +// (stored or refused). +type Received struct { + ID string + Lacks []string + Ended bool +} + +// Receiver takes transfers in parts into a holder's own objects. +type Receiver[M any] struct { + Objects Objects + Transfers Transfers[M] + // Prefix starts the name of every chunk object, so two receivers can share one object store. + Prefix string + // Cap is the bytes the objects may hold; an opening that would pass it is refused. Required. + Cap uint64 + // MaxChunk, Idle and Full override MaxReceived, IdleAfter and FullPercent when set. + MaxChunk int + Idle time.Duration + Full uint64 + // Holds says the holder already holds a part, which then counts as held at the open and is not sent. Optional. + Holds func(t Transfer[M], digest string) bool + // Check is the caller's check of a whole transfer before anything is stored; its error is the refusal's reason. + // Optional. + Check func(t Transfer[M]) error + // Store stores a whole, checked transfer: the parts taken in it by digest (not those held at the open). It says + // the outcome itself; its error is answered as it is. + Store func(t Transfer[M], parts map[string][]byte) error + // Refuse says a refusal by the receiver (the cap, the caller's check, a part it cannot read back). Its own error is + // joined to the refusal's. + Refuse func(t Transfer[M], reason string) error + // Filled is told the objects' fill after every change: used, the cap and whether it is past Full percent. Optional. + Filled func(used, cap uint64, full bool) + Now func() time.Time +} + +func (r *Receiver[M]) now() time.Time { + if r.Now != nil { + return r.Now().UTC() + } + return time.Now().UTC() +} + +func (r *Receiver[M]) maxChunk() int { + if r.MaxChunk > 0 { + return r.MaxChunk + } + return MaxReceived +} + +func (r *Receiver[M]) idle() time.Duration { + if r.Idle > 0 { + return r.Idle + } + return IdleAfter +} + +func (r *Receiver[M]) fullPct() uint64 { + if r.Full > 0 { + return r.Full + } + return FullPercent +} + +// Open opens a transfer for opener, or opens it again: a transfer already open answers what it lacks, so a cut transfer +// goes on from there, and only its opener may open it again. A new transfer that would pass the cap is refused; parts +// the holder holds count as held; when nothing is lacked the transfer ends at once. +func (r *Receiver[M]) Open(id, opener string, parts []Want, shape M) (Received, error) { + switch { + case strings.TrimSpace(id) == "" || strings.ContainsAny(id, "/. *>"): + return Received{}, fmt.Errorf("transfer %q is no transfer id", id) + case len(parts) == 0: + return Received{}, errors.New("no parts") + } + for _, p := range parts { + if !strings.HasPrefix(p.Digest, "sha256:") || len(p.Digest) != len("sha256:")+64 { + return Received{}, fmt.Errorf("part digest %q is no sha256 digest", p.Digest) + } + } + if t, found, err := r.Transfers.Get(id); err != nil { + return Received{}, err + } else if found { + if t.Opener != opener { + return Received{}, fmt.Errorf("refused for %s: transfer %s was opened by another caller", opener, id) + } + return Received{ID: id, Lacks: t.Lacks()}, nil + } + t := Transfer[M]{ID: id, Opener: opener, Parts: parts, Chunks: map[string]int{}, Opened: r.now(), Last: r.now(), Shape: shape} + used, err := r.Objects.Used() + if err != nil { + return Received{}, fmt.Errorf("the objects' fill cannot be read: %w", err) + } + var size uint64 + for _, p := range parts { + size += uint64(max(p.Size, 0)) + } + if used+size > r.Cap { + return Received{ID: id, Ended: true}, r.refuse(t, fmt.Sprintf("the objects are at their cap of %d bytes (%d held, %d asked)", r.Cap, used, size)) + } + if r.Holds != nil { + for _, p := range parts { + if r.Holds(t, p.Digest) { + t.Held = append(t.Held, p.Digest) + } + } + } + if err := r.Transfers.Put(t); err != nil { + return Received{}, fmt.Errorf("transfer %s cannot be kept: %w", id, err) + } + r.fill() + if len(t.Lacks()) == 0 { + return Received{ID: id, Ended: true}, r.finish(t) + } + return Received{ID: id, Lacks: t.Lacks()}, nil +} + +// Take takes one chunk of a part from the transfer's opener: chunks of a part arrive in sequence from 0, the last +// marked; the whole part must hash to its digest, else its chunks are dropped and the part is lacked again. Belongs, +// when given, refuses a transfer the caller's own shape says is not this call's (another seat's, say). When the last +// lacked part arrives the transfer ends: the caller's check, then its store. +func (r *Receiver[M]) Take(id, opener, digest string, sequence int, last bool, data []byte, belongs func(Transfer[M]) error) (Received, error) { + t, found, err := r.Transfers.Get(id) + switch { + case err != nil: + return Received{}, err + case !found: + return Received{}, fmt.Errorf("transfer %s is not open (dropped after %s with no chunk, or never opened)", id, r.idle()) + case t.Opener != opener: + return Received{}, fmt.Errorf("refused for %s: transfer %s was opened by another caller", opener, id) + } + if belongs != nil { + if err := belongs(t); err != nil { + return Received{}, err + } + } + switch { + case len(data) > r.maxChunk(): + return Received{}, fmt.Errorf("a chunk of %d bytes is over the %d a chunk may carry", len(data), r.maxChunk()) + case !slices.ContainsFunc(t.Parts, func(p Want) bool { return p.Digest == digest }): + return Received{}, fmt.Errorf("transfer %s names no part %s", id, digest) + case slices.Contains(t.Held, digest): + return Received{ID: id, Lacks: t.Lacks()}, nil + case sequence != t.Chunks[digest]: + return Received{}, fmt.Errorf("part %s of transfer %s waits for chunk %d, not %d", digest, id, t.Chunks[digest], sequence) + } + if err := r.Objects.Put(r.chunkName(id, digest, sequence), data); err != nil { + return Received{}, fmt.Errorf("the objects did not take the chunk: %w", err) + } + t.Chunks[digest] = sequence + 1 + t.Last = r.now() + if last { + whole, err := r.part(t, digest) + if err != nil { + return Received{}, err + } + if got := "sha256:" + sum(whole); got != digest { + r.dropChunks(t, digest) + delete(t.Chunks, digest) + _ = r.Transfers.Put(t) + r.fill() + return Received{}, fmt.Errorf("refused: the bytes of part %s hash to %s; send the part again", digest, got) + } + t.Held = append(t.Held, digest) + } + if err := r.Transfers.Put(t); err != nil { + return Received{}, err + } + r.fill() + if len(t.Lacks()) == 0 { + return Received{ID: id, Ended: true}, r.finish(t) + } + return Received{ID: id, Lacks: t.Lacks()}, nil +} + +// finish checks and stores a whole transfer; its chunks and its key go either way. +func (r *Receiver[M]) finish(t Transfer[M]) error { + defer func() { + for _, p := range t.Parts { + r.dropChunks(t, p.Digest) + } + _ = r.Transfers.Delete(t.ID) + r.fill() + }() + if r.Check != nil { + if err := r.Check(t); err != nil { + return r.refuse(t, err.Error()) + } + } + parts := map[string][]byte{} + for _, p := range t.Parts { + if !t.Taken(p.Digest) { + continue // held by the holder already + } + b, err := r.part(t, p.Digest) + if err != nil { + return r.refuse(t, "part "+p.Digest+" cannot be read from the objects: "+err.Error()) + } + parts[p.Digest] = b + } + return r.Store(t, parts) +} + +func (r *Receiver[M]) refuse(t Transfer[M], reason string) error { + err := errors.New("refused: " + reason) + if r.Refuse != nil { + if serr := r.Refuse(t, reason); serr != nil { + return fmt.Errorf("%w; and the refusal could not be said: %w", err, serr) + } + } + return err +} + +func (r *Receiver[M]) part(t Transfer[M], digest string) ([]byte, error) { + var buf bytes.Buffer + for seq := 0; seq < t.Chunks[digest]; seq++ { + b, err := r.Objects.Get(r.chunkName(t.ID, digest, seq)) + if err != nil { + return nil, err + } + buf.Write(b) + } + return buf.Bytes(), nil +} + +func (r *Receiver[M]) dropChunks(t Transfer[M], digest string) { + for seq := 0; seq < t.Chunks[digest]; seq++ { + _ = r.Objects.Delete(r.chunkName(t.ID, digest, seq)) + } +} + +func (r *Receiver[M]) chunkName(id, digest string, seq int) string { + return fmt.Sprintf("%s%s/%s/%d", r.Prefix, id, strings.TrimPrefix(digest, "sha256:"), seq) +} + +// fill tells Filled the objects' fill. +func (r *Receiver[M]) fill() { + if r.Filled == nil || r.Cap == 0 { + return + } + used, err := r.Objects.Used() + if err != nil { + return + } + r.Filled(used, r.Cap, used*100 >= r.Cap*r.fullPct()) +} + +// Drop drops a transfer, by hand or because it was idle: its chunks and its key. +func (r *Receiver[M]) Drop(id string) (Transfer[M], error) { + t, found, err := r.Transfers.Get(id) + if err != nil { + return Transfer[M]{}, err + } + if !found { + return Transfer[M]{}, fmt.Errorf("transfer %s is not open", id) + } + for _, p := range t.Parts { + r.dropChunks(t, p.Digest) + } + if err := r.Transfers.Delete(id); err != nil { + return Transfer[M]{}, err + } + r.fill() + return t, nil +} + +// DropIdle drops every transfer with no chunk for Idle and answers their ids. It runs when its caller calls it (on an +// open, on a listing), never on a timer. +func (r *Receiver[M]) DropIdle() ([]string, error) { + all, err := r.Transfers.List() + if err != nil { + return nil, err + } + var out []string + for _, t := range all { + if r.now().Sub(t.Last) >= r.idle() { + if _, err := r.Drop(t.ID); err == nil { + out = append(out, t.ID) + } + } + } + sort.Strings(out) + return out, nil +} + +func sum(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} diff --git a/go/parts/receive_test.go b/go/parts/receive_test.go new file mode 100644 index 0000000..0f355bf --- /dev/null +++ b/go/parts/receive_test.go @@ -0,0 +1,220 @@ +package parts + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +type memObjects map[string][]byte + +func (m memObjects) Put(n string, d []byte) error { m[n] = append([]byte(nil), d...); return nil } +func (m memObjects) Get(n string) ([]byte, error) { + if d, ok := m[n]; ok { + return d, nil + } + return nil, errors.New("no object " + n) +} +func (m memObjects) Delete(n string) error { delete(m, n); return nil } +func (m memObjects) Used() (uint64, error) { + var u uint64 + for _, d := range m { + u += uint64(len(d)) + } + return u, nil +} + +type memTransfers[M any] map[string]Transfer[M] + +func (m memTransfers[M]) Get(id string) (Transfer[M], bool, error) { t, ok := m[id]; return t, ok, nil } +func (m memTransfers[M]) Put(t Transfer[M]) error { m[t.ID] = t; return nil } +func (m memTransfers[M]) Delete(id string) error { delete(m, id); return nil } +func (m memTransfers[M]) List() ([]Transfer[M], error) { + var out []Transfer[M] + for _, t := range m { + out = append(out, t) + } + return out, nil +} + +// Two shapes of an opening, as two different seats' holders would keep them. +type packageShape struct{ Name, Version string } +type imageShape struct { + Repository string + Layers int +} + +type ended struct { + id, outcome string + parts map[string][]byte +} + +func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) } + +func receiverOf[M any](objects memObjects, prefix string, now *time.Time, log *[]ended, check func(Transfer[M]) error) *Receiver[M] { + return &Receiver[M]{Objects: objects, Transfers: memTransfers[M]{}, Prefix: prefix, Cap: 1 << 20, MaxChunk: 1000, + Check: check, Now: func() time.Time { return *now }, + Store: func(t Transfer[M], parts map[string][]byte) error { + *log = append(*log, ended{t.ID, "stored", parts}) + return nil + }, + Refuse: func(t Transfer[M], reason string) error { + *log = append(*log, ended{t.ID, "refused: " + reason, nil}) + return nil + }} +} + +// TestAHolderTakesAFileInParts runs the receiving side for two holders with two shapes and two prefixes on one object +// store (ADR 0627 §2): neither names a seat; the shapes, the checks and the saying are each caller's own. +func TestAHolderTakesAFileInParts(t *testing.T) { + objects := memObjects{} + now := time.Unix(1000, 0) + var pkgLog, imgLog []ended + pkg := receiverOf(objects, "npm/", &now, &pkgLog, func(t Transfer[packageShape]) error { + if t.Shape.Version == "" { + return errors.New("the builder did not number it") + } + return nil + }) + img := receiverOf[imageShape](objects, "image/", &now, &imgLog, nil) + blob := []byte(strings.Repeat("a", 1500)) + part := Want{Digest: digestOf(blob), Size: int64(len(blob))} + layer := []byte("layer") + held := Want{Digest: digestOf(layer), Size: int64(len(layer))} + img.Holds = func(_ Transfer[imageShape], d string) bool { return d == held.Digest } + + t.Run("two holders, one transfer id, two shapes", func(t *testing.T) { + a, err := pkg.Open("t1", "builder", []Want{part}, packageShape{"m/program", "0.1.4"}) + if err != nil || !reflect.DeepEqual(a.Lacks, []string{part.Digest}) { + t.Fatalf("pkg open = %+v, %v", a, err) + } + b, err := img.Open("t1", "builder", []Want{part, held}, imageShape{"m/app", 2}) + if err != nil || !reflect.DeepEqual(b.Lacks, []string{part.Digest}) { + t.Fatalf("img open = %+v, %v: a part the holder holds is not lacked", b, err) + } + for _, r := range []func(int, bool, []byte) (Received, error){ + func(s int, l bool, d []byte) (Received, error) { + return pkg.Take("t1", "builder", part.Digest, s, l, d, nil) + }, + func(s int, l bool, d []byte) (Received, error) { + return img.Take("t1", "builder", part.Digest, s, l, d, nil) + }, + } { + if _, err := r(0, false, blob[:1000]); err != nil { + t.Fatal(err) + } + } + // A cut transfer opened again goes on from what is held; another caller may not. + if a, _ := pkg.Open("t1", "builder", []Want{part}, packageShape{}); !reflect.DeepEqual(a.Lacks, []string{part.Digest}) { + t.Errorf("reopen = %+v", a) + } + if _, err := pkg.Open("t1", "other", []Want{part}, packageShape{}); err == nil || !strings.Contains(err.Error(), "another caller") { + t.Errorf("reopen by another = %v", err) + } + if _, err := pkg.Take("t1", "builder", part.Digest, 0, false, blob[:1000], nil); err == nil || !strings.Contains(err.Error(), "waits for chunk 1") { + t.Errorf("a chunk out of sequence = %v", err) + } + a, err = pkg.Take("t1", "builder", part.Digest, 1, true, blob[1000:], nil) + if err != nil || !a.Ended || len(pkgLog) != 1 || pkgLog[0].outcome != "stored" || string(pkgLog[0].parts[part.Digest]) != string(blob) { + t.Fatalf("pkg end = %+v, %v, %+v", a, err, pkgLog) + } + // The image holder's chunks stood apart under its own prefix and are still there. + if _, ok := objects["image/t1/"+strings.TrimPrefix(part.Digest, "sha256:")+"/0"]; !ok || len(objects) != 1 { + t.Fatalf("objects after pkg's end = %v keys, want only the image holder's chunk", len(objects)) + } + b, err = img.Take("t1", "builder", part.Digest, 1, true, blob[1000:], func(t Transfer[imageShape]) error { return nil }) + if err != nil || !b.Ended || len(imgLog) != 1 || len(imgLog[0].parts) != 1 { + t.Fatalf("img end = %+v, %v, %+v: only the part taken is stored", b, err, imgLog) + } + if len(objects) != 0 { + t.Errorf("objects after both ends = %d, want none", len(objects)) + } + }) + + t.Run("refusals", func(t *testing.T) { + for _, c := range []struct { + name, want string + do func() error + }{ + {"no transfer id", "no transfer id", func() error { _, err := pkg.Open("a/b", "builder", []Want{part}, packageShape{}); return err }}, + {"no sha256 digest", "no sha256 digest", func() error { + _, err := pkg.Open("t", "builder", []Want{{Digest: "md5:x"}}, packageShape{}) + return err + }}, + {"another caller's chunk", "another caller", func() error { + _, _ = pkg.Open("t2", "builder", []Want{part}, packageShape{"m", "1"}) + _, err := pkg.Take("t2", "other", part.Digest, 0, true, blob, nil) + return err + }}, + {"a chunk over the bound", "over the 1000", func() error { + _, err := pkg.Take("t2", "builder", part.Digest, 0, false, make([]byte, 1001), nil) + return err + }}, + {"a part the transfer does not name", "names no part", func() error { + _, err := pkg.Take("t2", "builder", held.Digest, 0, true, layer, nil) + return err + }}, + {"the caller's own belongs", "not this seat's", func() error { + _, err := pkg.Take("t2", "builder", part.Digest, 0, true, blob, func(Transfer[packageShape]) error { return errors.New("not this seat's") }) + return err + }}, + {"bytes that do not hash", "send the part again", func() error { + _, err := pkg.Take("t2", "builder", part.Digest, 0, true, []byte("other"), nil) + return err + }}, + {"the caller's check", "refused: the builder did not number it", func() error { + _, _ = pkg.Open("t3", "builder", []Want{part}, packageShape{Name: "m"}) + _, _ = pkg.Take("t3", "builder", part.Digest, 0, false, blob[:1000], nil) + _, err := pkg.Take("t3", "builder", part.Digest, 1, true, blob[1000:], nil) + return err + }}, + {"the cap", "at their cap", func() error { + pkg.Cap = 100 + defer func() { pkg.Cap = 1 << 20 }() + _, err := pkg.Open("t4", "builder", []Want{part}, packageShape{}) + return err + }}, + } { + if err := c.do(); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: err = %v, want %q", c.name, err, c.want) + } + } + if got := pkgLog[len(pkgLog)-1].outcome; !strings.HasPrefix(got, "refused: the objects are at their cap") { + t.Errorf("the cap's refusal said %q", got) + } + if got := pkgLog[len(pkgLog)-2].outcome; got != "refused: the builder did not number it" { + t.Errorf("the check's refusal said %q", got) + } + // A part that did not hash is lacked again, its chunk gone. + if a, _ := pkg.Open("t2", "builder", []Want{part}, packageShape{}); !reflect.DeepEqual(a.Lacks, []string{part.Digest}) { + t.Errorf("after a bad hash = %+v", a) + } + }) + + t.Run("idle drop and the fill", func(t *testing.T) { + var fills []bool + pkg.Filled = func(used, cap uint64, full bool) { fills = append(fills, full) } + pkg.Cap, pkg.Full = 1500, 60 + defer func() { pkg.Cap, pkg.Full, pkg.Filled = 1<<20, 0, nil }() + _, _ = pkg.Open("t5", "builder", []Want{part}, packageShape{"m", "1"}) + _, _ = pkg.Take("t5", "builder", part.Digest, 0, false, blob[:1000], nil) + if len(fills) == 0 || !fills[len(fills)-1] { + t.Errorf("fills = %v, want full at 1000 of 1500 bytes (60 percent)", fills) + } + now = now.Add(IdleAfter) + dropped, err := pkg.DropIdle() + if err != nil || !reflect.DeepEqual(dropped, []string{"t2", "t5"}) { + t.Errorf("DropIdle = %v, %v", dropped, err) + } + if fills[len(fills)-1] || len(objects) != 0 { + t.Errorf("after the drop: full %v, %d objects", fills[len(fills)-1], len(objects)) + } + if _, err := pkg.Take("t5", "builder", part.Digest, 1, true, blob[1000:], nil); err == nil || !strings.Contains(err.Error(), "not open") { + t.Errorf("a chunk after the drop = %v", err) + } + }) +} -- 2.54.0 From 7efb17386d6f8fcc5bd35e40ed1a1f2f2ac545d4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:14:43 +0100 Subject: [PATCH 036/144] Build log: a build narrates between its start and its end (build.said) Its module, fingerprint, line and why, as design 51 has a build narrate its publish; a start or end carries no line (A3g's finding). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/history_records.go | 19 ++++++++++++++++++- go/delivery/published_packages_test.go | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/go/delivery/history_records.go b/go/delivery/history_records.go index 1ce6146..8f8f23a 100644 --- a/go/delivery/history_records.go +++ b/go/delivery/history_records.go @@ -132,6 +132,10 @@ const ( BuildLogRead = BuilderModule + "." + BuilderLog BuildStartedEvent = "build.started" BuildEndedEvent = "build.ended" + // BuildSaidEvent is a build's narration between its start and its end (design 51: a build narrates its publish, + // asked, waiting on a seat, stored, refused, at least every five minutes while it waits): its module, fingerprint, + // line and why. + BuildSaidEvent = "build.said" // BuilderLogDays is how long the log keeps its entries. BuilderLogDays = 30 // BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its @@ -175,6 +179,9 @@ type BuildLogEntry struct { // sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer // keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's. Imports []string `json:"imports,omitempty"` + // Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone. + Line string `json:"line,omitempty"` + Why string `json:"why,omitempty"` } // BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held @@ -214,8 +221,18 @@ func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry { func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) { e.V = Version switch { + case e.Event == BuildSaidEvent: + switch { + case e.Module == "" || e.FP == "" || e.At.IsZero() || e.Line == "": + return nil, fmt.Errorf("delivery: a build's narration names its module, fingerprint, line and time") + case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0: + return nil, fmt.Errorf("delivery: a build's narration names no outcome, manifest, base, artifact or import") + } + return json.Marshal(e) case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent: - return nil, fmt.Errorf("delivery: a build log entry is %s or %s, not %q", BuildStartedEvent, BuildEndedEvent, e.Event) + return nil, fmt.Errorf("delivery: a build log entry is %s, %s or %s, not %q", BuildStartedEvent, BuildSaidEvent, BuildEndedEvent, e.Event) + case e.Line != "" || e.Why != "": + return nil, fmt.Errorf("delivery: a build's start or end carries no narration's line") case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "": return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time") case e.Event == BuildEndedEvent && e.Outcome == "": diff --git a/go/delivery/published_packages_test.go b/go/delivery/published_packages_test.go index a8b2240..bd44b58 100644 --- a/go/delivery/published_packages_test.go +++ b/go/delivery/published_packages_test.go @@ -75,6 +75,28 @@ func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) { if _, err := EncodeBuildLogEntry(end); err == nil { t.Error("unsorted imports encoded") } + // A build narrates between its start and its end (build.said): its module, fingerprint, line and why, nothing of an + // end; a start or end carries no line. + said := BuildLogEntry{Event: BuildSaidEvent, Module: "netcheck", FP: "src2:x", At: good.At, + Line: "waiting on container-image-registry", Why: "waiting"} + if raw, err := EncodeBuildLogEntry(said); err != nil { + t.Errorf("a narration refused: %v", err) + } else if e, err := DecodeBuildLogEntry(raw); err != nil || e.Line != said.Line || e.Why != "waiting" { + t.Errorf("a narration's round trip: %+v, %v", e, err) + } + for name, mutate := range map[string]func(e *BuildLogEntry){ + "a narration with no line": func(e *BuildLogEntry) { e.Line = "" }, + "a narration with an outcome": func(e *BuildLogEntry) { e.Outcome = "done" }, + "an end with a line": func(e *BuildLogEntry) { + e.Event, e.Outcome, e.Node, e.Repository, e.Commit = BuildEndedEvent, "done", "ace", "novox/mesh-catalog", commit + }, + } { + e := said + mutate(&e) + if _, err := EncodeBuildLogEntry(e); err == nil { + t.Errorf("%s encoded", name) + } + } var answer BuildAnswer if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"]}`), &answer); err != nil || len(answer.Imports) != 1 { t.Fatalf("build answer imports: %v, %v", err, answer.Imports) -- 2.54.0 From 3249b30e8d1cbda6e18a4959158b07474b7ef47b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:17:26 +0100 Subject: [PATCH 037/144] SD-M1: a holder's event is under its module's subjects, or its own stream's when a family keeps it The definition reader refuses any other subject, and a mesh.stream. subject no family names (A2c's finding: the checker's events in its own stream were written as mesh.mod.). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatdef/seatdef.go | 10 +++++++++- go/seatdef/seatdef_test.go | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 50f0da4..f1336f3 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -389,8 +389,16 @@ func (d Definition) check() Problems { if !eventName.MatchString(e.Name) { ps = append(ps, Problem{where + ".name", "missing, or not lower-case words joined by dots"}) } - if e.Subject == "" { + // A holder's event is published under the holder's own module subjects, or, when it is kept in a stream of the + // holder's own module (its family), under that stream's (buscompose.ModuleStreamSubject). + own, stream := "mesh.mod..event."+e.Name, "mesh.stream..event."+e.Name + switch { + case e.Subject == "": ps = append(ps, Problem{where + ".subject", "missing"}) + case e.Subject == stream && e.Family == "": + ps = append(ps, Problem{where + ".subject", "a subject of the holder's own stream, and no family names that stream"}) + case e.Subject != own && e.Subject != stream: + ps = append(ps, Problem{where + ".subject", fmt.Sprintf("not %s, nor %s for an event kept in the holder's own stream", own, stream)}) } } for i, r := range d.Records { diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index 7f27a8f..e51f316 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -41,6 +41,12 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { `"max-age-days":30`, 1), "family.max-age-days: missing, or not 7"}, {"a family with a cap of its own", strings.Replace(whole, `"max-age-days":7`, `"max-age-days":7,"max-mib":256`, 1), "max-mib: a field the format does not know"}, + {"a holder's event on its own stream", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ + `"subject":"mesh.stream..event.verdict","family":"checker.asks"}],"family":`, 1), ""}, + {"a holder's event on its own stream with no family", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ + `"subject":"mesh.stream..event.verdict"}],"family":`, 1), "no family names that stream"}, + {"a holder's event on a subject of no module", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ + `"subject":"mesh.event.verdict"}],"family":`, 1), "holder-events[0].subject: not mesh.mod..event.verdict"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, {"records at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, -- 2.54.0 From de3af0cc654d3f4221949c86fcc3f59312dacce0 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:18:32 +0100 Subject: [PATCH 038/144] buscalls.Unwrap: one reading of a tool's answer whatever wraps it A JSON string, the content list, {output, ok, answer} or the tool runner's {result, error}: mesh-config's unwrapAnswer and the distribution's copy take it, and views.AskVia reads through it. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/buscalls/answer.go | 50 ++++++++++++++++++++++++++++++++++++++++ go/packages_list_test.go | 19 +++++++++++++++ go/views/follow.go | 17 ++++++-------- 3 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 go/buscalls/answer.go diff --git a/go/buscalls/answer.go b/go/buscalls/answer.go new file mode 100644 index 0000000..afefd96 --- /dev/null +++ b/go/buscalls/answer.go @@ -0,0 +1,50 @@ +package buscalls + +import "encoding/json" + +// Unwrap reads a tool's answer whatever wraps it on its way from the tool runner (the lead's ruling 2026-11-04 13:12: +// one reading, not a copy per module): a JSON string holding the answer; the protocol's content list, whose first +// text is the answer and whose isError says it failed; the {output, ok, answer} form; the tool runner's {result, +// error} envelope, whose error is the call's. It answers the answer itself, the words said with it (the output, the +// error, or the answer's own text), and whether the tool says it succeeded. A bare answer is itself, succeeded. +func Unwrap(raw json.RawMessage) (answer json.RawMessage, said string, ok bool) { + var s string + if json.Unmarshal(raw, &s) == nil { + return Unwrap(json.RawMessage(s)) + } + var m map[string]json.RawMessage + if json.Unmarshal(raw, &m) != nil { + return raw, string(raw), true + } + if content, has := m["content"]; has { + var items []struct { + Text string `json:"text"` + } + var e struct { + IsError bool `json:"isError"` + } + _ = json.Unmarshal(raw, &e) + if json.Unmarshal(content, &items) == nil && len(items) > 0 { + inner, out, ok := Unwrap(json.RawMessage(items[0].Text)) + return inner, out, ok && !e.IsError + } + } + var v struct { + Output string `json:"output"` + OK *bool `json:"ok"` + Answer json.RawMessage `json:"answer"` + Result json.RawMessage `json:"result"` + Error string `json:"error"` + } + if json.Unmarshal(raw, &v) == nil { + switch { + case v.OK != nil: + return v.Answer, v.Output, *v.OK + case v.Error != "": + return nil, v.Error, false + case len(v.Result) > 0: + return Unwrap(v.Result) + } + } + return raw, string(raw), true +} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index a012bb4..633ea39 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -192,6 +192,25 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { if string(entry) != `{"instance":"listing/a17","sequence":3}` { t.Errorf("an index entry is %s", entry) } + // A tool's answer reads the same whatever wraps it (buscalls.Unwrap, the lead's ruling 2026-11-04 13:12). + for _, c := range []struct { + name, raw, answer, said string + ok bool + }{ + {"bare", `{"a":1}`, `{"a":1}`, `{"a":1}`, true}, + {"a JSON string", `"{\"a\":1}"`, `{"a":1}`, `{"a":1}`, true}, + {"the content list", `{"content":[{"type":"text","text":"{\"a\":1}"}]}`, `{"a":1}`, `{"a":1}`, true}, + {"the content list, failed", `{"content":[{"type":"text","text":"no"}],"isError":true}`, `no`, `no`, false}, + {"output, ok, answer", `{"output":"done","ok":true,"answer":{"a":1}}`, `{"a":1}`, "done", true}, + {"output, not ok", `{"output":"refused","ok":false}`, ``, "refused", false}, + {"the runner's result", `{"result":{"a":1}}`, `{"a":1}`, `{"a":1}`, true}, + {"the runner's error", `{"error":"no such tool"}`, ``, "no such tool", false}, + } { + answer, said, ok := buscalls.Unwrap(json.RawMessage(c.raw)) + if string(answer) != c.answer || said != c.said || ok != c.ok { + t.Errorf("%s: unwrapped %s, %q, %v; want %s, %q, %v", c.name, answer, said, ok, c.answer, c.said, c.ok) + } + } // 1966/AC5: an apply naming the tool call it serves, and its answer with the state behind, round-trip. for _, c := range []struct { name string diff --git a/go/views/follow.go b/go/views/follow.go index 5c005f7..6192bf6 100644 --- a/go/views/follow.go +++ b/go/views/follow.go @@ -11,6 +11,8 @@ import ( "time" "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/buscalls" ) // Follower is a reader's picture of one state of an owner (ADR 0615 §4 to §6): it asks the owner's first-view tool at @@ -353,7 +355,7 @@ func (f *Follower) Gap(ctx context.Context, why string) error { } // AskVia is a Follower's Ask through a call of the owner's tool (process.Conn.Call, or stdio.Ask through the tool -// runner), decoding its answer: bare, or in the tool runner's envelope {result, error}, whose error is the ask's. +// runner), decoding its answer whatever wraps it (buscalls.Unwrap): an error the tool says is the ask's. func AskVia(call func(key string, body any) (json.RawMessage, error), tool string) func(context.Context, string) (Answer, error) { return func(_ context.Context, after string) (Answer, error) { body := map[string]any{} @@ -364,16 +366,11 @@ func AskVia(call func(key string, body any) (json.RawMessage, error), tool strin if err != nil { return Answer{}, err } - var wrapped struct { - Result json.RawMessage `json:"result"` - Error string `json:"error"` - } - if json.Unmarshal(raw, &wrapped) == nil && (len(wrapped.Result) > 0 || wrapped.Error != "") { - if wrapped.Error != "" { - return Answer{}, fmt.Errorf("%s answered: %s", tool, wrapped.Error) - } - raw = wrapped.Result + answer, said, ok := buscalls.Unwrap(raw) + if !ok { + return Answer{}, fmt.Errorf("%s answered: %s", tool, said) } + raw = answer return DecodeAnswer(raw) } } -- 2.54.0 From d15c1bc8147c7f97bc735513a1907515a1669460 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:20:35 +0100 Subject: [PATCH 039/144] =?UTF-8?q?ADR=200625=20=C2=A73,=20ADR=200627=20?= =?UTF-8?q?=C2=A74:=20mesh-sdk=20imports=20the=20seat=20set=20in=20catalog?= =?UTF-8?q?ue=20alone,=20and=20names=20no=20client=20seat=20outside=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buscompose, delivery's builder and step asks, handacts, health and provisions take their seat names from catalogue. The package-list test refuses any other contracts import, the client seats' names outside catalogue, and the moved packages (asks, asker, publish) by name; what the group's join still moves (asks, asker, the vault module's name in delivery and provisions) is listed and only shrinks. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/buscompose/users.go | 6 ++-- go/catalogue/artifacts.go | 23 +++++++++++-- go/delivery/builder.go | 5 ++- go/delivery/stepasks.go | 5 ++- go/handacts/handacts.go | 4 +-- go/health/findings.go | 6 ++-- go/packages_list_test.go | 68 ++++++++++++++++++++++++++++++++++++- go/provisions/provisions.go | 3 +- go/testdata/seat-names.txt | 11 +++--- 9 files changed, 106 insertions(+), 25 deletions(-) diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 990da3f..af9e75b 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -1,6 +1,6 @@ package buscompose -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" +import "git.novox.be/novox/mesh-sdk/go/catalogue" // Declared is one module on one node, as composing its authority needs it. type Declared struct { @@ -104,6 +104,4 @@ type ProcessPrincipal struct { } // VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. -// -// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshVault); kept until its importers take it from there. -const VaultSeat = contractseats.MeshVault +const VaultSeat = catalogue.VaultSeat diff --git a/go/catalogue/artifacts.go b/go/catalogue/artifacts.go index 2a4c7d5..f23ce6b 100644 --- a/go/catalogue/artifacts.go +++ b/go/catalogue/artifacts.go @@ -47,7 +47,7 @@ func isRegistryHost(component string) bool { // through container-image-registry or oci-artifact-registry, whose blob it names the way the older form did. func InArtifactStore(reference string) (path string, kept bool) { if seat, rest, ok := InRegistrySeat(reference); ok { - if seat == contractseats.OciArtifactRegistry { + if seat == OciArtifactRegistrySeat { name, digest, _ := strings.Cut(rest, "@") return name + "/blobs/" + digest, true } @@ -58,7 +58,26 @@ func InArtifactStore(reference string) (path string, kept bool) { // RegistrySeats are the seats a reference of the registry form names first, the two that front the artifact store // (novox/hq ADR 0605 §1, §9). -var RegistrySeats = []string{contractseats.ContainerImageRegistry, contractseats.OciArtifactRegistry} +var RegistrySeats = []string{ContainerImageRegistrySeat, OciArtifactRegistrySeat} + +// The four registry seats (novox/hq ADR 0605 §1), named here so the rest of mesh-sdk takes them from the seat set +// through catalogue alone (ADR 0625 §3). +const ( + NpmPackageRegistrySeat = contractseats.NpmPackageRegistry + GoPackageRegistrySeat = contractseats.GoPackageRegistry + ContainerImageRegistrySeat = contractseats.ContainerImageRegistry + OciArtifactRegistrySeat = contractseats.OciArtifactRegistry +) + +// PublishSeats are the seats an artifact is published through (ADR 0605 §1). +var PublishSeats = []string{NpmPackageRegistrySeat, GoPackageRegistrySeat, ContainerImageRegistrySeat, OciArtifactRegistrySeat} + +// The seats of the vault, the provisioner, the hand acts' record and the conditions' keeper, as the seat set names them, +// for the rest of mesh-sdk (ADR 0625 §3). +const ( + VaultSeat = contractseats.MeshVault + ProvisionerSeat = contractseats.MeshProvisioner +) // InRegistrySeat reads a reference in the form a registry seat answers, `//@sha256:`: // the seat it came through and the rest. A seat's name holds no dot or port, so no image of an outside registry reads diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 42bee50..98a35a0 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -6,7 +6,6 @@ import ( "reflect" "strings" - contractseats "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -100,9 +99,9 @@ func (a BuiltArtifact) referenceProblem() string { return "" } if seat, path, ok := catalogue.InRegistrySeat(r); ok { - want := contractseats.ContainerImageRegistry + want := catalogue.ContainerImageRegistrySeat if a.Kind == "bundle" || a.Kind == "archive" { - want = contractseats.OciArtifactRegistry + want = catalogue.OciArtifactRegistrySeat } module, rest, _ := strings.Cut(path, "/") if a.Kind == "package" || seat != want || module == "" || rest != a.Name+"@"+a.Digest { diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index 327e87d..85da720 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -12,7 +12,7 @@ import ( "strings" "time" - contractseats "git.novox.be/novox/mesh-contracts/go/seats" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // **Asks: a build or a check step is a message a build node pulls when it has a free slot** (novox/hq ADR 0525, as @@ -466,8 +466,7 @@ func (c ComposeJob) check(id string) error { } // registrySeats are the seats an artifact is published through (ADR 0605 §1). -var registrySeats = []string{contractseats.NpmPackageRegistry, contractseats.GoPackageRegistry, - contractseats.ContainerImageRegistry, contractseats.OciArtifactRegistry} +var registrySeats = catalogue.PublishSeats func (b BuildJob) check(id string) error { if err := checkName("module", b.Module); err != nil { diff --git a/go/handacts/handacts.go b/go/handacts/handacts.go index 2cc4336..36bc835 100644 --- a/go/handacts/handacts.go +++ b/go/handacts/handacts.go @@ -11,12 +11,12 @@ import ( "strings" "time" - contractseats "git.novox.be/novox/mesh-contracts/go/seats" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The seat, its holder and what it keeps (ADR 0565 clause 10). const ( - Seat = contractseats.MeshHandActs + Seat = catalogue.HandActsSeat Module = "mesh-hand-acts" // ActsLog is the log of every act, kept 400 days; WarrantsState one key `.` per warrant, made once. ActsLog = "acts" diff --git a/go/health/findings.go b/go/health/findings.go index e237636..edafe11 100644 --- a/go/health/findings.go +++ b/go/health/findings.go @@ -11,7 +11,7 @@ import ( "strings" "time" - contractseats "git.novox.be/novox/mesh-contracts/go/seats" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The modules holding the four health seats, and the states between them, as `_` buckets. @@ -274,6 +274,6 @@ type Would struct { // KeeperSeat and the self-check's run as `checked` on SelfCheckSeat: the controller's events and the take-over key that // chose between the two sources ended with it (novox/hq ADR 0590 clause 3). const ( - KeeperSeat = contractseats.MeshConditions - SelfCheckSeat = contractseats.MeshSelfCheck + KeeperSeat = catalogue.ConditionsSeat + SelfCheckSeat = catalogue.SelfCheckSeat ) diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 633ea39..be88d7d 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -29,6 +29,28 @@ var meshSDKPackages = []string{ "sdkversion", "seal", "seatdef", "testbus", "views", } +// clientSeats are the seats whose logic is their client's in mesh-contracts (novox/hq ADR 0627 §1, §4): mesh-sdk names +// none of them outside catalogue, and holds no package over them. +var clientSeats = []string{contractseats.OperatorChannel, contractseats.MeshVault, "node-engine", + contractseats.NpmPackageRegistry, contractseats.GoPackageRegistry, contractseats.ContainerImageRegistry, + contractseats.OciArtifactRegistry} + +// notInSDK are the packages that moved to a seat's client or contract (ADR 0627 §1, §4) and may not come back. +var notInSDK = []string{"asks", "asker", "publish"} + +// untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below +// would refuse; it only shrinks, and is empty when the group is pinned: asks and asker are operator-channel's client +// (mesh-contracts go/clients/operatorchannelclient) once delivery's gate and assigner asks no longer hold the +// router's ask as asks.Ask; the vault module's name in delivery and provisions goes with the vault's client. +var untilTheJoin = []string{ + "asks/asks.go: imports git.novox.be/novox/mesh-contracts/go/seats", + "asks/asks.go: Seat", + "asks: a package that moved", + "asker: a package that moved", + "delivery/signers.go: mesh-vault", + "provisions/provisions.go: mesh-vault", +} + // computesNot are the functions whose computation is one owner's alone (ADR 0593 §2, §7): the indexer computes a // fingerprint, its named tree and a package's version; the node-engine composes. var computesNot = []string{"SourceFingerprint", "SourceFingerprint2", "NamedTree", "MeshPackagesOf", "PackageVersionsOf", @@ -55,7 +77,15 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } return false } - var seatNames []string + var seatNames, pending []string + clientNamed := func(v string) bool { + for _, seat := range clientSeats { + if v == seat || strings.HasPrefix(v, "mesh.seat."+seat+".") { + return true + } + } + return false + } err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -78,11 +108,22 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { return err } where := filepath.ToSlash(path) + ": " + inCatalogue := dir == "catalogue" + // ADR 0625 §3: mesh-sdk's only import of a contracts module is catalogue's of the seat set. + for _, imp := range file.Imports { + p, _ := strconv.Unquote(imp.Path.Value) + if strings.HasPrefix(p, "git.novox.be/novox/mesh-contracts/") && (!inCatalogue || p != contractsSeatSet) { + pending = append(pending, where+"imports "+p) + } + } ast.Inspect(file, func(n ast.Node) bool { switch n := n.(type) { case *ast.BasicLit: if v, err := strconv.Unquote(n.Value); err == nil && n.Kind == token.STRING && namesSeat(v) { seatNames = append(seatNames, where+v) + if !inCatalogue && clientNamed(v) { + pending = append(pending, where+v) + } } case *ast.ValueSpec: for i, name := range n.Names { @@ -90,6 +131,10 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { if sel, ok := n.Values[i].(*ast.SelectorExpr); ok { if x, ok := sel.X.(*ast.Ident); ok && x.Name == "contractseats" { seatNames = append(seatNames, where+name.Name) + if !inCatalogue && slices.Contains([]string{"OperatorChannel", "MeshVault", "NodeEngine", + "NpmPackageRegistry", "GoPackageRegistry", "ContainerImageRegistry", "OciArtifactRegistry"}, sel.Sel.Name) { + pending = append(pending, where+name.Name) + } } } } @@ -146,6 +191,24 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } } slices.Sort(found) + for _, dir := range found { + if slices.Contains(notInSDK, dir) { + pending = append(pending, dir+": a package that moved") + } + } + // ADR 0627 §4, ADR 0625 §3: no package over a client's seat, no client seat's name outside catalogue, no contracts + // import but the seat set's in catalogue; what the join still moves is untilTheJoin's. + for _, p := range pending { + if !slices.Contains(untilTheJoin, p) { + t.Errorf("%s: mesh-sdk holds no logic over a seat whose client is in mesh-contracts, names none of them outside "+ + "catalogue, and imports no contracts module but the seat set in catalogue (ADR 0627, ADR 0625 §3)", p) + } + } + for _, k := range untilTheJoin { + if !slices.Contains(pending, k) { + t.Errorf("untilTheJoin keeps %q, which is gone: take it off the list", k) + } + } for _, dir := range found { if !slices.Contains(meshSDKPackages, dir) { t.Errorf("mesh-sdk holds the package %s, which it does not keep (ADR 0593 §1): it is its owner module's package", dir) @@ -230,5 +293,8 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } } +// contractsSeatSet is the seat-set package of mesh-contracts, catalogue's one import of a contracts module. +const contractsSeatSet = "git.novox.be/novox/mesh-contracts/go/seats" + // sdkModule is mesh-sdk's Go module path. const sdkModule = "git.novox.be/novox/mesh-sdk/go" diff --git a/go/provisions/provisions.go b/go/provisions/provisions.go index c4b4690..e42b024 100644 --- a/go/provisions/provisions.go +++ b/go/provisions/provisions.go @@ -13,13 +13,12 @@ import ( "strings" "time" - contractseats "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The seat, its holder and its two states, and the names a reader declares in its `reads`. const ( - Seat = contractseats.MeshProvisioner + Seat = catalogue.ProvisionerSeat Module = "mesh-provisioner" ProvisionsState = "provisions" PlansState = "plans" diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 3002238..2f9e3b4 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -1,7 +1,12 @@ asks/asks.go: Seat buscompose/health_grants.go: mesh.seat.mesh-self-check.event. -buscompose/users.go: VaultSeat catalogue/artifact_sweep_seat.go: ArtifactSweepSeat +catalogue/artifacts.go: ContainerImageRegistrySeat +catalogue/artifacts.go: GoPackageRegistrySeat +catalogue/artifacts.go: NpmPackageRegistrySeat +catalogue/artifacts.go: OciArtifactRegistrySeat +catalogue/artifacts.go: ProvisionerSeat +catalogue/artifacts.go: VaultSeat catalogue/catalog_seat.go: CatalogSeat catalogue/checker_seat.go: CheckerSeat catalogue/control_records_seats.go: HandActsSeat @@ -48,14 +53,10 @@ delivery/provided.go: the-private-network delivery/signers.go: mesh-vault delivery/stepasks.go: node-build-node handacts/handacts.go: mesh-hand-acts -handacts/handacts.go: Seat -health/findings.go: KeeperSeat health/findings.go: mesh-conditions health/findings.go: mesh-healers health/findings.go: mesh-self-check -health/findings.go: SelfCheckSeat internal/beside/beside.go: mesh-catalog overlay/overlay.go: the-private-network provisions/provisions.go: mesh-provisioner provisions/provisions.go: mesh-vault -provisions/provisions.go: Seat -- 2.54.0 From 772b441be3527dfab2197b7569db3fe6097f4e64 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:24:40 +0100 Subject: [PATCH 040/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20a=20refusal=20f?= =?UTF-8?q?or=20want=20of=20room=20is=20told=20from=20the=20others,=20so?= =?UTF-8?q?=20a=20holder's=20finding=20for=20it=20can=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A holder raises a finding when it refuses a file for want of room; that finding is the holder's, not the file's, and clears once it takes a file again. AtCap and RefusedAtCap let it tell that refusal apart without its own copy of the reason's words. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/parts/receive.go | 9 ++++++++- go/parts/receive_test.go | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/go/parts/receive.go b/go/parts/receive.go index d2ac237..f745188 100644 --- a/go/parts/receive.go +++ b/go/parts/receive.go @@ -93,6 +93,13 @@ type Received struct { Ended bool } +// AtCap opens the reason of a refusal for want of room: the objects would pass their cap. A holder tells it from +// the other refusals with RefusedAtCap, so the finding it raises for it can clear once it takes a file again. +const AtCap = "the objects are at their cap" + +// RefusedAtCap says whether a refusal's reason is one for want of room (AtCap). +func RefusedAtCap(reason string) bool { return strings.HasPrefix(reason, AtCap) } + // Receiver takes transfers in parts into a holder's own objects. type Receiver[M any] struct { Objects Objects @@ -182,7 +189,7 @@ func (r *Receiver[M]) Open(id, opener string, parts []Want, shape M) (Received, size += uint64(max(p.Size, 0)) } if used+size > r.Cap { - return Received{ID: id, Ended: true}, r.refuse(t, fmt.Sprintf("the objects are at their cap of %d bytes (%d held, %d asked)", r.Cap, used, size)) + return Received{ID: id, Ended: true}, r.refuse(t, fmt.Sprintf(AtCap+" of %d bytes (%d held, %d asked)", r.Cap, used, size)) } if r.Holds != nil { for _, p := range parts { diff --git a/go/parts/receive_test.go b/go/parts/receive_test.go index 0f355bf..ac37613 100644 --- a/go/parts/receive_test.go +++ b/go/parts/receive_test.go @@ -186,6 +186,9 @@ func TestAHolderTakesAFileInParts(t *testing.T) { if got := pkgLog[len(pkgLog)-1].outcome; !strings.HasPrefix(got, "refused: the objects are at their cap") { t.Errorf("the cap's refusal said %q", got) } + if !RefusedAtCap(strings.TrimPrefix(pkgLog[len(pkgLog)-1].outcome, "refused: ")) || RefusedAtCap(strings.TrimPrefix(pkgLog[len(pkgLog)-2].outcome, "refused: ")) { + t.Errorf("RefusedAtCap tells the cap's refusal from the check's: %q, %q", pkgLog[len(pkgLog)-1].outcome, pkgLog[len(pkgLog)-2].outcome) + } if got := pkgLog[len(pkgLog)-2].outcome; got != "refused: the builder did not number it" { t.Errorf("the check's refusal said %q", got) } -- 2.54.0 From 5bc1bf9f0ad35bf5979f13803daba1dbd6c24334 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:27:00 +0100 Subject: [PATCH 041/144] =?UTF-8?q?ADR=200627=20=C2=A74:=20asks=20and=20as?= =?UTF-8?q?ker=20leave=20mesh-sdk=20for=20operator-channel's=20client;=20t?= =?UTF-8?q?he=20channel's=20benches=20stay=20as=20package=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's and the assigner's asks carry the router's ask as its JSON, so mesh-sdk names no type of a seat defined whole; the ask's id, options, digest and an option's level are read by the gate, the assigner and the teller through operatorchannelclient. ViewAnswerFits keeps the asking's check, from the key. The channel and intake benches are not defined whole: their shapes move to go/channel (ADR 0625 §9). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- README.md | 21 +- go/asker/asker.go | 196 ----------- go/asker/asker_test.go | 141 -------- go/asks/asks.go | 583 ------------------------------- go/asks/asks_test.go | 367 ------------------- go/asks/condition.go | 149 -------- go/asks/condition_test.go | 143 -------- go/channel/channel.go | 141 ++++++++ go/channel/channel_test.go | 32 ++ go/delivery/gate.go | 35 +- go/delivery/gate_test.go | 55 ++- go/delivery/view_answers.go | 59 ++-- go/delivery/view_answers_test.go | 56 ++- go/packages_list_test.go | 11 +- go/process/state.go | 2 +- go/process/state_test.go | 15 +- go/testdata/seat-names.txt | 1 - 17 files changed, 296 insertions(+), 1711 deletions(-) delete mode 100644 go/asker/asker.go delete mode 100644 go/asker/asker_test.go delete mode 100644 go/asks/asks.go delete mode 100644 go/asks/asks_test.go delete mode 100644 go/asks/condition.go delete mode 100644 go/asks/condition_test.go create mode 100644 go/channel/channel.go create mode 100644 go/channel/channel_test.go diff --git a/README.md b/README.md index 1788b82..a83b1ae 100644 --- a/README.md +++ b/README.md @@ -106,21 +106,20 @@ runtime's (`node-tools/internal/launch`, mesh-tools). values and then every change. Through the runtime that launched the bundle, which refuses what the module did not declare. Current values, never history and never a secret (novox/hq ADR 0201). -### `seats` and `asks` — asking a person, answering on a channel (Go) +### `seats` and `channel` — publishing on a seat, the channel's benches (Go) A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve` and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept), `stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by -compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the -contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves -who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on -(`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the -subjects each travels on. -`go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then -publishes it under the module's name; `Cancel`; `Where` reads its state from the router's record; `Listen` and -`Take` hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only -for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is -`stdio.ErrChanged`, told by the runtime's error code. +compare-and-set. The runtime does each only as far as the module's membership lists. `go/channel` holds the +shapes of the two benches a channel holds, `channel` and `intake` (the `Message` a channel takes, the `Choice`, +`LinkAsked`, `Failed`, `Standing` and `Code` it says, with the subjects each travels on): those benches are not +defined whole, so their layouts stay here (ADR 0625 §9). +Asking a person is the `operator-channel` seat's, defined whole in mesh-contracts: its `Ask` and `Warrant` are its +generated contract (`go/seats/operatorchannel`), and the checks an ask is held to, the warrant's (`For`, `Says`, +`Performs`) and an asking module's client (`Ask`, `Cancel`, `Where`, `Listen`, `Take`, each warrant handed once, +marked acted on by compare-and-set) are its client, `go/clients/operatorchannelclient` (ADR 0627 §1). A lost +compare-and-set is `stdio.ErrChanged`, told by the runtime's error code. `State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted. diff --git a/go/asker/asker.go b/go/asker/asker.go deleted file mode 100644 index 360c7b6..0000000 --- a/go/asker/asker.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package asker is a module's client for asking a person (novox/hq ADR 0259 §10): it publishes an ask under -// the module's own name, cancels it, reads its state from the router's record, and hands the module each -// warrant once — only one for an ask it made, for an option it offered, at that option's level, before it -// expired. The module then acts with its own grants. -// -// It keeps what it asked, and which warrants it acted on, in a state of the module's own (declared in its -// manifest, by default `asked`): the ask is written before it is published, and a warrant is marked acted on -// by compare-and-set before the module's handler runs, so a redelivery, or a second instance, acts once. -package asker - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" -) - -// Client asks as one module. -type Client struct { - // Module is the asking module's name: the one the bus grants its asks under. - Module string - // State is the module's own state the asks are kept in. - State Store - // Now is the clock; nil is time.Now. - Now func() time.Time - // Publish, Record and Subscribe reach the bus; nil is the stdio SDK's, through the module's runtime. - Publish func(subject string, body any, id string) error - Record func(key string) (*stdio.StateEntry, error) - Subscribe func(pattern string, handle func(stdio.Envelope) error) error -} - -// Store is where the asks are kept: stdio.State(name) is one. -type Store interface { - Get(key string) (*stdio.StateEntry, error) - Create(key string, value any) (uint64, error) - Update(key string, value any, revision uint64) (uint64, error) -} - -// New is a client for the module the runtime serves, keeping its asks in the state named. -func New(state string) *Client { - return &Client{Module: os.Getenv("MESH_MODULE"), State: stdio.State(state)} -} - -func (c *Client) now() time.Time { - if c.Now != nil { - return c.Now() - } - return time.Now() -} - -// Kept is one ask as the module keeps it. -type Kept struct { - Ask asks.Ask `json:"ask"` - Asked time.Time `json:"asked"` - Acted bool `json:"acted,omitempty"` - Ended asks.Outcome `json:"ended,omitempty"` - Answer *asks.Warrant `json:"answer,omitempty"` -} - -func key(id string) string { return "ask." + id } - -// Ask checks an ask, keeps it, and publishes it under the module's own name. An ask with an id already kept -// is refused: a new question is a new id. -func (c *Client) Ask(a asks.Ask) error { - if err := a.Check(c.now()); err != nil { - return err - } - if _, err := c.State.Create(key(a.ID), Kept{Ask: a, Asked: c.now()}); err != nil { - if errors.Is(err, stdio.ErrChanged) { - return fmt.Errorf("an ask %s was already made: a new question is a new id", a.ID) - } - return err - } - return c.publish(asks.AskSubject(c.Module), a, a.ID) -} - -// Cancel takes an ask back: the router ends it and edits every copy. -func (c *Client) Cancel(id string) error { - return c.publish(asks.CancelSubject(c.Module), map[string]string{"id": id}, "cancel."+id) -} - -func (c *Client) publish(subject string, body any, id string) error { - if c.Publish != nil { - return c.Publish(subject, body, id) - } - return stdio.SeatPublish(subject, body, id) -} - -// Where is an ask's state as the router keeps it: its state and its warrant, if any — read under the -// module's own name and no other's. -func (c *Client) Where(id string) (state string, w *asks.Warrant, err error) { - read := c.Record - if read == nil { - read = func(k string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", k) } - } - e, err := read(id) - if err != nil || e == nil { - return "", nil, err - } - var rec struct { - State string `json:"state"` - Warrant *asks.Warrant `json:"warrant"` - } - if err := json.Unmarshal(e.Value, &rec); err != nil { - return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) - } - return rec.State, rec.Warrant, nil -} - -// Handled is what came of a warrant: acted on, refused (and why), or already done. -type Handled string - -const ( - Acted Handled = "acted" - Refused Handled = "refused" - Done Handled = "already acted on" - Recorded Handled = "ended without a choice" -) - -// Take hands act a warrant once, after holding it to the ask it answers. act performs the option with the -// module's own grants; its error is said and the warrant stays acted on (a person's decision is not -// performed twice; the module says the failure in its own record). -func (c *Client) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (Handled, error) { - if w.Asker != c.Module { - return Refused, fmt.Errorf("a warrant for %s reached %s", w.Asker, c.Module) - } - e, err := c.State.Get(key(w.Ask)) - if err != nil { - return "", err - } - if e == nil { - return Refused, fmt.Errorf("%s holds no ask %s", c.Module, w.Ask) - } - var k Kept - if err := json.Unmarshal(e.Value, &k); err != nil { - return "", err - } - if k.Acted || k.Ended != "" { - return Done, nil - } - if w.Outcome != asks.OutcomeChosen { - k.Ended = w.Outcome - k.Answer = &w - if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil && !errors.Is(err, stdio.ErrChanged) { - return "", err - } - return Recorded, nil - } - o, err := w.For(c.Module, k.Ask) - if err != nil { - return Refused, err - } - // Marked before acting: of two deliveries, or two instances, only the one whose write stands acts. - k.Acted, k.Ended, k.Answer = true, asks.OutcomeChosen, &w - if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil { - if errors.Is(err, stdio.ErrChanged) { - return Done, nil - } - return "", err - } - return Acted, act(o, w) -} - -// Listen hands every warrant for this module to Take, with act. It asks the runtime for the module's -// events matching the router's `decided` subject for this module. -func (c *Client) Listen(act func(asks.Option, asks.Warrant) error, said func(Handled, asks.Warrant, error)) error { - subscribe := c.Subscribe - if subscribe == nil { - subscribe = stdio.Subscribe - } - return subscribe(asks.Seat+".decided."+c.Module, func(e stdio.Envelope) error { - if !strings.HasPrefix(e.Key, asks.Seat+".decided.") { - return nil - } - var w asks.Warrant - if err := json.Unmarshal(e.Body, &w); err != nil { - if said != nil { - said(Refused, w, fmt.Errorf("a warrant could not be read: %w", err)) - } - return nil - } - h, err := c.Take(w, act) - if h == "" && err != nil { - return err // the state could not be reached: offered again - } - if said != nil { - said(h, w, err) - } - return nil - }) -} diff --git a/go/asker/asker_test.go b/go/asker/asker_test.go deleted file mode 100644 index 6285876..0000000 --- a/go/asker/asker_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package asker - -import ( - "encoding/json" - "strings" - "testing" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asks" -) - -type mem struct { - vals map[string]json.RawMessage - revs map[string]uint64 - n uint64 -} - -func (m *mem) Get(k string) (*stdio.StateEntry, error) { - v, ok := m.vals[k] - if !ok { - return nil, nil - } - return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil -} -func (m *mem) Create(k string, v any) (uint64, error) { - if _, ok := m.vals[k]; ok { - return 0, stdio.ErrChanged - } - return m.put(k, v) -} -func (m *mem) Update(k string, v any, rev uint64) (uint64, error) { - if rev == 0 || m.revs[k] != rev { - return 0, stdio.ErrChanged - } - return m.put(k, v) -} -func (m *mem) put(k string, v any) (uint64, error) { - raw, _ := json.Marshal(v) - m.n++ - m.vals[k], m.revs[k] = raw, m.n - return m.n, nil -} - -var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) - -func client() (*Client, map[string][]string) { - sent := map[string][]string{} - c := &Client{Module: "mesh-delivery", State: &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}}, - Now: func() time.Time { return now }, - Publish: func(subject string, body any, id string) error { - sent[subject] = append(sent[subject], id) - return nil - }} - return c, sent -} - -// releaseHQ is the act the ask's one option binds. -var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"} - -func held() asks.Ask { - binds, _ := asks.ActDigest(releaseHQ) - return asks.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.", - Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", - Options: []asks.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: asks.Approve, Binds: binds}}} -} - -func warrant() asks.Warrant { - return asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: asks.OutcomeChosen, Option: "release", - Label: "Release", Level: asks.Approve, At: now.Add(time.Minute), AskDigest: held().Digest(), - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} -} - -func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) { - c, sent := client() - if err := c.Ask(held()); err != nil { - t.Fatal(err) - } - if got := sent[asks.AskSubject("mesh-delivery")]; len(got) != 1 || got[0] != "d-1" { - t.Fatalf("%v", sent) - } - if err := c.Ask(held()); err == nil || !strings.Contains(err.Error(), "already made") { - t.Errorf("the same id was asked twice: %v", err) - } - bad := held() - bad.Who = "agent" - if err := c.Ask(bad); err == nil { - t.Error("an ask the contract refuses was published") - } -} - -func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) { - c, _ := client() - _ = c.Ask(held()) - acted := 0 - act := func(o asks.Option, _ asks.Warrant) error { - if err := o.Performs(releaseHQ); err != nil { - t.Errorf("the bound act: %v", err) - } - acted++ - return nil - } - if h, err := c.Take(warrant(), act); h != Acted || err != nil || acted != 1 { - t.Fatalf("%s %v %d", h, err, acted) - } - if h, _ := c.Take(warrant(), act); h != Done || acted != 1 { - t.Errorf("a redelivery was acted on: %s", h) - } - for why, change := range map[string]func(*asks.Warrant){ - "another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" }, - "an ask not made": func(w *asks.Warrant) { w.Ask = "d-9" }, - "another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge }, - "an option not offered": func(w *asks.Warrant) { w.Option = "delete" }, - "after its expiry": func(w *asks.Warrant) { w.At = now.Add(2 * time.Hour) }, - "another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:00" }, - } { - c2, _ := client() - _ = c2.Ask(held()) - w := warrant() - change(&w) - if h, _ := c2.Take(w, act); h == Acted { - t.Errorf("a warrant from %s was acted on", why) - } - } - if acted != 1 { - t.Errorf("acted %d times", acted) - } -} - -func TestAnAskEndedWithoutAChoiceIsRecordedAndNothingDone(t *testing.T) { - c, _ := client() - _ = c.Ask(held()) - w := warrant() - w.Outcome, w.Option, w.By = asks.OutcomeExpired, "", nil - if h, err := c.Take(w, func(asks.Option, asks.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil { - t.Fatalf("%s %v", h, err) - } - if h, _ := c.Take(warrant(), func(asks.Option, asks.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done { - t.Errorf("%s", h) - } -} diff --git a/go/asks/asks.go b/go/asks/asks.go deleted file mode 100644 index 428e441..0000000 --- a/go/asks/asks.go +++ /dev/null @@ -1,583 +0,0 @@ -// Package asks is the contract of asking a person and answering on a channel (novox/hq ADR 0259): the -// shapes an asker, the router and a channel exchange on the bus, and the subjects they travel on. No -// transport and no channel's service: an asker publishes an Ask under its own name and acts on the Warrant -// it hears; the router holds the ask, sends channels a Message, and judges the Choice a channel says; a -// channel shows a Message and says what was chosen and by whom, as its service authenticated it. -package asks - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "regexp" - "sort" - "strconv" - "strings" - "time" - - contractseats "git.novox.be/novox/mesh-contracts/go/seats" -) - -// The seats (novox/hq ADR 0259 §3). -const ( - // Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by - // the asker. - // - // Deprecated: the seat is defined whole in mesh-contracts (operatorchannel.Seat, its subjects and message - // types); kept for its importers until their next change takes it from there (testdata/seat-names.txt). - Seat = contractseats.OperatorChannel - // ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind. - ChannelSeat = "channel" - // IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry - // the kind. - IntakeSeat = "intake" -) - -// AskSubject is where an asker publishes an ask, CancelSubject its cancel, and DecidedSubject where it -// hears the warrant, or the ask's end without one. -func AskSubject(asker string) string { return "mesh.seat." + Seat + ".accept.ask." + asker } -func CancelSubject(asker string) string { return "mesh.seat." + Seat + ".accept.cancel." + asker } -func DecidedSubject(asker string) string { return "mesh.seat." + Seat + ".event.decided." + asker } - -// The work a channel takes, on ChannelSubject. -const ( - Show = "show" // a message offering answers - Edit = "edit" // a message shown before, replaced - Send = "send" // a message offering nothing -) - -// ChannelSubject is where the router sends a channel of a kind its work. -func ChannelSubject(verb, kind string) string { - return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind -} - -// What a channel says, on IntakeSubject. -const ( - Chosen = "choice" // a button tapped - Link = "link" // somebody asked to be linked as the operator -) - -// IntakeSubject is where a channel of a kind says what arrived. -func IntakeSubject(what, kind string) string { - return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind -} - -// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept. -const CodeProof = "code" - -// ProofSubject is where a channel of a kind asks a proof. -func ProofSubject(verb, kind string) string { - return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind -} - -// A Level is how much proof an option's answer needs (novox/hq ADR 0234 §8, the glossary's assurance level). -type Level string - -const ( - // Acknowledge performs only what any granted principal may already do: silencing, details. No proof. - Acknowledge Level = "acknowledge" - // Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code. - Approve Level = "approve" - // Destroy needs two proofs, one of them a code. - Destroy Level = "destroy" -) - -// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less. -func (l Level) Rank() int { - switch l { - case Acknowledge: - return 0 - case Approve: - return 1 - case Destroy: - return 2 - } - return 3 -} - -// Operator is the one role an ask may be answered by today. -const Operator = "operator" - -// Option is one answer an ask offers: a label for the button, what it does in plain words, its level. -type Option struct { - ID string `json:"id"` - Label string `json:"label"` - Does string `json:"does"` - Level Level `json:"level"` - // Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the - // machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant, - // which names the ask's digest, names it too: a warrant then authorises that act and no other, and an - // asker whose record of the act changed after it asked finds the digests differ and does nothing. - // Required on an option above acknowledge. - Binds string `json:"binds,omitempty"` -} - -// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each -// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and -// values alone, never over how a language happens to encode a struct. -type Act map[string]string - -// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical), -// written "sha256:". The same names and values give the same digest in any language, whatever order -// they were set in; a field renamed, added or emptied gives another. -func ActDigest(act Act) (string, error) { - if len(act) == 0 { - return "", fmt.Errorf("the act cannot be digested: it names nothing") - } - keys := make([]string, 0, len(act)) - for k := range act { - if k == "" { - return "", fmt.Errorf("the act cannot be digested: a field has no name") - } - keys = append(keys, k) - } - sort.Strings(keys) - var b strings.Builder - b.WriteString("novox.act.v1\n") - for _, k := range keys { - canonical(&b, k) - canonical(&b, act[k]) - } - sum := sha256.Sum256([]byte(b.String())) - return "sha256:" + hex.EncodeToString(sum[:]), nil -} - -// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read -// as another's end or start, so two different sequences of values never encode the same. -func canonical(b *strings.Builder, v string) { - b.WriteString(strconv.Itoa(len(v))) - b.WriteByte(':') - b.WriteString(v) - b.WriteByte('\n') -} - -// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each -// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker -// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the -// person was shown it, and nothing published under the same id before or after. -// -// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC -// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask -// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow -// the options only when the ask gives either: an ask without them digests as it did before they existed, so -// a router and an asker of different builds still agree on every such ask. -func (a Ask) Digest() string { - var b strings.Builder - b.WriteString("novox.ask.v1\n") - for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who, - a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent), - strconv.Itoa(len(a.Options))} { - canonical(&b, v) - } - for _, o := range a.Options { - for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} { - canonical(&b, v) - } - } - // Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever - // it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes — - // none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks - // encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and - // one without WholeDetails as before it existed. - if a.Whole != "" || a.Details != "" || a.WholeDetails != "" { - canonical(&b, a.Whole) - canonical(&b, a.Details) - } - if a.WholeDetails != "" { - canonical(&b, a.WholeDetails) - } - sum := sha256.Sum256([]byte(b.String())) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// Ask is a request for a person's word (novox/hq ADR 0259 §4). -type Ask struct { - // ID is the asker's own, unique to it. - ID string `json:"id"` - // Headline names the thing and what is wrong, in a few plain words; Explanation is what happened and - // what it means. Both are held to the plain rule and the content rule by the router. - Headline string `json:"headline"` - Explanation string `json:"explanation"` - Options []Option `json:"options"` - // Who may answer: Operator. - Who string `json:"who"` - // Expires is when the ask ends unanswered; OnExpiry is what the asker then does, in words the person - // is shown ("the delivery stays held"). An ask that authorises never defaults. - Expires time.Time `json:"expires"` - OnExpiry string `json:"on-expiry"` - // About is what the ask is about (a condition's key): a newer ask about it replaces the older. - About string `json:"about,omitempty"` - Urgent bool `json:"urgent,omitempty"` - // Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind - // that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the - // person approves — a mount point, a share, a private address — must be readable where they approve it. - // The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left; - // Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere. - Whole string `json:"whole,omitempty"` - // Details are the ask's secondary words, line by line under the content rule: who proposed it, a - // fingerprint, how to read it whole. The router shows them in the note of the ask's own message - // (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq - // issue 639). - Details string `json:"details,omitempty"` - // WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown - // in place of the explanation (a kind that proves who answers, given the ask's answers): every value an - // approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like - // Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty, - // Details are shown everywhere. - WholeDetails string `json:"whole-details,omitempty"` -} - -// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary -// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at -// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when, -// the fingerprint and how to read it whole are Details, never the explanation. -const ( - SummaryLength = 300 - MostPoints = 3 - PointLength = 160 -) - -// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than -// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not -// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that -// does not hold to it. -func Glance(explanation string) error { - var lines []string - for _, l := range strings.Split(explanation, "\n") { - if strings.TrimSpace(l) != "" { - lines = append(lines, strings.TrimSpace(l)) - } - } - var problems []string - switch { - case len(lines) == 0: - problems = append(problems, "it has no summary") - case len([]rune(lines[0])) > SummaryLength: - problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength)) - } - if len(lines) > 1+MostPoints { - problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints)) - } - for i, l := range lines { - if i > 0 && len([]rune(l)) > PointLength { - problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength)) - } - } - if len(problems) > 0 { - return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; ")) - } - return nil -} - -// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). -const ( - MostOptions = 4 - MostOpen = 3 - ApproveLasts = 24 * time.Hour - DestroyLasts = 10 * time.Minute - HeadlineLength = 60 - LabelLength = 24 -) - -var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) - -// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both. -func UsableID(id string) bool { return usableID.MatchString(id) } - -// Highest is the highest level among the ask's options. -func (a Ask) Highest() Level { - high := Acknowledge - for _, o := range a.Options { - if o.Level.Rank() > high.Rank() { - high = o.Level - } - } - return high -} - -// Option is the option of this id, or false. -func (a Ask) Option(id string) (Option, bool) { - for _, o := range a.Options { - if o.ID == id { - return o, true - } - } - return Option{}, false -} - -// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on. -func (a Ask) Check(now time.Time) error { - var problems []string - say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } - if !UsableID(a.ID) { - say("its id %q is not letters, digits, - and _, at most 64", a.ID) - } - if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength { - say("its headline is empty or longer than %d characters", HeadlineLength) - } - if strings.TrimSpace(a.Explanation) == "" { - say("it explains nothing") - } - if a.Who != Operator { - say("it is answered by %q, and only the operator answers today", a.Who) - } - if len(a.Options) == 0 || len(a.Options) > MostOptions { - say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions) - } - seen := map[string]bool{} - for _, o := range a.Options { - switch { - case !UsableID(o.ID): - say("an option's id %q is not letters, digits, - and _", o.ID) - case seen[o.ID]: - say("the option %s is offered twice", o.ID) - } - seen[o.ID] = true - if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength { - say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength) - } - if strings.TrimSpace(o.Does) == "" { - say("the option %s does not say what it does", o.ID) - } - if o.Level.Rank() > Destroy.Rank() { - say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level) - } - if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") { - say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID) - } - } - if !a.Expires.After(now) { - say("it expires before it is asked") - } - switch a.Highest() { - case Approve: - if a.Expires.After(now.Add(ApproveLasts)) { - say("an ask that approves lasts at most %s", ApproveLasts) - } - case Destroy: - if a.Expires.After(now.Add(DestroyLasts)) { - say("an ask that destroys lasts at most %s", DestroyLasts) - } - } - if a.Highest() != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" { - say("it does not say what happens when nobody answers, and an ask that authorises never defaults") - } - if a.About != "" && strings.ContainsAny(a.About, " \n") { - say("what it is about is a key, without spaces") - } - if len(problems) > 0 { - return errors.New("the ask is refused: " + strings.Join(problems, "; ")) - } - return nil -} - -// Outcome is how an ask ended. -type Outcome string - -const ( - OutcomeChosen Outcome = "chosen" // a person chose an option: a warrant - OutcomeExpired Outcome = "expired" // nobody answered in time - OutcomeCancelled Outcome = "cancelled" // its asker took it back - OutcomeReplaced Outcome = "replaced" // a newer ask about the same thing replaced it - OutcomeRefused Outcome = "refused" // it was never shown: Words says why -) - -// Person is who chose, as the router verified them. -type Person struct { - // Who is the role: Operator. - Who string `json:"who"` - // Kind is the channel kind they answered on, Identity their account on that service, Display the name - // the service shows, and Verified how the router knew it was them. - Kind string `json:"kind"` - Identity string `json:"identity"` - Display string `json:"display,omitempty"` - Verified string `json:"verified"` -} - -// Warrant is the router's record that a person chose one option of one ask, or the ask's end without one -// (novox/hq ADR 0259 §6). It carries no secret. -type Warrant struct { - Ask string `json:"ask"` - Asker string `json:"asker"` - About string `json:"about,omitempty"` - Outcome Outcome `json:"outcome"` - // Option, Label and Level are the option chosen; By who chose it, Channel the module it came through, - // Proofs which proofs were present (P1, P2, P3). - Option string `json:"option,omitempty"` - Label string `json:"label,omitempty"` - Level Level `json:"level,omitempty"` - By *Person `json:"by,omitempty"` - Channel string `json:"channel,omitempty"` - Proofs []string `json:"proofs,omitempty"` - At time.Time `json:"at"` - // AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask - // alone, with the options it bound. - AskDigest string `json:"ask-digest,omitempty"` - // Words are why an ask ended without a choice, or what refused it. - Words string `json:"words,omitempty"` -} - -// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id -// verified), chose Release". -func (w Warrant) Says() string { - if w.Outcome != OutcomeChosen || w.By == nil { - return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome) - } - via := w.By.Kind - if w.By.Verified != "" { - via += " (" + w.By.Verified + ")" - } - return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label) -} - -// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the -// same words, options and binds), a choice, an option the ask offered, at that option's level, before the -// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names. -func (w Warrant) For(asker string, a Ask) (Option, error) { - if w.Asker != asker || w.Ask != a.ID { - return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID) - } - if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who { - return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome) - } - if d := a.Digest(); w.AskDigest != d { - return Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+ - "it was not the ask the person was shown", w.AskDigest, a.ID, d) - } - o, offered := a.Option(w.Option) - if !offered { - return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option) - } - if w.Level != o.Level { - return Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level) - } - // A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no - // expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time (the - // confirmation review of 2026-10-09). - if a.Expires.IsZero() { - return Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID) - } - if w.At.IsZero() { - return Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID) - } - if w.At.After(a.Expires) { - return Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s", - w.At.UTC().Format(time.RFC3339), a.ID, a.Expires.UTC().Format(time.RFC3339)) - } - return o, nil -} - -// Performs checks that the act an asker is about to perform is the one the chosen option bound when it -// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes. -func (o Option) Performs(act Act) error { - if o.Binds == "" && o.Level == Acknowledge { - return nil - } - d, err := ActDigest(act) - if err != nil { - return err - } - if d != o.Binds { - return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d) - } - return nil -} - -// Button is one answer a channel offers: its label and the router's one-time ticket for it. -type Button struct { - Label string `json:"label"` - Ticket string `json:"ticket"` -} - -// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said -// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps -// which of its own messages that is. The words are the router's, shown as given. -type Message struct { - Handle string `json:"handle"` - Title string `json:"title"` - Body string `json:"body"` - Buttons []Button `json:"buttons,omitempty"` - // Note is the message's secondary words, after its body and its buttons: who asked and when, what each - // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart - // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's - // length cuts the body first and the note only when the title and the note alone do not fit, saying each - // cut (novox/hq ADR 0347 §5). - Note string `json:"note,omitempty"` - Urgent bool `json:"urgent,omitempty"` - Silent bool `json:"silent,omitempty"` - // Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made. - Reply string `json:"reply,omitempty"` - // To is the account linked as the operator on this kind, for a channel that verifies its sender: where - // the channel sends what is not a reply. The router's word, from its list; empty when none is linked. - To string `json:"to,omitempty"` - // Secret says the words carry something shown once (a link's code): the channel shows it and keeps no - // copy of it — no state, no history of its own. - Secret bool `json:"secret,omitempty"` -} - -// Sender is who a channel's service says sent something: the account, the name it shows, and whether the -// service authenticated it. The router alone judges whether that is the operator. -type Sender struct { - Identity string `json:"identity"` - Display string `json:"display,omitempty"` - Authenticated bool `json:"authenticated"` -} - -// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help. -type Failed struct { - Handle string `json:"handle"` - Why string `json:"why"` - Permanent bool `json:"permanent,omitempty"` - At time.Time `json:"at"` -} - -// Standing is what a channel says of itself, at least every five minutes and whenever it changes: -// whether it can send now, why not, and whether its edits notify nobody. -type Standing struct { - Ready bool `json:"ready"` - Why string `json:"why,omitempty"` - EditsSilently bool `json:"edits-silently,omitempty"` - At time.Time `json:"at"` -} - -// What a channel says of its own delivery, on IntakeSubject. -const ( - FailedWhat = "failed" - StandingWhat = "standing" -) - -// Choice is a button chosen on a channel. -type Choice struct { - // ID is the channel's own for this arrival, unique, so the router acts on it once. - ID string `json:"id"` - Ticket string `json:"ticket"` - Handle string `json:"handle,omitempty"` - Sender Sender `json:"sender"` - At time.Time `json:"at"` -} - -// LinkAsked is somebody on a channel asking to be linked as the operator. -type LinkAsked struct { - ID string `json:"id"` - Sender Sender `json:"sender"` - At time.Time `json:"at"` -} - -// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept. -type Code struct { - Sender Sender `json:"sender"` - Code string `json:"code"` - Purpose string `json:"purpose"` -} - -// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person. -type ProofAnswer struct { - Accepted bool `json:"accepted"` - Words string `json:"words"` -} - -// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259 -// §2 to admit a node's engine as an asker): `node-engine-`, the last token of the by-caller subjects granted to that -// node's engine account alone, so no engine acts on another node's warrant. -func EngineAsker(node string) string { return "node-engine-" + node } diff --git a/go/asks/asks_test.go b/go/asks/asks_test.go deleted file mode 100644 index b9a7fdc..0000000 --- a/go/asks/asks_test.go +++ /dev/null @@ -1,367 +0,0 @@ -package asks - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "reflect" - "strings" - "testing" - "time" -) - -var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) - -func held() Ask { - return Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.", - Who: Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", About: "delivery.openrazer", - Options: []Option{ - {ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")}, - {ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")}, - {ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge}, - }} -} - -func bind(verb, what string) string { - d, err := ActDigest(Act{"verb": verb, "delivery": what}) - if err != nil { - panic(err) - } - return d -} - -func TestASoundAskPasses(t *testing.T) { - if err := held().Check(now); err != nil { - t.Fatal(err) - } -} - -func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) { - for want, change := range map[string]func(*Ask){ - "is not letters": func(a *Ask) { a.ID = "a.b" }, - "headline is empty": func(a *Ask) { a.Headline = "" }, - "explains nothing": func(a *Ask) { a.Explanation = " " }, - "only the operator answers": func(a *Ask) { a.Who = "agent" }, - "offers 0 options": func(a *Ask) { a.Options = nil }, - "offers 5 options": func(a *Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) }, - "is offered twice": func(a *Ask) { a.Options[1].ID = "release" }, - "does not say what it does": func(a *Ask) { a.Options[0].Does = "" }, - "none of acknowledge": func(a *Ask) { a.Options[0].Level = "admin" }, - "expires before it is asked": func(a *Ask) { a.Expires = now }, - "approves lasts at most": func(a *Ask) { a.Expires = now.Add(25 * time.Hour) }, - "destroys lasts at most": func(a *Ask) { a.Options[0].Level = Destroy }, - "never defaults": func(a *Ask) { a.OnExpiry = "" }, - "is a key, without spaces": func(a *Ask) { a.About = "a b" }, - "does not bind what it": func(a *Ask) { a.Options[0].Binds = "" }, - } { - a := held() - change(&a) - if err := a.Check(now); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("want a refusal saying %q, got %v", want, err) - } - } - ack := held() - ack.Options = ack.Options[2:] - ack.OnExpiry = "" - ack.Expires = now.Add(7 * 24 * time.Hour) - if err := ack.Check(now); err != nil { - t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err) - } -} - -func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) { - w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release", - Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, - AskDigest: held().Digest(), At: now.Add(time.Minute)} - if o, err := w.For("mesh-delivery", held()); err != nil || o.ID != "release" { - t.Fatalf("a sound warrant was refused: %v", err) - } - if got := w.Says(); got != "the operator, via telegram (user id verified), chose Release" { - t.Errorf("the warrant says %q", got) - } - for why, change := range map[string]func(*Warrant){ - "another asker": func(w *Warrant) { w.Asker = "mesh-controller" }, - "another ask": func(w *Warrant) { w.Ask = "d-2" }, - "no choice": func(w *Warrant) { w.Outcome = OutcomeExpired }, - "nobody": func(w *Warrant) { w.By = nil }, - "not offered": func(w *Warrant) { w.Option = "delete" }, - "a lower level": func(w *Warrant) { w.Level = Acknowledge }, - "another role": func(w *Warrant) { w.By = &Person{Who: "agent"} }, - "no digest": func(w *Warrant) { w.AskDigest = "" }, - "no time given": func(w *Warrant) { w.At = time.Time{} }, - "after expiry": func(w *Warrant) { w.At = now.Add(2 * time.Hour) }, - "another ask's digest": func(w *Warrant) { - other := held() - other.Options[0].Binds = bind("release", "another") - w.AskDigest = other.Digest() - }, - } { - c := w - by := *w.By - c.By = &by - change(&c) - if _, err := c.For("mesh-delivery", held()); err == nil { - t.Errorf("a warrant with %s was acted on", why) - } - } -} - -func TestTheSubjectsNameTheirPublisher(t *testing.T) { - for got, want := range map[string]string{ - AskSubject("mesh-delivery"): "mesh.seat.operator-channel.accept.ask.mesh-delivery", - DecidedSubject("mesh-delivery"): "mesh.seat.operator-channel.event.decided.mesh-delivery", - ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram", - IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop", - ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram", - } { - if got != want { - t.Errorf("%s, want %s", got, want) - } - } -} - -// A warrant answers one ask as it was shown, and authorises the act its option bound and no other -// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments). -func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) { - a := held() - // The digest survives the trip through the bus: what the router decodes and digests is what was sent. - raw, err := json.Marshal(a) - if err != nil { - t.Fatal(err) - } - var decoded Ask - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatal(err) - } - if decoded.Digest() != a.Digest() { - t.Fatalf("the ask's digest changed on the way: %s, %s", decoded.Digest(), a.Digest()) - } - local := a - local.Expires = a.Expires.In(time.FixedZone("CEST", 2*3600)) - if local.Digest() != a.Digest() { - t.Error("the same instant in another zone digests differently") - } - // Changing anything the person was shown, or what an option binds, changes the digest. - for what, change := range map[string]func(*Ask){ - "the headline": func(a *Ask) { a.Headline += "!" }, - "the explanation": func(a *Ask) { a.Explanation += " " }, - "a label": func(a *Ask) { a.Options[0].Label = "Go" }, - "what it binds": func(a *Ask) { a.Options[0].Binds = bind("release", "another") }, - "the expiry": func(a *Ask) { a.Expires = a.Expires.Add(time.Second) }, - "what it is about": func(a *Ask) { a.About = "delivery.other" }, - } { - c := held() - change(&c) - if c.Digest() == a.Digest() { - t.Errorf("changing %s leaves the digest", what) - } - } - o, _ := a.Option("release") - if err := o.Performs(Act{"verb": "release", "delivery": "openrazer"}); err != nil { - t.Errorf("the bound act was refused: %v", err) - } - if err := o.Performs(Act{"verb": "release", "delivery": "another"}); err == nil { - t.Error("an act the option did not bind was let through") - } - ack, _ := a.Option("silence") - if err := ack.Performs(Act{"anything": "x"}); err != nil { - t.Errorf("an acknowledgement that bound nothing: %v", err) - } -} - -// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's -// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another -// order changes nothing it should not — and every field that is shown or bound is in it. -func TestDigestsAreCanonicalOverNamedFields(t *testing.T) { - a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"}) - b := Act{} - b["arg.delivery"] = "hq" - b["verb"] = "release" - a2, _ := ActDigest(b) - if a1 != a2 { - t.Error("the order an act was built in changed its digest") - } - // A value cannot be read as two: "a"+"b=c" is not "a=b"+"c". - x, _ := ActDigest(Act{"a": "b\n1:c"}) - y, _ := ActDigest(Act{"a": "b", "c": ""}) - if x == y { - t.Error("two different acts encode the same") - } - if _, err := ActDigest(Act{}); err == nil { - t.Error("an empty act was digested") - } - // The encoding, by hand: any language reproduces it. - sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n")) - if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) { - t.Errorf("the act's encoding is not the one written down: %s", got) - } - - // Every exported field of Ask and Option is in the digest: changing any one changes it. - base := held() - mutations := map[string]func(*Ask){ - "ID": func(a *Ask) { a.ID = "other" }, - "Headline": func(a *Ask) { a.Headline += "!" }, - "Explanation": func(a *Ask) { a.Explanation += "!" }, - "Who": func(a *Ask) { a.Who = "someone" }, - "Expires": func(a *Ask) { a.Expires = a.Expires.Add(time.Nanosecond) }, - "OnExpiry": func(a *Ask) { a.OnExpiry += "!" }, - "About": func(a *Ask) { a.About += "!" }, - "Urgent": func(a *Ask) { a.Urgent = !a.Urgent }, - "Options": func(a *Ask) { a.Options = a.Options[:1] }, - "Option.ID": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].ID = "x" }, - "Option.Label": func(a *Ask) { - a.Options = append([]Option(nil), a.Options...) - a.Options[0].Label += "!" - }, - "Option.Does": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].Does += "!" }, - "Option.Level": func(a *Ask) { - a.Options = append([]Option(nil), a.Options...) - a.Options[0].Level = Destroy - }, - "Option.Binds": func(a *Ask) { - a.Options = append([]Option(nil), a.Options...) - a.Options[0].Binds = "sha256:00" - }, - } - for name, mutate := range mutations { - c := base - mutate(&c) - if c.Digest() == base.Digest() { - t.Errorf("changing %s left the digest the same", name) - } - } - // Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests - // exactly as before they existed — the encoding by hand, so no build can drift from it unseen. - plain := Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: now, OnExpiry: "o", About: "a", - Options: []Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}} - byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" + - "2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n")) - if plain.Digest() != "sha256:"+hex.EncodeToString(byHand[:]) { - t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", plain.Digest()) - } - withWhole, withDetails := plain, plain - withWhole.Whole = "e, whole" - withDetails.Details = "fingerprint 1234" - if withWhole.Digest() == plain.Digest() || withDetails.Digest() == plain.Digest() || withWhole.Digest() == withDetails.Digest() { - t.Error("Whole or Details left the digest the same") - } - both := withWhole - both.Details = "fingerprint 1234" - changed := both - changed.Whole += "!" - if changed.Digest() == both.Digest() { - t.Error("changing Whole beside Details left the digest the same") - } - // A field added to Ask or Option without being added to Digest fails here. - if n := reflect.TypeOf(Ask{}).NumField(); n != 12 { - t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n) - } - if n := reflect.TypeOf(Option{}).NumField(); n != 5 { - t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n) - } - // The same instant in another zone is the same ask. - z := base - z.Expires = z.Expires.In(time.FixedZone("x", 3600)) - if z.Digest() != base.Digest() { - t.Error("the expiry's zone changed the digest") - } -} - -// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could -// show the answer was in time. -func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) { - a := held() - a.Expires = time.Time{} - w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release", - Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: a.Digest(), - At: now.Add(time.Minute)} - if _, err := w.For("mesh-delivery", a); err == nil { - t.Error("a warrant for an ask with no expiry was taken") - } -} - -// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639). -func TestAnExplanationReadsAtAGlance(t *testing.T) { - if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil { - t.Fatalf("a summary and two points: %v", err) - } - var wall strings.Builder - wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.") - for i := 1; i <= 13; i++ { - wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3)) - } - err := Glance(wall.String()) - if err == nil || !strings.Contains(err.Error(), "13 points") { - t.Fatalf("thirteen clause lines pass as a glance: %v", err) - } - if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") { - t.Fatalf("a long summary passes: %v", err) - } - if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") { - t.Fatalf("a long point passes: %v", err) - } - if err := Glance(" \n"); err == nil { - t.Fatal("an empty explanation passes") - } -} - -// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older -// than it reads every message it read before. -func TestAMessageCarriesItsNoteApart(t *testing.T) { - raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."}) - if !strings.Contains(string(raw), `"note":"Asked by x."`) { - t.Fatalf("the note is not encoded: %s", raw) - } - raw, _ = json.Marshal(Message{Handle: "h", Title: "t"}) - if strings.Contains(string(raw), "note") { - t.Fatalf("an empty note is encoded: %s", raw) - } -} - -// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker -// of different builds still agree on every such ask. -func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) { - a := Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"} - before := a.Digest() - a.WholeDetails = "+ shares: media=/storage/media" - if a.Digest() == before { - t.Fatal("the whole details are not in the digest") - } - b := a - b.WholeDetails = "+ shares: media=/storage/other" - if a.Digest() == b.Digest() { - t.Fatal("two whole details digest the same") - } - a.WholeDetails = "" - if a.Digest() != before { - t.Fatal("an ask without whole details digests otherwise than before") - } -} - -// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as -// a Whole and Details that happen to spell the same values, nor as any other split of the three fields. -func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) { - base := Ask{ID: "a", Headline: "h", Explanation: "e"} - shapes := []Ask{} - for _, f := range []struct{ whole, details, wholeDetails string }{ - {"", "", "+ x: 1"}, - {"whole-details", "+ x: 1", ""}, - {"", "+ x: 1", ""}, - {"+ x: 1", "", ""}, - {"", "", "whole-details"}, - {"", "+ x: 1", "+ x: 1"}, - {"+ x: 1", "+ x: 1", ""}, - {"", "", ""}, - } { - a := base - a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails - shapes = append(shapes, a) - } - seen := map[string]int{} - for i, a := range shapes { - if j, ok := seen[a.Digest()]; ok { - t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a) - } - seen[a.Digest()] = i - } -} diff --git a/go/asks/condition.go b/go/asks/condition.go deleted file mode 100644 index 43aae39..0000000 --- a/go/asks/condition.go +++ /dev/null @@ -1,149 +0,0 @@ -package asks - -import ( - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" -) - -// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel -// seat, `mesh.seat.operator-channel.accept.condition.` (novox/hq ADR 0437 §9). The router reads the -// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is -// refused when empty or when it holds a dot, a wildcard or whitespace. -func ConditionSubject(publisher string) (string, error) { - if publisher == "" { - return "", errors.New("asks: the condition's publisher is empty") - } - if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) { - return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) - } - return "mesh.seat." + Seat + ".accept.condition." + publisher, nil -} - -// ConditionVersion is the version of the condition value this package writes and the only one it reads. -const ConditionVersion = 1 - -// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not -// ConditionVersion. Test it with errors.Is. -var ErrUnknownConditionVersion = errors.New("asks: unknown condition version") - -// The words a condition's explanation opens with (novox/hq ADR 0253 §1). -const ( - NothingToDo = "Nothing for you to do." - NeedsYou = "Needs you: " -) - -// The severities and states of a condition. -const ( - SeverityUrgent = "urgent" - SeverityWarning = "warning" - StateRaised = "raised" - StateCleared = "cleared" -) - -// Scope is what a condition is about, under the controller's names. -type Scope struct { - Scope string `json:"scope"` - ID string `json:"id"` - Machine string `json:"machine,omitempty"` -} - -// Action is one answer a notification offers, under the controller's names. -type Action struct { - Label string `json:"label"` - Verb string `json:"verb"` - Machine string `json:"machine,omitempty"` - Arguments map[string]string `json:"arguments,omitempty"` - Level Level `json:"level,omitempty"` -} - -// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key, -// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs -// only its key, subject and state. The router alone chooses the channels. -type Condition struct { - V int `json:"v"` - Key string `json:"key"` - Severity string `json:"severity,omitempty"` - Subject Scope `json:"subject"` - State string `json:"state"` - Summary string `json:"summary,omitempty"` - Headline string `json:"headline,omitempty"` - Needs string `json:"needs,omitempty"` - Explanation string `json:"explanation,omitempty"` - Resolved string `json:"resolved,omitempty"` - Actions []Action `json:"actions,omitempty"` -} - -func (c Condition) check() error { - if c.Key == "" { - return errors.New("asks: a condition has no key") - } - if c.Subject.Scope == "" || c.Subject.ID == "" { - return errors.New("asks: a condition's subject needs its scope and id") - } - switch c.State { - case StateCleared: - return nil - case StateRaised: - default: - return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State) - } - switch c.Severity { - case SeverityUrgent, SeverityWarning: - default: - return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity) - } - if c.Headline == "" { - return errors.New("asks: a raised condition has no headline") - } - if n := len([]rune(c.Headline)); n > HeadlineLength { - return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength) - } - verdict := NothingToDo - if c.Needs != "" { - verdict = NeedsYou + c.Needs - } - if !strings.HasPrefix(c.Explanation, verdict) { - return fmt.Errorf("asks: the condition's explanation must open with %q", verdict) - } - return nil -} - -// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse. -func EncodeCondition(c Condition) ([]byte, error) { - if err := c.check(); err != nil { - return nil, err - } - c.V = ConditionVersion - return json.Marshal(c) -} - -// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what -// EncodeCondition refuses. -func DecodeCondition(data []byte) (Condition, error) { - var probe struct { - V *int `json:"v"` - } - if err := json.Unmarshal(data, &probe); err != nil { - return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err) - } - if probe.V == nil { - return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) - } - if *probe.V != ConditionVersion { - return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) - } - var c Condition - if err := json.Unmarshal(data, &c); err != nil { - return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err) - } - if err := c.check(); err != nil { - return Condition{}, err - } - return c, nil -} - -// SameCondition says whether two conditions are the same value. -func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) } diff --git a/go/asks/condition_test.go b/go/asks/condition_test.go deleted file mode 100644 index 05ab6fa..0000000 --- a/go/asks/condition_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package asks - -import ( - "encoding/json" - "errors" - "strings" - "testing" -) - -func TestConditionSubject(t *testing.T) { // 1202/AC1 - s, err := ConditionSubject("mesh-delivery-teller") - if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" { - t.Fatalf("subject = %q, %v", s, err) - } - // The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5. - parts := strings.Split(s, ".") - if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" { - t.Errorf("subject %q does not keep the router's shape: %v", s, parts) - } - for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} { - if _, err := ConditionSubject(bad); err == nil { - t.Errorf("publisher %q accepted", bad) - } - } -} - -func raisedCondition() Condition { - return Condition{ - Key: "delivery.stuck.mesh-mail", Severity: "warning", - Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised", - Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck", - Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.", - Resolved: "The delivery of mesh-mail moved on.", - Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace", - Arguments: map[string]string{"why": ""}, Level: Approve}}, - } -} - -func clearedCondition() Condition { - return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"} -} - -func TestConditionRoundTrip(t *testing.T) { // 1202/AC2 - for _, c := range []Condition{raisedCondition(), clearedCondition()} { - data, err := EncodeCondition(c) - if err != nil { - t.Fatalf("%s: %v", c.State, err) - } - var probe map[string]any - if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) { - t.Fatalf("%s: %s does not carry v:1", c.State, data) - } - c.V = ConditionVersion - got, err := DecodeCondition(data) - if err != nil || !SameCondition(got, c) { - t.Errorf("%s round trip: %+v %v", c.State, got, err) - } - } - data, _ := EncodeCondition(raisedCondition()) - var probe map[string]any - _ = json.Unmarshal(data, &probe) - for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} { - if _, ok := probe[name]; !ok { - t.Errorf("the JSON has no %q: %s", name, data) - } - } - subject := probe["subject"].(map[string]any) - for _, name := range []string{"scope", "id", "machine"} { - if _, ok := subject[name]; !ok { - t.Errorf("the subject has no %q: %s", name, data) - } - } - action := probe["actions"].([]any)[0].(map[string]any) - for _, name := range []string{"label", "verb", "machine", "arguments", "level"} { - if _, ok := action[name]; !ok { - t.Errorf("the action has no %q: %s", name, data) - } - } - for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} { - if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) { - t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err) - } - } -} - -func TestConditionVerdict(t *testing.T) { // 1202/AC3 - c := raisedCondition() - c.Needs, c.Explanation = "", "The plan waits." - _, err := EncodeCondition(c) - if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") { - t.Errorf("empty needs without its verdict: %v", err) - } - c.Explanation = "Nothing for you to do. The plan waits." - if _, err := EncodeCondition(c); err != nil { - t.Errorf("empty needs with its verdict refused: %v", err) - } - c = raisedCondition() - c.Explanation = "Nothing for you to do. The plan waits." - _, err = EncodeCondition(c) - if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") { - t.Errorf("needs set with the wrong verdict: %v", err) - } - c.Explanation = "Needs you: something else. The plan waits." - if _, err = EncodeCondition(c); err == nil { - t.Error("needs set with other needs in the verdict accepted") - } -} - -func TestConditionRefused(t *testing.T) { // 1202/AC4 - mutate := func(f func(*Condition)) error { - c := raisedCondition() - f(&c) - _, err := EncodeCondition(c) - return err - } - for name, f := range map[string]func(*Condition){ - "severity": func(c *Condition) { c.Severity = "critical" }, - "state": func(c *Condition) { c.State = "open" }, - "key": func(c *Condition) { c.Key = "" }, - "headline": func(c *Condition) { c.Headline = "" }, - "long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) }, - } { - if err := mutate(f); err == nil { - t.Errorf("a condition with a bad %s accepted", name) - } - } - if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil { - t.Errorf("a headline of the greatest length refused: %v", err) - } - if _, err := EncodeCondition(clearedCondition()); err != nil { - t.Errorf("a cleared condition with only key, subject and state refused: %v", err) - } - c := clearedCondition() - c.Key = "" - if _, err := EncodeCondition(c); err == nil { - t.Error("a cleared condition without its key accepted") - } - c = clearedCondition() - c.State = "gone" - if _, err := EncodeCondition(c); err == nil { - t.Error("a condition with an unknown state accepted") - } -} diff --git a/go/channel/channel.go b/go/channel/channel.go new file mode 100644 index 0000000..a9b1d54 --- /dev/null +++ b/go/channel/channel.go @@ -0,0 +1,141 @@ +// Package channel holds the shapes of the two kinded benches a channel holds, `channel` (to show and say) and `intake` +// (to say what was chosen, and to ask a proof), and the subjects they travel on (novox/hq ADR 0259 §3). The router +// sends a channel a Message, and a channel says a Choice, a LinkAsked, a Failed or its Standing, or asks a Code's proof. +// Those benches are not defined whole, so their value layouts stay in mesh-sdk (novox/hq ADR 0625 §9, ADR 0627 §4); +// the operator-channel seat's own ask and warrant are its contract's and its client's, in mesh-contracts. +package channel + +import "time" + +// The benches (novox/hq ADR 0259 §3). +const ( + // ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind. + ChannelSeat = "channel" + // IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry + // the kind. + IntakeSeat = "intake" +) + +// The work a channel takes, on ChannelSubject. +const ( + Show = "show" // a message offering answers + Edit = "edit" // a message shown before, replaced + Send = "send" // a message offering nothing +) + +// ChannelSubject is where the router sends a channel of a kind its work. +func ChannelSubject(verb, kind string) string { + return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind +} + +// What a channel says, on IntakeSubject. +const ( + Chosen = "choice" // a button tapped + Link = "link" // somebody asked to be linked as the operator +) + +// IntakeSubject is where a channel of a kind says what arrived. +func IntakeSubject(what, kind string) string { + return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind +} + +// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept. +const CodeProof = "code" + +// ProofSubject is where a channel of a kind asks a proof. +func ProofSubject(verb, kind string) string { + return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind +} + +// Button is one answer a channel offers: its label and the router's one-time ticket for it. +type Button struct { + Label string `json:"label"` + Ticket string `json:"ticket"` +} + +// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said +// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps +// which of its own messages that is. The words are the router's, shown as given. +type Message struct { + Handle string `json:"handle"` + Title string `json:"title"` + Body string `json:"body"` + Buttons []Button `json:"buttons,omitempty"` + // Note is the message's secondary words, after its body and its buttons: who asked and when, what each + // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart + // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's + // length cuts the body first and the note only when the title and the note alone do not fit, saying each + // cut (novox/hq ADR 0347 §5). + Note string `json:"note,omitempty"` + Urgent bool `json:"urgent,omitempty"` + Silent bool `json:"silent,omitempty"` + // Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made. + Reply string `json:"reply,omitempty"` + // To is the account linked as the operator on this kind, for a channel that verifies its sender: where + // the channel sends what is not a reply. The router's word, from its list; empty when none is linked. + To string `json:"to,omitempty"` + // Secret says the words carry something shown once (a link's code): the channel shows it and keeps no + // copy of it — no state, no history of its own. + Secret bool `json:"secret,omitempty"` +} + +// Sender is who a channel's service says sent something: the account, the name it shows, and whether the +// service authenticated it. The router alone judges whether that is the operator. +type Sender struct { + Identity string `json:"identity"` + Display string `json:"display,omitempty"` + Authenticated bool `json:"authenticated"` +} + +// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help. +type Failed struct { + Handle string `json:"handle"` + Why string `json:"why"` + Permanent bool `json:"permanent,omitempty"` + At time.Time `json:"at"` +} + +// Standing is what a channel says of itself, at least every five minutes and whenever it changes: +// whether it can send now, why not, and whether its edits notify nobody. +type Standing struct { + Ready bool `json:"ready"` + Why string `json:"why,omitempty"` + EditsSilently bool `json:"edits-silently,omitempty"` + At time.Time `json:"at"` +} + +// What a channel says of its own delivery, on IntakeSubject. +const ( + FailedWhat = "failed" + StandingWhat = "standing" +) + +// Choice is a button chosen on a channel. +type Choice struct { + // ID is the channel's own for this arrival, unique, so the router acts on it once. + ID string `json:"id"` + Ticket string `json:"ticket"` + Handle string `json:"handle,omitempty"` + Sender Sender `json:"sender"` + At time.Time `json:"at"` +} + +// LinkAsked is somebody on a channel asking to be linked as the operator. +type LinkAsked struct { + ID string `json:"id"` + Sender Sender `json:"sender"` + At time.Time `json:"at"` +} + +// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept. +type Code struct { + Sender Sender `json:"sender"` + Code string `json:"code"` + Purpose string `json:"purpose"` +} + +// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person. +type ProofAnswer struct { + Accepted bool `json:"accepted"` + Words string `json:"words"` +} diff --git a/go/channel/channel_test.go b/go/channel/channel_test.go new file mode 100644 index 0000000..bdcb31d --- /dev/null +++ b/go/channel/channel_test.go @@ -0,0 +1,32 @@ +package channel + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestTheSubjectsNameTheirPublisher(t *testing.T) { + for got, want := range map[string]string{ + ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram", + IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop", + ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram", + } { + if got != want { + t.Errorf("%s, want %s", got, want) + } + } +} + +// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older +// than it reads every message it read before. +func TestAMessageCarriesItsNoteApart(t *testing.T) { + raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."}) + if !strings.Contains(string(raw), `"note":"Asked by x."`) { + t.Fatalf("the note is not encoded: %s", raw) + } + raw, _ = json.Marshal(Message{Handle: "h", Title: "t"}) + if strings.Contains(string(raw), "note") { + t.Fatalf("an empty note is encoded: %s", raw) + } +} diff --git a/go/delivery/gate.go b/go/delivery/gate.go index 450fa5b..c6b593b 100644 --- a/go/delivery/gate.go +++ b/go/delivery/gate.go @@ -1,6 +1,7 @@ package delivery import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -10,8 +11,6 @@ import ( "sort" "strconv" "strings" - - "git.novox.be/novox/mesh-sdk/go/asks" ) // The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected @@ -131,7 +130,7 @@ func ParseAskKey(key string) (kind, group, hash string, err error) { // AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string. func AnswerKey(askKey string) string { return askKey } -// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID), +// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (its usable id), // so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and // the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back: // the teller keeps the key beside it. @@ -329,12 +328,15 @@ func (m Merge) check() error { } // Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it -// unchanged, and the group and heads it was composed for. +// unchanged, and the group and heads it was composed for. The ask is the operator-channel seat's (mesh-contracts +// operatorchannel.Ask), carried here as its JSON: mesh-sdk names no seat defined whole (novox/hq ADR 0627 §4), so its +// id (AskingID of its key and asking), its options and its digest are checked by the gate and the teller through +// operator-channel's client. type Ask struct { - V int `json:"v"` - Group string `json:"group"` - Heads []Head `json:"heads"` - Ask asks.Ask `json:"ask"` + V int `json:"v"` + Group string `json:"group"` + Heads []Head `json:"heads"` + Ask json.RawMessage `json:"ask"` // Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th. // The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id // (AskingID); the first asking's id is unchanged and the field is omitted for it. @@ -345,15 +347,18 @@ func (a Ask) check() error { if a.Asking < 0 { return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) } - key, err := MergeAskKey(a.Group, a.Heads) - if err != nil { + if _, err := MergeAskKey(a.Group, a.Heads); err != nil { return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err) } - if want := AskingID(key, a.Asking); a.Ask.ID != want { - return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group, heads and asking %d give", a.Ask.ID, want, a.Asking) - } - if len(a.Ask.Options) == 0 { - return fmt.Errorf("delivery: the ask has no option") + return routerAsk(a.Ask) +} + +// routerAsk refuses a value that carries no router's ask: none, null, or JSON that is no object. What the ask says +// is operator-channel's to check, through its client (novox/hq ADR 0627 §4). +func routerAsk(raw json.RawMessage) error { + var fields map[string]json.RawMessage + if len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil { + return fmt.Errorf("delivery: the value carries no router's ask") } return nil } diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index 0927506..4cb2c40 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -3,12 +3,11 @@ package delivery import ( "encoding/json" "errors" + "regexp" "strconv" "strings" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/asks" ) func gateHeads() []Head { @@ -66,7 +65,7 @@ func TestAskID(t *testing.T) { // 1201/AC2 if strings.Contains(id, ".") { t.Errorf("id %q holds a dot", id) } - if !asks.UsableID(id) { + if !routerUsableID(id) { t.Errorf("id %q is not usable by the router", id) } if other, dup := seen[id]; dup { @@ -83,7 +82,7 @@ func TestAskID(t *testing.T) { // 1201/AC2 if g != SettleGroup([]string{"novox/a@111111111111", "novox/b@222222222222"}) || !strings.HasPrefix(g, SettleGroupPrefix) || len(g) != len(SettleGroupPrefix)+12 { t.Errorf("SettleGroup = %q", g) } - if k, err := MergeAskKey(g, gateHeads()); err != nil || !asks.UsableID(AskID(k)) { + if k, err := MergeAskKey(g, gateHeads()); err != nil || !routerUsableID(AskID(k)) { t.Errorf("the settle ask's key %q: %v", k, err) } for group, want := range map[string]string{g: "mesh-delivery.settle", "feat/x": "mesh-delivery.merge", @@ -149,13 +148,22 @@ func sampleTurn() Turn { func sampleAsk() Ask { key, _ := MergeAskKey("feat/x", gateHeads()) - return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{ - ID: AskID(key), Headline: "Merge into main: group feat/x", Explanation: "The group passed its checks.", - Who: asks.Operator, Expires: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), OnExpiry: "nothing is merged", - Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}, - }} + return Ask{Group: "feat/x", Heads: gateHeads(), Ask: routerAskJSON(AskID(key), "Merge into main: group feat/x")} } +// routerAskJSON is a router's ask as the gate carries it: the operator-channel seat's JSON, which mesh-sdk does not +// type (novox/hq ADR 0627 §4). +func routerAskJSON(id, headline string) json.RawMessage { + raw, _ := json.Marshal(map[string]any{"id": id, "headline": headline, "explanation": "The group passed its checks.", + "who": "operator", "expires": "2026-10-20T08:00:00Z", "on-expiry": "nothing is merged", + "options": []map[string]string{{"id": "approve", "label": "Approve", "does": "merges", "level": "approve", "binds": "sha256:00"}}}) + return raw +} + +// routerUsableID is the router's rule for an ask's id (operator-channel's client, UsableID), held here to check that +// AskID and AskingID give ids the router takes. +var routerUsableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`).MatchString + func sampleOwed() []Owed { return []Owed{ {Act: OwedStatus, Context: "mesh/merge-gate", State: "success", Target: "https://mesh.example/delivery/36"}, @@ -271,13 +279,11 @@ func TestGateValuesRefused(t *testing.T) { // 1201/AC5 wantErr("merge without its ask", err, "ask") a := sampleAsk() - a.Ask.ID = "something-else" - _, err = EncodeAsk(a) - wantErr("ask with another id", err, "something-else", AskID("merge.feat/x."+mustHash(gateHeads()))) - a = sampleAsk() - a.Ask.Options = nil - _, err = EncodeAsk(a) - wantErr("ask without options", err, "option") + for _, raw := range []string{"", "null", "[]", `"ask"`} { + a.Ask = json.RawMessage(raw) + _, err = EncodeAsk(a) + wantErr("a value with no router's ask "+raw, err, "no router's ask") + } a = sampleAsk() a.Heads = nil _, err = EncodeAsk(a) @@ -343,7 +349,7 @@ func TestGateSame(t *testing.T) { // 1201/AC6 t.Error("different merges are the same") } a := sampleAsk() - a.Ask.Headline = "other" + a.Ask = routerAskJSON(AskID("merge.feat/x."+mustHash(gateHeads())), "other") if SameAsk(a, sampleAsk()) { t.Error("different asks are the same") } @@ -414,7 +420,7 @@ func TestKeysEscapeAndRoundTrip(t *testing.T) { // 1323/AC1 t.Error("answer key differs from the ask key") } // the ask and the answer of such a group encode and decode - ask := Ask{Group: name, Heads: gateHeads(), Ask: asks.Ask{ID: AskID(ak), Options: []asks.Option{{ID: "ok"}}}} + ask := Ask{Group: name, Heads: gateHeads(), Ask: routerAskJSON(AskID(ak), "ok")} if data, err := EncodeAsk(ask); err != nil { t.Errorf("encode ask for %q: %v", name, err) } else if _, err := DecodeAsk(data); err != nil { @@ -600,7 +606,7 @@ func TestAskingID(t *testing.T) { // 1370/AC1 t.Errorf("askings %d and %d give the id %q", n, other, id) } seen[id] = n - if len(id) > 64 || !asks.UsableID(id) { + if len(id) > 64 || !routerUsableID(id) { t.Errorf("id %q of asking %d is not usable by the router", id, n) } if id != AskID(key+"."+strconv.Itoa(n)) { @@ -621,7 +627,7 @@ func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2 t.Fatalf("asking 1 under the first id: %v", err) } a.Asking = 3 - a.Ask.ID = AskingID(key, 3) + a.Ask = routerAskJSON(AskingID(key, 3), "Merge into main: group feat/x") data, err := EncodeAsk(a) if err != nil { t.Fatalf("asking 3 under its id: %v", err) @@ -630,15 +636,6 @@ func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2 if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) || got.Asking != 3 { t.Errorf("asking round trip: %+v %v", got, err) } - a.Ask.ID = AskingID(key, 2) - if _, err := EncodeAsk(a); err == nil { - t.Error("asking 3 under asking 2's id was accepted") - } - a.Asking = 2 - a.Ask.ID = AskID(key) - if _, err := EncodeAsk(a); err == nil { - t.Error("asking 2 under the first asking's id was accepted") - } a.Asking = -1 if _, err := EncodeAsk(a); err == nil { t.Error("a negative asking was accepted") diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go index 9d3233e..4ba3bfa 100644 --- a/go/delivery/view_answers.go +++ b/go/delivery/view_answers.go @@ -1,11 +1,10 @@ package delivery import ( + "bytes" "encoding/json" "fmt" "strings" - - "git.novox.be/novox/mesh-sdk/go/asks" ) // The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The @@ -221,11 +220,13 @@ func ParseViewAnswerKey(key string) (askKey string, err error) { // AssignerAsk is the value of a key in AssignerAsksBucket: the router's ask whole, as the assigner composes it, so the // teller publishes it unchanged. Asking counts the times the assigner wrote the ask at this key, as the gate's Ask // does: 0 or 1 for the first, n for the n-th, and the ask's id is AskingID(key, Asking), so each asking has its own id -// and an answer to an earlier asking is never taken for a later one. +// and an answer to an earlier asking is never taken for a later one. The ask is the operator-channel seat's +// (mesh-contracts operatorchannel.Ask) as its JSON, as in the gate's Ask: its id, its options and its digest are +// checked by the assigner and the teller through operator-channel's client (novox/hq ADR 0627 §4). type AssignerAsk struct { - V int `json:"v"` - Ask asks.Ask `json:"ask"` - Asking int `json:"asking,omitempty"` + V int `json:"v"` + Ask json.RawMessage `json:"ask"` + Asking int `json:"asking,omitempty"` } func (a AssignerAsk) check(key string) error { @@ -239,17 +240,11 @@ func (a AssignerAsk) check(key string) error { if a.Asking < 0 { return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) } - if want := AskingID(key, a.Asking); a.Ask.ID != want { - return fmt.Errorf("delivery: the ask's id %q is not %q, the id its key and asking %d give", a.Ask.ID, want, a.Asking) - } - if len(a.Ask.Options) == 0 { - return fmt.Errorf("delivery: the ask has no option") - } - return nil + return routerAsk(a.Ask) } // EncodeAssignerAsk is a, the ask at key, as JSON at Version; it refuses a key of a kind that needs proof or of no -// kind, an id other than the one the key and the asking give, and an ask with no option. +// kind, a negative asking, and a value carrying no router's ask. func EncodeAssignerAsk(key string, a AssignerAsk) ([]byte, error) { if err := a.check(key); err != nil { return nil, err @@ -271,9 +266,18 @@ func DecodeAssignerAsk(key string, data []byte) (AssignerAsk, error) { return a, nil } -// SameAssignerAsk says whether two asks are the same value. +// SameAssignerAsk says whether two asks are the same value: the same version, asking and router's ask, its JSON +// compared compacted. func SameAssignerAsk(a, b AssignerAsk) bool { - return a.V == b.V && a.Asking == b.Asking && a.Ask.Digest() == b.Ask.Digest() + return a.V == b.V && a.Asking == b.Asking && bytes.Equal(compacted(a.Ask), compacted(b.Ask)) +} + +func compacted(raw json.RawMessage) []byte { + var b bytes.Buffer + if json.Compact(&b, raw) != nil { + return raw + } + return b.Bytes() } // ViewAnswer is the view's answer to an ask: the id of the asking it answers (the ask's id, AskingID of its key and @@ -295,21 +299,14 @@ func (a ViewAnswer) check() error { return nil } -// ViewAnswerFits says whether the view's answer a answers the ask as it stands now: it refuses an answer to another -// asking of the ask (a stale answer to an earlier writing of the same key) and an option the ask does not offer, -// naming both. -func ViewAnswerFits(a ViewAnswer, ask AssignerAsk) error { - if a.Asking != ask.Ask.ID { - return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, ask.Ask.ID) - } - o, ok := ask.Ask.Option(a.Option) - if !ok { - return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID) - } - if o.Level != "" && o.Level != asks.Acknowledge { - // An option that authorises is answered on a channel that proves who answered (ADR 0259 §5, ADR 0546 clause 8), - // never in the view, which proves nobody. - return fmt.Errorf("delivery: the view's answer chose %q, an option of level %s, which only a channel that proves who answered takes", a.Option, o.Level) +// ViewAnswerFits says whether the view's answer a answers the ask at key as it stands now: it refuses an answer to +// another asking of the ask (a stale answer to an earlier writing of the same key), naming both. Whether the ask +// offers the option chosen, and at the level acknowledge only (an option that authorises is answered on a channel +// that proves who answered, ADR 0259 §5, ADR 0546 clause 8, never in the view, which proves nobody), the teller reads +// from the ask through operator-channel's client (novox/hq ADR 0627 §4). +func ViewAnswerFits(a ViewAnswer, key string, ask AssignerAsk) error { + if want := AskingID(key, ask.Asking); a.Asking != want { + return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, want) } return nil } diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go index df69b2f..2d44d4b 100644 --- a/go/delivery/view_answers_test.go +++ b/go/delivery/view_answers_test.go @@ -1,11 +1,9 @@ package delivery import ( + "encoding/json" "strings" "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/asks" ) // 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an @@ -110,13 +108,14 @@ func holdAsk(t *testing.T, asking int) (string, AssignerAsk) { if err != nil { t.Fatal(err) } - return key, AssignerAsk{Asking: asking, Ask: asks.Ask{ID: AskingID(key, asking), Headline: "mesh-mail is held", Explanation: "its health failed", - Options: []asks.Option{{ID: "release", Label: "Release"}, {ID: "keep", Label: "Keep held"}}, Who: "operator", - Expires: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC), OnExpiry: "it stays held"}} + raw, _ := json.Marshal(map[string]any{"id": AskingID(key, asking), "headline": "mesh-mail is held", + "explanation": "its health failed", "who": "operator", "expires": "2026-10-30T00:00:00Z", "on-expiry": "it stays held", + "options": []map[string]string{{"id": "release", "label": "Release"}, {"id": "keep", "label": "Keep held"}}}) + return key, AssignerAsk{Asking: asking, Ask: raw} } -// 1297/B1c: the assigner's ask round-trips at its key; an id another key or asking gives, an ask of a kind that needs -// proof and one with no option are refused. +// 1297/B1c: the assigner's ask round-trips at its key; an ask of a kind that needs proof, a negative asking and a value +// carrying no router's ask are refused (its id and options are the teller's to check, ADR 0627 §4). func TestAssignerAskRoundTrip(t *testing.T) { key, a := holdAsk(t, 2) data, err := EncodeAssignerAsk(key, a) @@ -131,46 +130,39 @@ func TestAssignerAskRoundTrip(t *testing.T) { if err != nil || !SameAssignerAsk(got, a) { t.Errorf("the ask came back %+v, %v", got, err) } - other, _ := HoldAskKey("mesh-web", fp2of('f')) - if _, err := DecodeAssignerAsk(other, data); err == nil { - t.Errorf("an ask was read at another key") - } stale := a - stale.Asking = 3 + stale.Asking = -1 if _, err := EncodeAssignerAsk(key, stale); err == nil { - t.Errorf("an ask whose id is another asking's was accepted") + t.Errorf("a negative asking was accepted") } merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) - if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: asks.Ask{ID: AskingID(merge, 0), Options: a.Ask.Options}}); err == nil { + if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: a.Ask}); err == nil { t.Errorf("the assigner was let write a merge's ask") } - none := a - none.Ask.Options = nil - if _, err := EncodeAssignerAsk(key, none); err == nil { - t.Errorf("an ask with no option was accepted") + for _, raw := range []string{"", "null", "[1]"} { + none := a + none.Ask = json.RawMessage(raw) + if _, err := EncodeAssignerAsk(key, none); err == nil || !strings.Contains(err.Error(), "no router's ask") { + t.Errorf("a value carrying %q as its ask: %v", raw, err) + } + } + spaced := a + spaced.Ask = json.RawMessage(" " + string(a.Ask) + "\n") + if !SameAssignerAsk(spaced, a) { + t.Errorf("the same ask written with spaces is another") } } // 1297/B1c: a view answer fits only the asking it names: an answer to an earlier asking of the same key is refused, -// naming both, and so is an option the ask does not offer. +// naming both. Whether the option is offered, at the level acknowledge, the teller reads (ADR 0627 §4). func TestViewAnswerFits(t *testing.T) { key, first := holdAsk(t, 0) _, second := holdAsk(t, 2) answer := ViewAnswer{Asking: AskingID(key, 0), Option: "release"} - if err := ViewAnswerFits(answer, first); err != nil { + if err := ViewAnswerFits(answer, key, first); err != nil { t.Errorf("the answer to the first asking does not fit it: %v", err) } - if err := ViewAnswerFits(answer, second); err == nil || !strings.Contains(err.Error(), first.Ask.ID) || !strings.Contains(err.Error(), second.Ask.ID) { + if err := ViewAnswerFits(answer, key, second); err == nil || !strings.Contains(err.Error(), AskingID(key, 0)) || !strings.Contains(err.Error(), AskingID(key, 2)) { t.Errorf("a stale answer fits the later asking, or the refusal names neither: %v", err) } - if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil { - t.Errorf("an option the ask does not offer fits") - } - // An option that authorises is never answered in the view (ADR 0546 clause 8). - approving := first - approving.Ask.Options = append([]asks.Option(nil), first.Ask.Options...) - approving.Ask.Options[0].Level = asks.Approve - if err := ViewAnswerFits(answer, approving); err == nil || !strings.Contains(err.Error(), "approve") { - t.Errorf("the view answered an option of level approve: %v", err) - } } diff --git a/go/packages_list_test.go b/go/packages_list_test.go index be88d7d..918ced6 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -23,7 +23,7 @@ import ( // Logic only some modules need is its owner module's package. A package added here is a decision to say in the pull // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ - ".", "announce", "announcetest", "asker", "asks", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", + ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "testbus", "views", @@ -39,14 +39,9 @@ var clientSeats = []string{contractseats.OperatorChannel, contractseats.MeshVaul var notInSDK = []string{"asks", "asker", "publish"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below -// would refuse; it only shrinks, and is empty when the group is pinned: asks and asker are operator-channel's client -// (mesh-contracts go/clients/operatorchannelclient) once delivery's gate and assigner asks no longer hold the -// router's ask as asks.Ask; the vault module's name in delivery and provisions goes with the vault's client. +// would refuse; it only shrinks, and is empty when the group is pinned: the vault module's name in delivery and +// provisions goes when the vault's answers are its contract's. var untilTheJoin = []string{ - "asks/asks.go: imports git.novox.be/novox/mesh-contracts/go/seats", - "asks/asks.go: Seat", - "asks: a package that moved", - "asker: a package that moved", "delivery/signers.go: mesh-vault", "provisions/provisions.go: mesh-vault", } diff --git a/go/process/state.go b/go/process/state.go index 4d44d44..4011ef9 100644 --- a/go/process/state.go +++ b/go/process/state.go @@ -24,7 +24,7 @@ const stateTimeout = 30 * time.Second // State is one of this process's module's states, read and written on this connection (novox/hq ADR 0436 // clause 4), in the bucket _ (mesh-controller internal/broker/state.go:49) — only as the -// account's grants allow. It satisfies go/asker's Store. +// account's grants allow. It satisfies operator-channel's client's Store (mesh-contracts operatorchannelclient). type State struct { Module string Name string diff --git a/go/process/state_test.go b/go/process/state_test.go index 7c9cfb9..6950504 100644 --- a/go/process/state_test.go +++ b/go/process/state_test.go @@ -12,7 +12,6 @@ import ( "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/busretry" ) @@ -137,10 +136,18 @@ func stateOn(kv jetstream.KeyValue) State { return State{Module: "mesh-mail", Name: "sends", kv: func(context.Context) (jetstream.KeyValue, error) { return kv, nil }} } -// 1188/AC2: given the process connection's state type, when used as go/asker's Store, then it compiles and -// behaves as the interface says, against a fake KV. +// askerStore is the store operator-channel's client keeps its asks in (mesh-contracts operatorchannelclient.Store, +// whose own test holds process.State to it). +type askerStore interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) +} + +// 1188/AC2: given the process connection's state type, when used as operator-channel's client's Store, then it +// compiles and behaves as the interface says, against a fake KV. func TestStateSatisfiesAskerStore(t *testing.T) { - var _ asker.Store = State{} + var _ askerStore = State{} s := stateOn(newFakeKV()) if e, err := s.Get("ask.1"); err != nil || e != nil { diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 2f9e3b4..0c08efc 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -1,4 +1,3 @@ -asks/asks.go: Seat buscompose/health_grants.go: mesh.seat.mesh-self-check.event. catalogue/artifact_sweep_seat.go: ArtifactSweepSeat catalogue/artifacts.go: ContainerImageRegistrySeat -- 2.54.0 From ce592a94d0698031831b7d8190b05b7a52995faf Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:27:16 +0100 Subject: [PATCH 042/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20a=20holder=20bi?= =?UTF-8?q?nds=20the=20receiving=20side=20to=20its=20own=20objects=20and?= =?UTF-8?q?=20transfers=20state=20through=20the=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every holder of a registry seat needs the same two adapters over its bus state; kept here so none writes its own. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/parts/bus.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 go/parts/bus.go diff --git a/go/parts/bus.go b/go/parts/bus.go new file mode 100644 index 0000000..c879c97 --- /dev/null +++ b/go/parts/bus.go @@ -0,0 +1,66 @@ +package parts + +import ( + "encoding/json" + "errors" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/busstate" +) + +// BusObjects is a Receiver's Objects over the holder's own object store, asked for in `requires` by local (the +// template objects, novox/hq ADR 0595 §2), through the runtime: every holder binds the same adapter, none its own. +func BusObjects(local string) Objects { return busObjects{busstate.ObjectsOf(local)} } + +type busObjects struct{ o busstate.Objects } + +func (b busObjects) Put(name string, data []byte) error { + _, err := b.o.Put(name, data, "") + return err +} +func (b busObjects) Get(name string) ([]byte, error) { + d, err := b.o.Get(name) + return d.Data, err +} +func (b busObjects) Delete(name string) error { return b.o.Delete(name) } +func (b busObjects) Used() (uint64, error) { + f, err := b.o.Fill() + return f.Bytes, err +} + +// BusTransfers is a Receiver's Transfers over the holder's own key-value state of that name (the template state), +// through the runtime, each transfer under its id. +func BusTransfers[M any](state string) Transfers[M] { return busTransfers[M]{state} } + +type busTransfers[M any] struct{ state string } + +func (b busTransfers[M]) Get(id string) (Transfer[M], bool, error) { + var t Transfer[M] + e, err := stdio.State(b.state).Get(id) + if err != nil || e == nil { + return t, false, err + } + return t, true, json.Unmarshal(e.Value, &t) +} +func (b busTransfers[M]) Put(t Transfer[M]) error { + if t.ID == "" { + return errors.New("a transfer without an id cannot be kept") + } + _, err := stdio.State(b.state).Put(t.ID, t) + return err +} +func (b busTransfers[M]) Delete(id string) error { return stdio.State(b.state).Delete(id) } +func (b busTransfers[M]) List() ([]Transfer[M], error) { + keys, err := stdio.State(b.state).Keys() + if err != nil { + return nil, err + } + var out []Transfer[M] + for _, k := range keys { + if t, ok, err := b.Get(k); err == nil && ok { + out = append(out, t) + } + } + return out, nil +} + -- 2.54.0 From 44a97d9ad63a37f2568ec83308f403fa43d62a11 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:27:21 +0100 Subject: [PATCH 043/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20gofmt=20the=20b?= =?UTF-8?q?us=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/parts/bus.go | 1 - 1 file changed, 1 deletion(-) diff --git a/go/parts/bus.go b/go/parts/bus.go index c879c97..a9327fc 100644 --- a/go/parts/bus.go +++ b/go/parts/bus.go @@ -63,4 +63,3 @@ func (b busTransfers[M]) List() ([]Transfer[M], error) { } return out, nil } - -- 2.54.0 From 7faa4a1b20b8c62ce0af26fd4208eef856850ce6 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:29:47 +0100 Subject: [PATCH 044/144] =?UTF-8?q?ADR=200627=20=C2=A71:=20the=20vault's?= =?UTF-8?q?=20answers,=20verbs=20and=20signed=20event=20are=20its=20contra?= =?UTF-8?q?ct's,=20not=20mesh-sdk's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignAnswer, Signed, EnrolmentTokenAnswer, NodeAccountAnswer and the names SignVerb, SignedEvent, SignedSubject, EnrolmentTokenVerb, NodeAccountVerb go; mesh-contracts meshvault holds them. The signers state, the kinds, the signing contexts and the redeem stay: they are the vault module's and the enrolment's, not the seat's. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/enrolment.go | 30 +++--------------------------- go/delivery/signers.go | 29 ++--------------------------- 2 files changed, 5 insertions(+), 54 deletions(-) diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index 24b624e..4526bbd 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -303,30 +303,6 @@ func (a RedeemAnswer) SignedBytes() []byte { return raw } -// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone. -const ( - EnrolmentTokenVerb = "enrolment-token" - NodeAccountVerb = "node-account" -) - -// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its -// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed -// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token -// nor its hash. -type EnrolmentTokenAnswer struct { - Node string `json:"node"` - Broker string `json:"broker,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - Signer string `json:"signer,omitempty"` - Sealed string `json:"sealed,omitempty"` - Dropped bool `json:"dropped,omitempty"` -} - -// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node -// presented (base64), never in the clear; Retired says a retire left no account. -type NodeAccountAnswer struct { - Node string `json:"node"` - Login string `json:"login,omitempty"` - Account string `json:"account,omitempty"` - Retired bool `json:"retired,omitempty"` -} +// The vault's enrolment verbs (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone, and their answers are the +// vault's contract's (mesh-contracts meshvault: VerbEnrolmentToken, EnrolmentTokenAnswer, VerbNodeAccount, +// NodeAccountAnswer; novox/hq ADR 0627 §1). diff --git a/go/delivery/signers.go b/go/delivery/signers.go index 04c86bf..5a6b435 100644 --- a/go/delivery/signers.go +++ b/go/delivery/signers.go @@ -39,11 +39,8 @@ const ( SignersKeyKey = "key" SignersAuthorityKey = "authority" - // SignVerb is the vault's verb that signs. - SignVerb = "sign" - // SignedEvent is what the vault says when it signed (Signed), on SignedSubject. - SignedEvent = "signed" - SignedSubject = "mesh.mod." + VaultModule + ".event." + SignedEvent + // The verb sign, its answer and the event signed are the vault's contract's (mesh-contracts meshvault: VerbSign, + // SignAnswer, HolderEventSigned, HolderSubjectSigned, SignedHolderEvent; novox/hq ADR 0627 §1). // KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone. KindNodeCertificate = "node-certificate" @@ -200,28 +197,6 @@ func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte { return ed25519.Sign(key, append([]byte(context), msg...)) } -// Signed is the vault's `signed`: who asked, the kind, the node, and the sha256 digest of what was signed. Never a key. -type Signed struct { - Caller string `json:"caller"` - Kind string `json:"kind"` - Node string `json:"node,omitempty"` - Digest string `json:"digest"` - Signer string `json:"signer"` - At time.Time `json:"at"` -} - -// SignAnswer is what `sign` answers: for a node certificate the certificate as PEM and the authority that signed it, -// for an enrolment the signature in base64; always the signer's fingerprint and the digest. Never a key's private half. -type SignAnswer struct { - Kind string `json:"kind"` - Node string `json:"node,omitempty"` - Certificate string `json:"certificate,omitempty"` - Authority string `json:"authority,omitempty"` - Signature string `json:"signature,omitempty"` - Signer string `json:"signer"` - Digest string `json:"digest"` -} - // DigestOf is the digest a `signed` names: `sha256:` of the bytes signed. func DigestOf(b []byte) string { sum := sha256.Sum256(b) -- 2.54.0 From a410447ff5fb68088866f0042edefe44d5411c71 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:31:16 +0100 Subject: [PATCH 045/144] SD-M1: a holder's event kept in its own stream is refused under its module's subjects A family on a holder's event names the holder's own stream, so its subject is mesh.stream..event.; operator-channel's offered and closed said the router's stream but mesh.mod subjects. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatdef/seatdef.go | 2 ++ go/seatdef/seatdef_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index f1336f3..6017b36 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -397,6 +397,8 @@ func (d Definition) check() Problems { ps = append(ps, Problem{where + ".subject", "missing"}) case e.Subject == stream && e.Family == "": ps = append(ps, Problem{where + ".subject", "a subject of the holder's own stream, and no family names that stream"}) + case e.Subject == own && e.Family != "": + ps = append(ps, Problem{where + ".subject", "a family names the holder's own stream, so the event is under " + stream}) case e.Subject != own && e.Subject != stream: ps = append(ps, Problem{where + ".subject", fmt.Sprintf("not %s, nor %s for an event kept in the holder's own stream", own, stream)}) } diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index e51f316..e385e38 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -45,6 +45,8 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { `"subject":"mesh.stream..event.verdict","family":"checker.asks"}],"family":`, 1), ""}, {"a holder's event on its own stream with no family", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ `"subject":"mesh.stream..event.verdict"}],"family":`, 1), "no family names that stream"}, + {"a holder's event kept in its own stream under its module's subjects", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ + `"subject":"mesh.mod..event.verdict","family":"checker.asks"}],"family":`, 1), "a family names the holder's own stream"}, {"a holder's event on a subject of no module", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ `"subject":"mesh.event.verdict"}],"family":`, 1), "holder-events[0].subject: not mesh.mod..event.verdict"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, -- 2.54.0 From c1d7218b3283e111a11ef33aa87e8baae0d5cc0d Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:31:38 +0100 Subject: [PATCH 046/144] DLV-T (A2d): a package's version is a full commit or a Go semantic version, a group's tagged version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delivery.PackageVersions.Check takes v0.1.15-feat-x.2 beside a full commit (ADR 0607 §3, §5): a delivery group's check names the group's tagged version. A2d's patch, whole. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/stepasks.go | 14 ++++++++++---- go/delivery/stepasks_test.go | 11 +++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index 85da720..110cd21 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -266,17 +266,19 @@ type StepAsk struct { } // PackageVersions are the versions of the mesh packages a build or a check builds against, by package (a Go module's -// path), each a full commit (novox/hq ADR 0573 §2). +// path): each a full commit (novox/hq ADR 0573 §2), or a Go semantic version, the form a delivery group's check names the +// group's tagged version in (`v0.1.15-feat-x.2`, ADR 0607 §3, §5; design 51 *Tagged versions*). type PackageVersions map[string]string -// Check refuses a package that is no module path or a version that is no full commit, naming it. +// Check refuses a package that is no module path or a version that is neither a full commit nor a Go semantic version, +// naming it. func (p PackageVersions) Check() error { for pkg, version := range p { if pkg == "" || strings.ContainsAny(pkg, " \t\n@") || strings.HasPrefix(pkg, "/") { return fmt.Errorf("delivery: the package %q is no module path", pkg) } - if !askCommitOK.MatchString(version) { - return fmt.Errorf("delivery: the package %s names the version %q, which is no full commit", pkg, version) + if !askCommitOK.MatchString(version) && !PackageSemver.MatchString(version) { + return fmt.Errorf("delivery: the package %s names the version %q, which is neither a full commit nor a Go semantic version (v0.1.15-feat-x.2)", pkg, version) } } return nil @@ -527,6 +529,10 @@ func (c CheckJob) check(id string) error { var askCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) +// PackageSemver is a Go semantic version a package may be named at (PackageVersions): v, three numbers, and an optional +// pre-release of Semantic Versioning's characters (a tagged version's, ADR 0607 §1). +var PackageSemver = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + // checkFetchArgs refuses fetch arguments with no repository or no full commit; the rest is the build module's fetch to // judge, as a direct call's is. func checkFetchArgs(id string, args map[string]any) error { diff --git a/go/delivery/stepasks_test.go b/go/delivery/stepasks_test.go index 0dcc50f..bcdcc41 100644 --- a/go/delivery/stepasks_test.go +++ b/go/delivery/stepasks_test.go @@ -28,7 +28,8 @@ func checkAsk() StepAsk { Check: &CheckJob{Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit, "ref": "refs/pull/3/head", "base": askCommit}, Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: "", NodeNames: []string{"anchor", "laptop"}}, - Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit, "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": askCommit}} + Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit, "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": askCommit, + "git.novox.be/novox/mesh-contracts/go": "v0.1.15-feat-x.2"}} // ADR 0607 §5: a group's tagged version } // 1887/A1a: an ask encodes and decodes with every field; 1585/S4: the package versions too. @@ -61,9 +62,11 @@ func TestAskRefusesUnknownKindAndLanguage(t *testing.T) { "commit": func(a *StepAsk) { a.Build.Fetch = map[string]any{"repository": "novox/x", "commit": "main"} }, "path": func(a *StepAsk) { a.Build.Path = "" }, "needs": func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, // issue 890: a build needs no node - // Issue 1585: a package's version is a full commit, of a module path. - "version": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "v0.1.7" }, - "package": func(a *StepAsk) { a.Packages["git.novox.be/novox/x y"] = askCommit }, + // Issue 1585, ADR 0607 §5: a package's version is a full commit or a Go semantic version, of a module path. + "version": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "main" }, + "no v": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "0.1.15-feat-x.2" }, + "pre-chars": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "v0.1.15-feat/x.2" }, + "package": func(a *StepAsk) { a.Packages["git.novox.be/novox/x y"] = askCommit }, "compose": func(a *StepAsk) { a.Kind, a.Build = AskCheck, nil a.Compose = &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit}, Dir: "go", Package: "x", Request: json.RawMessage(`{}`)} -- 2.54.0 From e802e67f9ab77bcd15f9142701209b9838c8d14a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:32:20 +0100 Subject: [PATCH 047/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20a=20holder's=20?= =?UTF-8?q?caller=20list=20per=20verb=20and=20its=20question=20to=20the=20?= =?UTF-8?q?builder=20are=20shared,=20not=20copied=20per=20holder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/caller_test.go | 26 ++++++++++++++ go/callers.go | 64 +++++++++++++++++++++++++++++++++ go/delivery/builder_test.go | 17 +++++++++ go/delivery/builder_versions.go | 38 ++++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 go/delivery/builder_versions.go diff --git a/go/caller_test.go b/go/caller_test.go index bac2a36..1759eec 100644 --- a/go/caller_test.go +++ b/go/caller_test.go @@ -122,6 +122,32 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) { if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil { t.Error("a list with a field it does not know was read") } + // A holder's list per verb (ADR 0627 §2): each verb's entries as above, plus every proved caller (AnyCaller) or + // every operator's session (AnyPerson); an unproven caller never, a verb the holder does not serve refused. + verbs, err := ParseVerbCallers(`{"publish":["mesh-build-go"],"versions":["*"],"release":["operator","sweep"]}`, + []string{"publish", "versions", "release", "remove"}, nil) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + verb, caller string + want bool + }{{"publish", "ace.mesh-build-go", true}, {"publish", "ace.mesh-build-node", false}, {"versions", "g14.anything", true}, + {"versions", Unproven, false}, {"release", "person.jo", true}, {"release", "ace.operator", false}, + {"release", "novox.sweep", true}, {"remove", "novox.sweep", false}} { + if got := verbs.Allowed(c.verb, c.caller) == nil; got != c.want { + t.Errorf("%s by %q allowed: %v, want %v", c.verb, c.caller, got, c.want) + } + } + if _, err := ParseVerbCallers(`{"mirror":["x"]}`, []string{"publish"}, nil); err == nil { + t.Error("a list naming a verb the holder does not serve was read") + } + if d, _ := ParseVerbCallers(" ", nil, VerbCallers{"publish": {"x"}}); len(d["publish"]) != 1 { + t.Error("an empty value is not the holder's defaults") + } + if ModuleOf("ace.mesh-build-go") != "mesh-build-go" { + t.Error("ModuleOf does not read the module of a proved caller") + } } // A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is diff --git a/go/callers.go b/go/callers.go index 74a7ce3..67a8a38 100644 --- a/go/callers.go +++ b/go/callers.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "slices" "strings" ) @@ -46,3 +47,66 @@ func (c Callers) Allows(caller string) bool { } return false } + +// VerbCallers is a holder's trusted list per verb (novox/hq ADR 0627 §2, design 51 *Who may call which verb*): for each +// verb of the seats it holds, the callers that may call it, each entry read as Callers reads it. Two entries more stand +// for a whole class: AnyCaller lets every proved caller call the verb (a read the mesh keeps open), AnyPerson every +// operator's session the runtime proved as person.. It names no seat and no verb: the holder gives its verbs and +// its defaults, and keeps any rule of its own seat (who may never be on a list) beside it. +type VerbCallers map[string][]string + +// The entries of a VerbCallers list that stand for a class of callers. +const ( + AnyCaller = "*" + AnyPerson = "operator" +) + +// ParseVerbCallers reads a holder's config value of verb to callers; an empty value is defaults. A verb not among verbs +// is refused, so a list that names a verb the holder does not serve is said at once. +func ParseVerbCallers(raw string, verbs []string, defaults VerbCallers) (VerbCallers, error) { + if strings.TrimSpace(raw) == "" { + return defaults, nil + } + var c VerbCallers + if err := json.Unmarshal([]byte(raw), &c); err != nil { + return nil, fmt.Errorf("callers is not a JSON object of verb to callers: %w", err) + } + for verb := range c { + if !slices.Contains(verbs, verb) { + return nil, fmt.Errorf("callers names the verb %q, which the holder does not serve", verb) + } + } + return c, nil +} + +// Allowed answers nil when caller, as the runtime proved it, may call verb; else the refusal naming both. An unproven +// caller is never allowed, not even by AnyCaller. +func (c VerbCallers) Allowed(verb, caller string) error { + if caller == "" || caller == Unproven { + return fmt.Errorf("%s: refused: the request carries no caller the tool runner proved", verb) + } + person := strings.HasPrefix(caller, "person.") + for _, entry := range c[verb] { + switch entry { + case AnyCaller: + return nil + case AnyPerson: + if person { + return nil + } + default: + if (Callers{entry}).Allows(caller) { + return nil + } + } + } + return fmt.Errorf("%s: refused for %s: not on the holder's caller list for %s", verb, caller, verb) +} + +// ModuleOf is the module of a proved module caller, which the runtime names .. +func ModuleOf(proved string) string { + if _, module, ok := strings.Cut(proved, "."); ok { + return module + } + return proved +} diff --git a/go/delivery/builder_test.go b/go/delivery/builder_test.go index 8e29474..7968c19 100644 --- a/go/delivery/builder_test.go +++ b/go/delivery/builder_test.go @@ -1,7 +1,9 @@ package delivery import ( + "encoding/json" "errors" + "fmt" "reflect" "strings" "testing" @@ -158,6 +160,21 @@ func TestBuilderBuildVersion(t *testing.T) { t.Errorf("DecodeBuilderBuild(%s) = %v", in, err) } } + // A holder's question before it stores (ADR 0605 §4): asked at the builder's address with name, version and digest; + // an answer without recorded is an error, never a yes. + for answer, want := range map[string]string{`{"recorded":true}`: "true ", `{"recorded":false,"why":"other digest"}`: "false other digest", + `{"name":"x"}`: "error", `not json`: "error"} { + b := BuilderVersions{Ask: func(addr string, body any) (json.RawMessage, error) { + if a := body.(map[string]any); addr != BuilderVersionsAddress || a["name"] != "n" || a["version"] != "0.1.2" || a["digest"] != "sha256:d" { + t.Errorf("asked %s %v", addr, body) + } + return json.RawMessage(answer), nil + }} + ok, why, err := b.Recorded("n", "0.1.2", "sha256:d") + if got := fmt.Sprintf("%v %s", ok, why); err != nil && want != "error" || err == nil && got != want { + t.Errorf("answer %s: %s, %v; want %s", answer, got, err, want) + } + } } // 1358/AC6: the builder's bucket and module do not collide with the controller's build layout (buildrecord.go), which diff --git a/go/delivery/builder_versions.go b/go/delivery/builder_versions.go new file mode 100644 index 0000000..86e5c10 --- /dev/null +++ b/go/delivery/builder_versions.go @@ -0,0 +1,38 @@ +package delivery + +import ( + "encoding/json" + "fmt" +) + +// The holder's question before it stores a publish (novox/hq ADR 0605 §4, design 51 *Publishing*): did the builder +// number this version with this digest? Every registry seat's holder asks it the same way, so the asking lives here +// once (ADR 0627 §2), beside the builder's layouts; no holder writes it again. +// +// The builder answers it today only as its module's tool builder_versions: no seat in the seat set names the builder's +// role yet. Once one does, BuilderVersionsAddress becomes that seat's verb and nothing else changes for a holder. +const BuilderVersionsAddress = BuilderModule + ".builder_versions" + +// AskFunc is the runtime's request and reply over the bus (mesh-sdk stdio.Ask), given by the holder so this package does +// not depend on the runtime. +type AskFunc func(address string, body any) (json.RawMessage, error) + +// BuilderVersions asks the builder whether it numbered a version with a digest. It answers the holder's Check: +// recorded, and why not when it is not. +type BuilderVersions struct{ Ask AskFunc } + +// Recorded asks the builder about name@version with digest. An answer without `recorded` is an error, never a yes. +func (b BuilderVersions) Recorded(name, version, digest string) (bool, string, error) { + raw, err := b.Ask(BuilderVersionsAddress, map[string]any{"name": name, "version": version, "digest": digest}) + if err != nil { + return false, "", err + } + var a struct { + Recorded *bool `json:"recorded"` + Why string `json:"why"` + } + if err := json.Unmarshal(raw, &a); err != nil || a.Recorded == nil { + return false, "", fmt.Errorf("builder_versions answered no recorded: %s", raw) + } + return *a.Recorded, a.Why, nil +} -- 2.54.0 From a0465bf0b769024b8cefce8da401197d3bbc89dc Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:34:50 +0100 Subject: [PATCH 048/144] =?UTF-8?q?ADR=200625=20=C2=A710:=20the=20README?= =?UTF-8?q?=20says=20a=20seat's=20code=20is=20mesh-contracts',=20and=20wha?= =?UTF-8?q?t=20mesh-sdk=20keeps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index a83b1ae..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: -- 2.54.0 From c5fc2ee859308fd853d643797f9b4ae22a9ff477 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:35:11 +0100 Subject: [PATCH 049/144] DLV-T (A2d): a fetch request's package named at a Go semantic version is taken, a branch name refused The row of buildstep that refused v0.1.7 now refuses main: c1d7218 lets PackageVersions take a group's tagged version. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/buildstep/request_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/buildstep/request_test.go b/go/buildstep/request_test.go index 69102f7..7732329 100644 --- a/go/buildstep/request_test.go +++ b/go/buildstep/request_test.go @@ -46,7 +46,7 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { {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{"git.novox.be/novox/mesh-sdk/go": "main"}}, "neither a full commit nor a Go semantic version"}, {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"}, } { -- 2.54.0 From 54e1ecce45ce3c82cbb0ec7805f377c2a94e6583 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:40:29 +0100 Subject: [PATCH 050/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20a=20holder=20re?= =?UTF-8?q?ads=20its=20caller=20list=20as=20mesh-config=20resolved=20it,?= =?UTF-8?q?=20not=20from=20an=20environment=20nothing=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/caller_test.go | 7 +++++++ go/callers.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/go/caller_test.go b/go/caller_test.go index 1759eec..d08b88f 100644 --- a/go/caller_test.go +++ b/go/caller_test.go @@ -145,6 +145,13 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) { if d, _ := ParseVerbCallers(" ", nil, VerbCallers{"publish": {"x"}}); len(d["publish"]) != 1 { t.Error("an empty value is not the holder's defaults") } + // As mesh-config resolves the value: the object itself, or written as a string; null is the defaults. + for _, raw := range []string{`{"publish":["a"]}`, `"{\"publish\":[\"a\"]}"`, `null`} { + c, err := parseConfiguredVerbCallers(json.RawMessage(raw), []string{"publish"}, VerbCallers{"publish": {"a"}}) + if err != nil || c.Allowed("publish", "ace.a") != nil { + t.Errorf("the config value %s read as %v, %v", raw, c, err) + } + } if ModuleOf("ace.mesh-build-go") != "mesh-build-go" { t.Error("ModuleOf does not read the module of a proved caller") } diff --git a/go/callers.go b/go/callers.go index 67a8a38..6fe6a49 100644 --- a/go/callers.go +++ b/go/callers.go @@ -110,3 +110,22 @@ func ModuleOf(proved string) string { } return proved } + +// ConfiguredVerbCallers is the holder's list as mesh-config resolved its config value key for this machine (Config): +// a JSON object of verb to callers, or that object written as a string; an unset or empty value is defaults. A value +// that cannot be read is an error, never the defaults, so a broken list is said, not widened. +func ConfiguredVerbCallers(key string, verbs []string, defaults VerbCallers) (VerbCallers, error) { + raw, err := Config(key) + if err != nil { + return nil, fmt.Errorf("the config value %s cannot be read: %w", key, err) + } + return parseConfiguredVerbCallers(raw, verbs, defaults) +} + +func parseConfiguredVerbCallers(raw json.RawMessage, verbs []string, defaults VerbCallers) (VerbCallers, error) { + var text string + if err := json.Unmarshal(raw, &text); err == nil || len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return ParseVerbCallers(text, verbs, defaults) + } + return ParseVerbCallers(string(raw), verbs, defaults) +} -- 2.54.0 From 85b31412b9d90b278e00adc417aa41f63b210243 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:53:41 +0100 Subject: [PATCH 051/144] J1: node-engine's compose-candidate checks leave mesh-sdk for its client; pull-waits leaves the seat-set row (local only) --- go/catalogue/engine_seat.go | 21 ++- go/catalogue/seats_test.go | 2 +- go/delivery/compose_candidate.go | 196 -------------------------- go/delivery/compose_candidate_test.go | 162 --------------------- go/delivery/composed_plan_test.go | 74 ++++++++++ go/testdata/seat-names.txt | 1 + 6 files changed, 86 insertions(+), 370 deletions(-) delete mode 100644 go/delivery/compose_candidate.go delete mode 100644 go/delivery/compose_candidate_test.go create mode 100644 go/delivery/composed_plan_test.go diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index 34d39eb..363e2fa 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -1,16 +1,14 @@ package catalogue -import "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" +import contractseats "git.novox.be/novox/mesh-contracts/go/seats" -// EngineSeat is the node seat every node's engine holds. -// -// Deprecated: the seat is defined whole in mesh-contracts (nodeengine.Seat, its verbs, events, subjects and message -// types); kept for its importers until their next change takes it from there (testdata/seat-names.txt). -const EngineSeat = nodeengine.Seat +// EngineSeat is the node seat every node's engine holds, from the seat set. The seat is defined whole in mesh-contracts +// (its contract, go/seats/nodeengine, and its client, go/clients/nodeengineclient): its verbs' and events' names and +// message types are taken from there (ADR 0627 §4). +const EngineSeat = contractseats.NodeEngine // EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the -// generated seat list (design 26), each with its description and its input's schema as discovery shows them. Their -// names are nodeengine.Verbs(). +// generated seat list (design 26), each with its description and its input's schema as discovery shows them. func EngineVerbs() []Verb { s, _ := seatIn(defaultSeats, EngineSeat) return append([]Verb(nil), s.Serves...) @@ -18,9 +16,10 @@ func EngineVerbs() []Verb { // EngineEvents are what the seat says: of an act, each naming the act, the path or access, the caller as the runtime // proved it and the ask (ADR 0563 §1), and that what its engine reads of a manifest changed (ADR 0618 §4). -// -// Deprecated: the seat's contract names them, nodeengine.Events(); kept for its importers. -var EngineEvents = nodeengine.Events() +var EngineEvents = func() []string { + s, _ := seatIn(defaultSeats, EngineSeat) + return append([]string(nil), s.Emits...) +}() // seatIn is the seat of that name in a list. func seatIn(list []Seat, name string) (Seat, bool) { diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 4f6bdec..71c02ad 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -57,7 +57,7 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"} dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. - addedVerbs := map[string]bool{"node-engine.pull-waits": true} + addedVerbs := map[string]bool{} // A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the // checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it). retyped := map[string]bool{"node-engine.compose-candidate": true} diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go deleted file mode 100644 index 2420d61..0000000 --- a/go/delivery/compose_candidate.go +++ /dev/null @@ -1,196 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "fmt" - "time" - - "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" -) - -// A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb -// compose-candidate, served by each node's engine alone, composes a check's candidate in shadow with the node's own -// sealed values and the indexer's manifest verdicts, applies and writes nothing, and answers within the asker's bound. -// The checker asks every placed node in parallel and makes the check's composition verdict from the answers; a node -// that does not answer within the bound is named "not composed on that node: no answer" and the check passes with that -// warning (§9). This is the contract only: the composing is the node-engine's own. - -const ( - // EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat). - // - // Deprecated: the seat's contract names it, nodeengine.VerbComposeCandidate; kept for its importers. - EngineComposeCandidateVerb = nodeengine.VerbComposeCandidate - // ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's - // answer (ADR 0593 §7, §9). - ComposeAnswerBound = 60 * time.Second - // ComposeNoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9). - ComposeNoAnswer = "not composed on that node: no answer" - - // ComposeCandidateComposed and ComposeCandidateRefused are an answer's verdict: no module and not the node's own composition is - // refused by the candidate, or at least one is (or the node could not compose with it at all, Refused). - ComposeCandidateComposed = "composed" - ComposeCandidateRefused = "refused" - // ComposeCandidateUnready is the verdict of a node that cannot compose yet (its shadow not ready, its agent - // starting): no module is named, Refused says why in words, and the checker takes it as a node that did not answer - // (ComposeNoAnswer, ADR 0593 §9), deciding by this verdict and never by the words. - ComposeCandidateUnready = "unready" - // ComposeCandidateNotJudged is the verdict for a node the checker's sandbox composed and could not judge (an older - // node-engine's input that does not say how it composes, novox/hq ADR 0593 §9): no module is named, Refused says why in - // words, and the checker names the node with them and passes the check with that warning, never silent and never a - // failure of the change. The checker decides by this verdict, never by the words (ADR 0601 §4's kind: a contract is a - // field, not a phrase). - ComposeCandidateNotJudged = "not-judged" - - // The states of one module's composition with the candidate: composed (Digest set), refused by the candidate - // (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict. - ModuleComposedState = "composed" - ModuleRefusedState = "refused" - ModuleHeldState = "held" -) - -// The verb's ask and answer are the seat's contract, generated in mesh-contracts (seats/node-engine): mesh-sdk keeps -// these names for their importers and the checks below over them, until the seat has a client package (ADR 0625 §4). -type ( - // ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, - // `.`); the engine reads that candidate itself and lays its modules over its placement. - // Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the - // catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer - // runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). - // - // Deprecated: the seat's contract is nodeengine.ComposeCandidateInput; kept for its importers. - ComposeCandidateAsk = nodeengine.ComposeCandidateInput - // ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node - // sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate - // at all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the - // engine took in milliseconds, and, when asked (ComposeCandidateAsk.Input), the composition input it composed from. - // - // Deprecated: the seat's contract is nodeengine.ComposeCandidateAnswer; kept for its importers. - ComposeCandidateAnswer = nodeengine.ComposeCandidateAnswer - // ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the - // candidate's FP2 for a module the candidate touches, else its newest done build as composed- holds it), its - // state, the composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one - // composed without it (the checker's "what the change reaches on this node"), and why, for a refused or held one. - // - // Deprecated: the seat's contract is nodeengine.Module; kept for its importers. - ModuleComposed = nodeengine.Module -) - -func checkComposeAsk(a ComposeCandidateAsk) error { - if _, _, err := ParseCompositionKey(a.Candidate); err != nil { - return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err) - } - return nil -} - -func checkComposedModule(m ModuleComposed, node string) error { - if m.Module == "" { - return fmt.Errorf("delivery: a module's composition on %s names no module", node) - } - switch m.State { - case ModuleComposedState: - if m.Digest == "" { - return fmt.Errorf("delivery: %s is composed on %s and names no digest", m.Module, node) - } - case ModuleRefusedState, ModuleHeldState: - if m.Words == "" { - return fmt.Errorf("delivery: %s is %s on %s and says no why", m.Module, m.State, node) - } - default: - return fmt.Errorf("delivery: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State, - ModuleComposedState, ModuleRefusedState, ModuleHeldState) - } - return nil -} - -func checkComposeAnswer(a ComposeCandidateAnswer) error { - if err := checkName("node", a.Node); err != nil { - return err - } - if _, _, err := ParseCompositionKey(a.Candidate); err != nil { - return err - } - if a.Took < 0 { - return fmt.Errorf("delivery: the answer of %s took %d ms", a.Node, a.Took) - } - if (a.Verdict == ComposeCandidateUnready || a.Verdict == ComposeCandidateNotJudged) && a.Refused == "" { - return fmt.Errorf("delivery: %s's answer is %s and says no why", a.Node, a.Verdict) - } - if a.Refused != "" { - if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused && a.Verdict != ComposeCandidateUnready && a.Verdict != ComposeCandidateNotJudged { - return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or is neither %s, %s nor %s", - a.Node, a.Refused, ComposeCandidateRefused, ComposeCandidateUnready, ComposeCandidateNotJudged) - } - return nil - } - refused := false - for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) { - if err := checkComposedModule(m, a.Node); err != nil { - return err - } - if i > 1 && a.Modules[i-2].Module >= m.Module { - return fmt.Errorf("delivery: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module) - } - refused = refused || m.State == ModuleRefusedState - } - switch { - case a.Own.Module != nodeWord: - return fmt.Errorf("delivery: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, nodeWord) - case refused && a.Verdict != ComposeCandidateRefused: - return fmt.Errorf("delivery: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict) - case !refused && a.Verdict != ComposeCandidateComposed: - return fmt.Errorf("delivery: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict) - } - return nil -} - -// strictDecode reads data into v, refusing an unknown key. -func strictDecode(data []byte, v any, what string) error { - d := json.NewDecoder(bytes.NewReader(data)) - d.DisallowUnknownFields() - d.UseNumber() // a number in an object the contract leaves open (the composition input) keeps its digits as written - if err := d.Decode(v); err != nil { - return fmt.Errorf("delivery: %s does not fit its shape: %w", what, err) - } - return nil -} - -// EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key. -func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) { - if err := checkComposeAsk(a); err != nil { - return nil, err - } - return json.Marshal(a) -} - -// DecodeComposeCandidateAsk reads an ask strictly; it refuses an unknown key and what EncodeComposeCandidateAsk refuses. -func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) { - var a ComposeCandidateAsk - if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { - return ComposeCandidateAsk{}, err - } - return a, checkComposeAsk(a) -} - -// EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest, -// a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give. -func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) { - if err := checkComposeAnswer(a); err != nil { - return nil, err - } - a.V = Version - return json.Marshal(a) -} - -// DecodeComposeCandidateAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown -// key and what EncodeComposeCandidateAnswer refuses. -func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) { - if err := checkVersion(data); err != nil { - return ComposeCandidateAnswer{}, err - } - var a ComposeCandidateAnswer - if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { - return ComposeCandidateAnswer{}, err - } - return a, checkComposeAnswer(a) -} diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go deleted file mode 100644 index 66afd83..0000000 --- a/go/delivery/compose_candidate_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package delivery - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The verb compose-candidate's ask and answer, and a composition that carries its plan or its refusal (ADR 0593 §6, §7): -// each round-trips, and a reader refuses what it could not take, naming it. -func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { - key, err := CompositionKey("mesh-sdk#7", "0123456789ab") - if err != nil { - t.Fatal(err) - } - raw, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: key, Input: true}) - if err != nil { - t.Fatal(err) - } - if a, err := DecodeComposeCandidateAsk(raw); err != nil || a.Candidate != key || !a.Input { - t.Fatalf("ask round trip: %v, %+v", err, a) - } - if _, err := DecodeComposeCandidateAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil { - t.Error("an ask with an unknown key was read") - } - if _, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: "nope"}); err == nil { - t.Error("an ask naming no candidate key was encoded") - } - - good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812, - Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"}, - Modules: []ModuleComposed{ - {Module: "netcheck", Fp: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, - {Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"}, - {Module: "zz", State: ModuleHeldState, Words: "held before"}, - }, Input: map[string]any{"machines": []any{"ace"}}} - raw, err = EncodeComposeCandidateAnswer(good) - if err != nil { - t.Fatal(err) - } - back, err := DecodeComposeCandidateAnswer(raw) - if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) { - t.Fatalf("answer round trip: %v, %+v", err, back) - } - for name, mutate := range map[string]func(a *ComposeCandidateAnswer){ - "verdict not the modules'": func(a *ComposeCandidateAnswer) { a.Verdict = ComposeCandidateComposed }, - "unknown state": func(a *ComposeCandidateAnswer) { a.Modules[0].State = "fine" }, - "composed without digest": func(a *ComposeCandidateAnswer) { a.Modules[0].Digest = "" }, - "refused without words": func(a *ComposeCandidateAnswer) { a.Modules[1].Words = "" }, - "unsorted": func(a *ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] }, - "own misnamed": func(a *ComposeCandidateAnswer) { a.Own.Module = "ace" }, - "whole refusal w/ modules": func(a *ComposeCandidateAnswer) { a.Refused = "the bound passed" }, - } { - a := good - a.Modules = append([]ModuleComposed(nil), good.Modules...) - mutate(&a) - if _, err := EncodeComposeCandidateAnswer(a); err == nil { - t.Errorf("%s: encoded", name) - } - } - whole := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Refused: "the bound passed"} - if _, err := EncodeComposeCandidateAnswer(whole); err != nil { - t.Errorf("a whole refusal was not encoded: %v", err) - } - unready := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateUnready, Refused: "the agent is starting"} - if raw, err := EncodeComposeCandidateAnswer(unready); err != nil { - t.Errorf("an unready answer was not encoded: %v", err) - } else if back, err := DecodeComposeCandidateAnswer(raw); err != nil || back.Verdict != ComposeCandidateUnready { - t.Errorf("an unready answer read back as %+v, %v", back, err) - } - unready.Refused = "" - if _, err := EncodeComposeCandidateAnswer(unready); err == nil { - t.Error("an unready answer saying no why was encoded") - } - unready.Refused, unready.Modules = "starting", good.Modules - if _, err := EncodeComposeCandidateAnswer(unready); err == nil { - t.Error("an unready answer naming modules was encoded") - } - // A node the sandbox did not judge: its own verdict, with words and no module (ADR 0593 §9, ADR 0601 §4). - notJudged := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateNotJudged, Refused: "its node-engine sends no running state"} - if raw, err := EncodeComposeCandidateAnswer(notJudged); err != nil { - t.Errorf("a not-judged answer was not encoded: %v", err) - } else if back, err := DecodeComposeCandidateAnswer(raw); err != nil || back.Verdict != ComposeCandidateNotJudged { - t.Errorf("a not-judged answer read back as %+v, %v", back, err) - } - for name, mutate := range map[string]func(*ComposeCandidateAnswer){ - "no why": func(a *ComposeCandidateAnswer) { a.Refused = "" }, - "modules too": func(a *ComposeCandidateAnswer) { a.Modules = good.Modules }, - } { - a := notJudged - mutate(&a) - if _, err := EncodeComposeCandidateAnswer(a); err == nil { - t.Errorf("a not-judged answer with %s was encoded", name) - } - } - if _, err := DecodeComposeCandidateAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil { - t.Error("an answer with an unknown key was read") - } - - fp := "src2:" + strings.Repeat("a", 64) - planned := Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}, - Plan: &catalogue.Plan{Files: []catalogue.PlanFile{}}} - raw, err = EncodeComposed(planned) - if err != nil { - t.Fatal(err) - } - c, err := DecodeComposed(raw) - if err != nil || c.Plan == nil { - t.Fatalf("a composition's plan did not round-trip: %v, %+v", err, c) - } - d1, _ := ComposedDigestOf(planned) - d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) - if d1 == "" || d1 != d0 { - t.Error("the digest moves with the plan: it is the resources' alone") - } - if SameComposed(planned, Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) { - t.Error("a plan change is not a new composition value") - } - signed := strings.Replace(string(raw), `"resources"`, `"signature":{"signer":"x","sig":"y"},"resources"`, 1) - if c, err := DecodeComposed([]byte(signed)); err != nil || c.Node != "ace" { - t.Fatalf("a composition the ended controller signed does not decode: %v", err) - } - if strings.Contains(string(raw), "signature") { - t.Error("a composition is written with a signature") - } - refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"} - if raw, err = EncodeComposed(refused); err != nil { - t.Fatal(err) - } - if c, err := DecodeComposed(raw); err != nil || c.Refused != refused.Refused { - t.Fatalf("a refused composition did not round-trip: %v", err) - } - if _, err := ComposedDigestOf(refused); err == nil { - t.Error("a refused composition has a digest") - } - refused.Data = true - if _, err := EncodeComposed(refused); err == nil { - t.Error("a refused composition holding data was encoded") - } - if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{}, Refused: "x"}); err == nil { - t.Error("the node's own composition carried a refusal") - } -} - -// A kept import form is set only with imports, and in the fingerprint's form (ADR 0593 §4). -func TestAKeptImportFormNeedsImports(t *testing.T) { - form := SourcePrefix2 + strings.Repeat("b", 64) - in := KeptInputs2{Tree: "t", Imports: []string{"git.novox.be/novox/mesh-sdk/go"}, ImportForm: form} - m := Module{FP2: form, Manifest: strings.Repeat("c", 64), Inputs2: &in, Repository: "novox/x", Commit: "c"} - if err := m.check(); err != nil && strings.Contains(err.Error(), "import form") { - t.Fatalf("a good import form refused: %v", err) - } - for _, bad := range []KeptInputs2{{Tree: "t", ImportForm: form}, {Tree: "t", Imports: in.Imports, ImportForm: "src2:short"}} { - m.Inputs2 = &bad - if err := m.check(); err == nil || !strings.Contains(err.Error(), "import form") { - t.Errorf("%+v: %v", bad, err) - } - } -} diff --git a/go/delivery/composed_plan_test.go b/go/delivery/composed_plan_test.go new file mode 100644 index 0000000..f6993c4 --- /dev/null +++ b/go/delivery/composed_plan_test.go @@ -0,0 +1,74 @@ +package delivery + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A composition that carries its plan or its refusal (ADR 0593 §6): each round-trips, and a reader refuses what it could +// not take, naming it. (compose-candidate's ask and answer are node-engine's client's, mesh-contracts +// go/clients/nodeengineclient, ADR 0627 §1.) +func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { + fp := "src2:" + strings.Repeat("a", 64) + planned := Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}, + Plan: &catalogue.Plan{Files: []catalogue.PlanFile{}}} + raw, err := EncodeComposed(planned) + if err != nil { + t.Fatal(err) + } + c, err := DecodeComposed(raw) + if err != nil || c.Plan == nil { + t.Fatalf("a composition's plan did not round-trip: %v, %+v", err, c) + } + d1, _ := ComposedDigestOf(planned) + d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) + if d1 == "" || d1 != d0 { + t.Error("the digest moves with the plan: it is the resources' alone") + } + if SameComposed(planned, Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) { + t.Error("a plan change is not a new composition value") + } + signed := strings.Replace(string(raw), `"resources"`, `"signature":{"signer":"x","sig":"y"},"resources"`, 1) + if c, err := DecodeComposed([]byte(signed)); err != nil || c.Node != "ace" { + t.Fatalf("a composition the ended controller signed does not decode: %v", err) + } + if strings.Contains(string(raw), "signature") { + t.Error("a composition is written with a signature") + } + refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"} + if raw, err = EncodeComposed(refused); err != nil { + t.Fatal(err) + } + if c, err := DecodeComposed(raw); err != nil || c.Refused != refused.Refused { + t.Fatalf("a refused composition did not round-trip: %v", err) + } + if _, err := ComposedDigestOf(refused); err == nil { + t.Error("a refused composition has a digest") + } + refused.Data = true + if _, err := EncodeComposed(refused); err == nil { + t.Error("a refused composition holding data was encoded") + } + if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{}, Refused: "x"}); err == nil { + t.Error("the node's own composition carried a refusal") + } +} + +// A kept import form is set only with imports, and in the fingerprint's form (ADR 0593 §4). +func TestAKeptImportFormNeedsImports(t *testing.T) { + form := SourcePrefix2 + strings.Repeat("b", 64) + in := KeptInputs2{Tree: "t", Imports: []string{"git.novox.be/novox/mesh-sdk/go"}, ImportForm: form} + m := Module{FP2: form, Manifest: strings.Repeat("c", 64), Inputs2: &in, Repository: "novox/x", Commit: "c"} + if err := m.check(); err != nil && strings.Contains(err.Error(), "import form") { + t.Fatalf("a good import form refused: %v", err) + } + for _, bad := range []KeptInputs2{{Tree: "t", ImportForm: form}, {Tree: "t", Imports: in.Imports, ImportForm: "src2:short"}} { + m.Inputs2 = &bad + if err := m.check(); err == nil || !strings.Contains(err.Error(), "import form") { + t.Errorf("%+v: %v", bad, err) + } + } +} diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 2dce7f4..0c08efc 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -11,6 +11,7 @@ catalogue/checker_seat.go: CheckerSeat catalogue/control_records_seats.go: HandActsSeat catalogue/control_records_seats.go: WantsSeat catalogue/delivery_seat.go: DeliverySeat +catalogue/engine_seat.go: EngineSeat catalogue/enrolment_seat.go: EnrolmentSeat catalogue/environment_into.go: EnvironmentSeat catalogue/environment_into.go: LoginShellSeat -- 2.54.0 From f9e035fbee11db83ed1f190521c27f5799dfc6b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:55:26 +0100 Subject: [PATCH 052/144] J1: a failed have carries the pull it waits on (ENG-9, the lead's ruling on pull-waits; local only) --- go/delivery/delivery.go | 35 +++++++++++++++++++++++++++++++++++ go/delivery/delivery_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index bd84f9c..62af7fc 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -369,6 +369,31 @@ type Have struct { // slot that holds it, so a provider and a consumer of one value on one node each keep their own. Written in Sealed's // place by a node-engine that knows it; Sealed is read only from a have written before. SealedAt Seals `json:"sealed-at,omitempty"` + // PullWait is, on a failed have, the pull from a store its apply failed on and waits on that store's holder to settle + // for (design 51 *What a pull meets in the swap*, issue 1966 ENG-9): nil on every other have, and on a failure that + // waits on no holder. + PullWait *PullWait `json:"pull-wait,omitempty"` +} + +// PullWait is the pull a failed apply met from a store: the image, the seat it came through and that seat's holder, whose +// settled the apply is tried again on, when the pull began, and when the apply was last tried again with what triggered +// it, in words (empty while it has not been). +type PullWait struct { + Image string `json:"image"` + Seat string `json:"seat,omitempty"` + Holder string `json:"holder"` + Began time.Time `json:"began"` + Retried string `json:"retried,omitempty"` + RetriedAt time.Time `json:"retried-at,omitzero"` +} + +// samePullWait says whether two pull waits are the same, their times compared as instants. +func samePullWait(a, b *PullWait) bool { + if a == nil || b == nil { + return a == b + } + return a.Image == b.Image && a.Seat == b.Seat && a.Holder == b.Holder && a.Began.Equal(b.Began) && + a.Retried == b.Retried && a.RetriedAt.Equal(b.RetriedAt) } // UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied @@ -488,6 +513,9 @@ func (h Have) check() error { if h.Judgement != "" || h.Waits != "" || h.BuildAlone { return fmt.Errorf("delivery: only a have waiting for a yes names a judgement, the composition waiting and whether it changes the build alone") } + if h.PullWait != nil && h.Phase != PhaseFailed { + return fmt.Errorf("delivery: only a failed have names the pull it waits on, not one %s", h.Phase) + } case PhaseWaitingYes: if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot { return fmt.Errorf("delivery: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement) @@ -498,6 +526,9 @@ func (h Have) check() error { default: return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase) } + if w := h.PullWait; w != nil && (w.Image == "" || w.Holder == "") { + return fmt.Errorf("delivery: a have's pull wait names no image or no holder") + } if h.Composed != "" { if err := checkDigest(h.Composed, "the have's composition digest"); err != nil { return err @@ -601,6 +632,10 @@ func DecodePulse(data []byte) (Pulse, error) { func SameHave(a, b Have) bool { ua, ub := a.UserList, b.UserList a.UserList, b.UserList = nil, nil + if !samePullWait(a.PullWait, b.PullWait) { + return false + } + a.PullWait, b.PullWait = nil, nil if !slices.Equal(a.Sealed, b.Sealed) || !maps.Equal(a.SealedAt, b.SealedAt) { return false } diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index c54fdbd..f292a01 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -452,6 +452,35 @@ func TestSame(t *testing.T) { } else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) { t.Errorf("sealed values do not travel: %+v, %v", back, err) } + // A failed have carries the pull it waits on, compared by what it says (issue 1966 ENG-9); only a failed one does. + began := time.Date(2026, 11, 4, 14, 0, 0, 0, time.UTC) + pa, pb := a, a + pa.Phase, pb.Phase = PhaseFailed, PhaseFailed + pa.PullWait = &PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution", Began: began, + Retried: "distribution settled", RetriedAt: began.Add(time.Minute)} + w := *pa.PullWait + w.Began, w.RetriedAt = began.In(time.Local), w.RetriedAt.In(time.Local) + pb.PullWait = &w + if !SameHave(pa, pb) || SameHave(pa, Have{V: 1, Build: "b", Phase: PhaseFailed, Health: HealthOK}) { + t.Error("equal pull waits differ, or one said and one not are the same") + } + if raw, err := EncodeHave(pa); err != nil { + t.Error(err) + } else if back, err := DecodeHave(raw); err != nil || !SameHave(back, pa) { + t.Errorf("a pull wait does not travel: %+v, %v", back, err) + } + pb.PullWait = &PullWait{Image: "reg/x@sha256:1", Holder: "distribution", Began: began} + if SameHave(pa, pb) { + t.Error("pull waits retried and not are the same") + } + for name, h := range map[string]Have{ + "on a running have": {Phase: PhaseRunning, Health: HealthOK, PullWait: pa.PullWait}, + "naming no holder": {Phase: PhaseFailed, Health: HealthOK, PullWait: &PullWait{Image: "reg/x@sha256:1"}}, + } { + if _, err := EncodeHave(h); err == nil { + t.Errorf("a pull wait %s was encoded", name) + } + } // The user list is compared by what it says, never by where it is held (ADR 0547 §8). at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC) ua, ub := a, a -- 2.54.0 From 0424e87399d8540bf64ad6f254ce796057da2af4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:19:21 +0100 Subject: [PATCH 053/144] =?UTF-8?q?ADR=200627=20=C2=A72:=20a=20transfer=20?= =?UTF-8?q?read=20back=20from=20the=20bus=20takes=20its=20first=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transfer is kept in a key-value state as JSON, and its empty Chunks map is omitted, so the one read back for the first chunk had none and Take panicked: every publish in parts through BusTransfers. The unit tests' transfers now round-trip JSON as the bus does. Found by the forge module's whole test (CAT-G3). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/go.sum | 4 ++++ go/parts/receive.go | 4 ++++ go/parts/receive_test.go | 28 +++++++++++++++++++++++----- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/go/go.sum b/go/go.sum index b6588f4..8ca93df 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,10 +1,13 @@ github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= +github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= +github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -19,3 +22,4 @@ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= diff --git a/go/parts/receive.go b/go/parts/receive.go index f745188..cdb6b6e 100644 --- a/go/parts/receive.go +++ b/go/parts/receive.go @@ -240,6 +240,10 @@ func (r *Receiver[M]) Take(id, opener, digest string, sequence int, last bool, d if err := r.Objects.Put(r.chunkName(id, digest, sequence), data); err != nil { return Received{}, fmt.Errorf("the objects did not take the chunk: %w", err) } + // A transfer read back from a key-value state has no Chunks until one was taken: the empty map is kept omitted. + if t.Chunks == nil { + t.Chunks = map[string]int{} + } t.Chunks[digest] = sequence + 1 t.Last = r.now() if last { diff --git a/go/parts/receive_test.go b/go/parts/receive_test.go index ac37613..8f2f8d2 100644 --- a/go/parts/receive_test.go +++ b/go/parts/receive_test.go @@ -3,6 +3,7 @@ package parts import ( "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "reflect" "strings" @@ -28,14 +29,31 @@ func (m memObjects) Used() (uint64, error) { return u, nil } -type memTransfers[M any] map[string]Transfer[M] +// memTransfers keeps each transfer as the bus keeps it, its JSON, so every case reads back what a key-value state +// gives (BusTransfers): an empty map kept with omitempty comes back nil. +type memTransfers[M any] map[string][]byte -func (m memTransfers[M]) Get(id string) (Transfer[M], bool, error) { t, ok := m[id]; return t, ok, nil } -func (m memTransfers[M]) Put(t Transfer[M]) error { m[t.ID] = t; return nil } -func (m memTransfers[M]) Delete(id string) error { delete(m, id); return nil } +func (m memTransfers[M]) Get(id string) (Transfer[M], bool, error) { + var t Transfer[M] + raw, ok := m[id] + if !ok { + return t, false, nil + } + return t, true, json.Unmarshal(raw, &t) +} +func (m memTransfers[M]) Put(t Transfer[M]) error { + raw, err := json.Marshal(t) + m[t.ID] = raw + return err +} +func (m memTransfers[M]) Delete(id string) error { delete(m, id); return nil } func (m memTransfers[M]) List() ([]Transfer[M], error) { var out []Transfer[M] - for _, t := range m { + for id := range m { + t, _, err := m.Get(id) + if err != nil { + return nil, err + } out = append(out, t) } return out, nil -- 2.54.0 From 315d7e9b8cb7036c2883389c72fc5241ebb8cb24 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:22:20 +0100 Subject: [PATCH 054/144] J3 (A2g, A2h): a process's pictures of its owners' states are go/views', and a first view says when each key was written ProcessReader and Picture (A2g's mesh-delivery sdk/firstview at c6c6687) and the test owner (viewstest) move into go/views, so the builder, checker, teller, view and watchman read the indexer's and the assigner's states through the one shared follower. The first view's answer carries each key's written time as the bus keeps it, and Follower.Changed hands it on, so a reader's want time is the owner's write, not its own start (A2h's request). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/packages_list_test.go | 2 +- go/state.go | 3 +- go/views/follow.go | 10 +- go/views/process_reader.go | 546 ++++++++++++++++++++++++++++++++++++ go/views/tools.go | 2 +- go/views/views.go | 12 +- go/views/views_test.go | 58 +++- go/views/viewstest/owner.go | 107 +++++++ 8 files changed, 731 insertions(+), 9 deletions(-) create mode 100644 go/views/process_reader.go create mode 100644 go/views/viewstest/owner.go diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 918ced6..6aa3664 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -26,7 +26,7 @@ var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", - "sdkversion", "seal", "seatdef", "testbus", "views", + "sdkversion", "seal", "seatdef", "testbus", "views", "views/viewstest", } // clientSeats are the seats whose logic is their client's in mesh-contracts (novox/hq ADR 0627 §1, §4): mesh-sdk names diff --git a/go/state.go b/go/state.go index 90824d8..c420edf 100644 --- a/go/state.go +++ b/go/state.go @@ -16,11 +16,12 @@ import ( // `.`) and names each here as it named it there; the runtime refuses, with the reason, // what it did not declare. -// StateEntry is one key's current value. +// StateEntry is one key's current value, and when the bus wrote it (zero from a runtime that does not say it). type StateEntry struct { Key string `json:"key"` Value json.RawMessage `json:"value"` Revision uint64 `json:"revision"` + At time.Time `json:"at,omitzero"` } // The operations a watch hands over (StateChange.Op). diff --git a/go/views/follow.go b/go/views/follow.go index 6192bf6..11107a4 100644 --- a/go/views/follow.go +++ b/go/views/follow.go @@ -30,8 +30,9 @@ type Follower struct { // nothing. Raise func(Status) Clear func(Status) - // Changed is told of each key the picture changes (deleted: the key is gone), after the change is held, with when it - // was written: the zero time for a key a first view brought (since before what the reader heard). + // Changed is told of each key the picture changes (deleted: the key is gone), after the change is held, with when the + // owner's bus wrote it: the event's time, or for a key a first view brought the time its answer says (Answer.Written; + // zero when the owner does not say it, and for a key the first view no longer holds). Changed func(key string, value json.RawMessage, deleted bool, at time.Time) // Said is told of the status each time an ask starts, is answered or fails, and of a raise and a clear: where a // reader keeps it on the bus for its read tool (StatusTools). @@ -210,6 +211,7 @@ func (f *Follower) askOnce(ctx context.Context) error { f.say(asked) var first Answer values := map[string]json.RawMessage{} + written := map[string]time.Time{} after := "" for page := 0; ; page++ { a, err := f.Ask(ctx, after) @@ -220,6 +222,7 @@ func (f *Follower) askOnce(ctx context.Context) error { first = a } maps.Copy(values, a.Values) + maps.Copy(written, a.Written) if a.Next == "" || a.Next == after { break } @@ -240,6 +243,7 @@ func (f *Follower) askOnce(ctx context.Context) error { delete(f.values, ev.Key) } else { f.values[ev.Key] = ev.Value + written[ev.Key] = ev.At // a change taken again was written when its event says } f.st.Revision = ev.Revision } @@ -259,7 +263,7 @@ func (f *Follower) askOnce(ctx context.Context) error { if f.Changed != nil { for k, v := range values { if ov, ok := old[k]; !ok || string(ov) != string(v) { - f.Changed(k, v, false, time.Time{}) + f.Changed(k, v, false, written[k]) } } for k := range old { diff --git a/go/views/process_reader.go b/go/views/process_reader.go new file mode 100644 index 0000000..c12ce8a --- /dev/null +++ b/go/views/process_reader.go @@ -0,0 +1,546 @@ +package views + +// A process's pictures of the owners' states it used to read (novox/hq ADR 0615 §1, design 25 § 12.2), for the parts +// that run as processes: each state is asked once through the owner's ordinary read tool with `values` (the first +// view), then kept from the owner's views family, asked again only on a gap or by hand (Follower). +// +// A Picture reads as the key-value bucket the process used to bind (jetstream.KeyValue: Get, Keys, ListKeys, Watch, +// WatchAll), so a reader's code is unchanged; every write is refused, for the owner's state is the owner's alone. The +// picture is kept in memory: the owner's state is the truth and a restart asks again (ADR 0615 §4). ProcessReader is +// the process side of Reader (the stdio side). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/process" +) + +// ErrReadOnly is every write to a picture: the state is its owner's. +var ErrReadOnly = errors.New("a first view is read-only: the state is its owner's") + +// OwnerReadTool is the owner's ordinary read tool of state, as a reader's `invokes` names it (design 25 § 12.2): +// `._read_`, short the owner's name past its last dash-separated `mesh-delivery-` prefix +// (indexer, assigner) and the state's dashes as underscores. +func OwnerReadTool(owner, state string) string { + short := strings.TrimPrefix(owner, "mesh-delivery-") + return owner + "." + short + "_read_" + strings.ReplaceAll(state, "-", "_") +} + +// ProcessReader is one process's pictures of its owners' states. +type ProcessReader struct { + // Module is the reading module, named in the finding of a first view unanswered past its bound. + Module string + // Call asks a module's tool (process.Conn.Call): the read tools are asked with `values`. + Call func(key string, body any) (json.RawMessage, error) + // Findings is the reader's own state `findings`, where the condition keeper raises and clears the condition of a + // first view unanswered past its bound (ADR 0615 §6); nil says it in the log only. + Findings func() jetstream.KeyValue + Logf func(string, ...any) + // Now, Bound and Wait are for tests: zero are the clock, Bound and five seconds. + Now func() time.Time + Bound time.Duration + Wait time.Duration + + mu sync.Mutex + pics map[string]*Picture // by bucket name _ + order []string +} + +func (r *ProcessReader) logf(format string, args ...any) { + if r.Logf != nil { + r.Logf(format, args...) + } +} + +// Follow adds the owner's state to follow through its read tool (OwnerReadTool). Call it before Start. +func (r *ProcessReader) Follow(owner, state string) *Picture { + r.mu.Lock() + defer r.mu.Unlock() + if r.pics == nil { + r.pics = map[string]*Picture{} + } + bucket := owner + "_" + state + if p := r.pics[bucket]; p != nil { + return p + } + tool := OwnerReadTool(owner, state) + p := &Picture{bucket: bucket, ctx: context.Background()} + call := r.Call + ask := AskVia(func(key string, body any) (json.RawMessage, error) { + m, _ := body.(map[string]any) + if m == nil { + m = map[string]any{} + } + m["values"] = true + if call == nil { + return nil, errors.New("this reader asks no tool") + } + return call(key, m) + }, tool) + p.f = &Follower{Owner: owner, Tool: tool, State: state, Ask: ask, Logf: r.Logf, Now: r.Now, Bound: r.Bound, Wait: r.Wait, + Raise: func(s Status) { r.finding(s, true) }, Clear: func(s Status) { r.finding(s, false) }, + Changed: p.changed} + r.pics[bucket] = p + r.order = append(r.order, bucket) + return p +} + +// Picture is the picture of the bucket the process used to bind, by its bus name (`_`); nil when not +// followed. +func (r *ProcessReader) Picture(bucket string) *Picture { + r.mu.Lock() + defer r.mu.Unlock() + return r.pics[bucket] +} + +// KeyValue opens a followed state's picture by its bus name, where a process opened the bucket (process.BucketOpener, +// as buildrun.FollowPlacement takes it); any other bucket is refused, for the reader reads its owners' states by their +// first views alone. +func (r *ProcessReader) KeyValue(_ context.Context, bucket string) (jetstream.KeyValue, error) { + if p := r.Picture(bucket); p != nil { + return p, nil + } + return nil, fmt.Errorf("%s reads %s by its owner's first view alone, and does not follow it", r.Module, bucket) +} + +// Followers are the followers, in the order they were added. +func (r *ProcessReader) Followers() []*Follower { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Follower, 0, len(r.order)) + for _, b := range r.order { + out = append(out, r.pics[b].f) + } + return out +} + +func (r *ProcessReader) finding(s Status, raised bool) { + if raised { + r.logf("the first view %s of %s's %s is unanswered past its bound: %s", s.Tool, s.Owner, s.State, s.Error) + } + if r.Findings == nil { + return + } + kv := r.Findings() + if kv == nil { + return + } + at := time.Now() + if r.Now != nil { + at = r.Now() + } + key, value, err := UnansweredFinding(r.Module, s, at) + if err == nil && raised { + _, err = kv.Put(context.Background(), key, value) + } else if err == nil { + err = kv.Delete(context.Background(), key) + } + if err != nil { + r.logf("views: the finding of the first view %s is not written: %v", s.Tool, err) + } +} + +// Hear is the handler of the owner's views family on the reader's durable consumer (cred.EventConsumer on the owner's +// views stream): each event to the follower of its state. +func (r *ProcessReader) Hear(ctx context.Context, owner string) func(jetstream.Msg) { + fs := map[string]*Follower{} + for _, f := range r.Followers() { + if f.Owner == owner { + fs[f.State] = f + } + } + return Hear(ctx, fs, r.logf) +} + +// Owners are the owners followed, sorted. +func (r *ProcessReader) Owners() []string { + var out []string + for _, f := range r.Followers() { + if !slices.Contains(out, f.Owner) { + out = append(out, f.Owner) + } + } + slices.Sort(out) + return out +} + +// Ask asks every first view until answered, each on a goroutine of its own, so the process starts at once and waits for +// an owner that is down, saying so (ADR 0615 §6). Said, when set, keeps each status (KeepStatus). +func (r *ProcessReader) Ask(ctx context.Context, said func(Status)) { + for _, f := range r.Followers() { + if said != nil { + f.Said = said + } + go func() { + if err := f.Start(ctx); err != nil { + r.logf("the first view %s was not answered: %v", f.Tool, err) + } + }() + } +} + +// Ready is closed once every followed state had its first view. +func (r *ProcessReader) Ready() <-chan struct{} { + fs := r.Followers() + done := make(chan struct{}) + go func() { + for _, f := range fs { + <-f.Ready() + } + close(done) + }() + return done +} + +// Picture is one owner's state as the process reads it: a read-only jetstream.KeyValue over a Follower. Its +// revisions are the picture's own, one per change it took, so a watcher sees them rise; they are not the owner's. An +// entry's Created is when the owner's bus wrote the key (Follower.Changed), zero when the owner did not say it. +type Picture struct { + jetstream.KeyValue // never set: every method a reader may call is the picture's own + + bucket string + ctx context.Context + f *Follower + + mu sync.Mutex + seq uint64 + revs map[string]uint64 + ats map[string]time.Time // when the owner's bus wrote each key, as the follower handed it (zero unsaid) + watchers []*watcher +} + +// Follower is the picture's follower (its status, a gap or a hand ask). +func (p *Picture) Follower() *Follower { return p.f } + +func (p *Picture) changed(key string, value json.RawMessage, deleted bool, at time.Time) { + p.mu.Lock() + p.seq++ + if p.revs == nil { + p.revs = map[string]uint64{} + } + p.revs[key] = p.seq + if p.ats == nil { + p.ats = map[string]time.Time{} + } + p.ats[key] = at + op := jetstream.KeyValuePut + if deleted { + op, value = jetstream.KeyValueDelete, nil + } + e := entry{bucket: p.bucket, key: key, value: value, rev: p.seq, at: at, op: op} + ws := slices.Clone(p.watchers) + p.mu.Unlock() + for _, w := range ws { + w.send(e) + } +} + +// answered is nil once the first view is answered, else the error a read before it gets (never a wait). +func (p *Picture) answered() error { + select { + case <-p.f.Ready(): + return nil + default: + return fmt.Errorf("no first view of %s yet: %s has not answered", p.bucket, p.f.Tool) + } +} + +func (p *Picture) ready(ctx context.Context) error { + select { + case <-p.f.Ready(): + return nil + case <-ctx.Done(): + return fmt.Errorf("no first view of %s yet (%s has not answered): %w", p.bucket, p.f.Tool, ctx.Err()) + } +} + +// Bucket is the bus name of the owner's state the picture stands for. +func (p *Picture) Bucket() string { return p.bucket } + +// Get is one key; before the first view is answered it is an error, never a wait. +func (p *Picture) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + if err := p.answered(); err != nil { + return nil, err + } + v, ok := p.f.Get(key) + if !ok { + return nil, jetstream.ErrKeyNotFound + } + p.mu.Lock() + rev, at := p.revs[key], p.ats[key] + p.mu.Unlock() + return entry{bucket: p.bucket, key: key, value: v, rev: rev, at: at, op: jetstream.KeyValuePut}, nil +} + +// Keys is every key; none is jetstream.ErrNoKeysFound, as the bus says it; before the first view is answered it is an +// error, never a wait. +func (p *Picture) Keys(_ context.Context, _ ...jetstream.WatchOpt) ([]string, error) { + if err := p.answered(); err != nil { + return nil, err + } + var keys []string + for k := range p.f.Values() { + keys = append(keys, k) + } + if len(keys) == 0 { + return nil, jetstream.ErrNoKeysFound + } + slices.Sort(keys) + return keys, nil +} + +// ListKeys is Keys on a channel. +func (p *Picture) ListKeys(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyLister, error) { + keys, err := p.Keys(ctx, opts...) + if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, err + } + ch := make(chan string, len(keys)) + for _, k := range keys { + ch <- k + } + close(ch) + return lister{ch}, nil +} + +// WatchAll is Watch of every key. +func (p *Picture) WatchAll(ctx context.Context, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + return p.Watch(ctx, ">", opts...) +} + +// Watch hands, once the first view is answered, every current value of the keys matching (a subject pattern: `*` one +// token, `>` the rest), then nil, then each change, as the bus's watch does. Options are not read: a picture holds no +// history, and deletes are always handed. +func (p *Picture) Watch(ctx context.Context, keys string, _ ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + w := &watcher{ch: make(chan jetstream.KeyValueEntry, 64), match: keys, wake: make(chan struct{}, 1), stop: make(chan struct{})} + go func() { + defer close(w.ch) + if p.ready(ctx) != nil { + return + } + p.mu.Lock() + p.watchers = append(p.watchers, w) + p.mu.Unlock() + defer p.unwatch(w) + vals := p.f.Values() + names := make([]string, 0, len(vals)) + for k := range vals { + names = append(names, k) + } + slices.Sort(names) + p.mu.Lock() + first := make([]jetstream.KeyValueEntry, 0, len(names)+1) + for _, k := range names { + if matches(keys, k) { + first = append(first, entry{bucket: p.bucket, key: k, value: vals[k], rev: p.revs[k], at: p.ats[k], op: jetstream.KeyValuePut}) + } + } + p.mu.Unlock() + first = append(first, nil) + w.run(ctx, first) + }() + return w, nil +} + +func (p *Picture) unwatch(w *watcher) { + p.mu.Lock() + defer p.mu.Unlock() + p.watchers = slices.DeleteFunc(p.watchers, func(x *watcher) bool { return x == w }) +} + +// The writes and the reads a picture cannot answer are refused, never handed to a bucket. +func (p *Picture) Put(context.Context, string, []byte) (uint64, error) { return 0, ErrReadOnly } +func (p *Picture) PutString(context.Context, string, string) (uint64, error) { return 0, ErrReadOnly } +func (p *Picture) Create(context.Context, string, []byte, ...jetstream.KVCreateOpt) (uint64, error) { + return 0, ErrReadOnly +} +func (p *Picture) Update(context.Context, string, []byte, uint64) (uint64, error) { + return 0, ErrReadOnly +} +func (p *Picture) Delete(context.Context, string, ...jetstream.KVDeleteOpt) error { return ErrReadOnly } +func (p *Picture) Purge(context.Context, string, ...jetstream.KVDeleteOpt) error { return ErrReadOnly } +func (p *Picture) PurgeDeletes(context.Context, ...jetstream.KVPurgeOpt) error { return ErrReadOnly } +func (p *Picture) GetRevision(context.Context, string, uint64) (jetstream.KeyValueEntry, error) { + return nil, errors.New("a first view holds no history") +} +func (p *Picture) History(context.Context, string, ...jetstream.WatchOpt) ([]jetstream.KeyValueEntry, error) { + return nil, errors.New("a first view holds no history") +} +func (p *Picture) WatchFiltered(ctx context.Context, keys []string, opts ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) { + if len(keys) != 1 { + return nil, errors.New("a first view watches one pattern") + } + return p.Watch(ctx, keys[0], opts...) +} +func (p *Picture) ListKeysFiltered(ctx context.Context, filters ...string) (jetstream.KeyLister, error) { + keys, err := p.Keys(ctx) + if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, err + } + ch := make(chan string, len(keys)) + for _, k := range keys { + if len(filters) == 0 || slices.ContainsFunc(filters, func(f string) bool { return matches(f, k) }) { + ch <- k + } + } + close(ch) + return lister{ch}, nil +} +func (p *Picture) Status(context.Context) (jetstream.KeyValueStatus, error) { + return nil, errors.New("a first view has no bucket status: the owner's read tool says its fill") +} + +// matches says whether key matches a subject pattern of the bus's watch. +func matches(pattern, key string) bool { + if pattern == "" || pattern == ">" { + return true + } + pt, kt := strings.Split(pattern, "."), strings.Split(key, ".") + for i, t := range pt { + if t == ">" { + return len(kt) > i + } + if i >= len(kt) || (t != "*" && t != kt[i]) { + return false + } + } + return len(pt) == len(kt) +} + +// watcher hands a watch's entries in order: the current values first, then each change heard, never blocking the +// follower (the changes wait in a queue of its own). +type watcher struct { + ch chan jetstream.KeyValueEntry + match string + wake chan struct{} + stop chan struct{} + once sync.Once + + mu sync.Mutex + queue []jetstream.KeyValueEntry +} + +func (w *watcher) send(e entry) { + if !matches(w.match, e.key) { + return + } + w.mu.Lock() + w.queue = append(w.queue, e) + w.mu.Unlock() + select { + case w.wake <- struct{}{}: + default: + } +} + +func (w *watcher) run(ctx context.Context, first []jetstream.KeyValueEntry) { + out := func(e jetstream.KeyValueEntry) bool { + select { + case w.ch <- e: + return true + case <-ctx.Done(): + return false + case <-w.stop: + return false + } + } + for _, e := range first { + if !out(e) { + return + } + } + for { + w.mu.Lock() + q := w.queue + w.queue = nil + w.mu.Unlock() + for _, e := range q { + if !out(e) { + return + } + } + select { + case <-w.wake: + case <-ctx.Done(): + return + case <-w.stop: + return + } + } +} + +func (w *watcher) Updates() <-chan jetstream.KeyValueEntry { return w.ch } + +func (w *watcher) Stop() error { + w.once.Do(func() { close(w.stop) }) + return nil +} + +type lister struct{ ch chan string } + +func (l lister) Keys() <-chan string { return l.ch } +func (l lister) Stop() error { return nil } + +type entry struct { + bucket, key string + value []byte + rev uint64 + at time.Time + op jetstream.KeyValueOp +} + +func (e entry) Bucket() string { return e.bucket } +func (e entry) Key() string { return e.key } +func (e entry) Value() []byte { return e.value } +func (e entry) Revision() uint64 { return e.rev } +func (e entry) Created() time.Time { return e.at } +func (e entry) Delta() uint64 { return 0 } +func (e entry) Operation() jetstream.KeyValueOp { return e.op } + +// StreamOf is the bus name of owner's views family (IndexerStream, AssignerStream): `-views` +// as the owner's old `streams` section names it. +func StreamOf(owner string) string { + return catalogue.StreamBusName(strings.TrimPrefix(owner, "mesh-delivery-") + "-views") +} + +// Start hears each owner's views family on the process's durable consumer of it (its manifest's `consumes` +// `.views.`), keeps each status in the process's state `first-views` and hears the hand asks the tools +// bundle's `_first_view_again` writes there (StatusTools), then asks every first view (Ask). A consumer +// the bus does not have yet is said: the picture is then the first view's alone, asked again on a gap or by hand. +func (r *ProcessReader) Start(ctx context.Context, js jetstream.JetStream, cred process.Account, status *process.AwaitedBucket) { + for _, owner := range r.Owners() { + stream := StreamOf(owner) + cons, err := cred.EventConsumer(ctx, js, stream) + if err == nil { + _, err = process.HearEvents(cons, r.Hear(ctx, owner), r.logf) + } + if err != nil { + r.logf("the consumer %s on %s is not heard (%v): %s's states are the first view's alone, asked again by hand, until the %s starts again", + cred.EventConsumerName(), stream, err, owner, r.Module) + } + } + var said func(Status) + if status != nil { + status.Ready(func(kv jetstream.KeyValue) { + if err := HearAgain(ctx, kv, r.Followers(), r.logf); err != nil { + r.logf("views: the hand asks of %s's first views are not heard (%v) until it starts again", r.Module, err) + } + }) + said = func(s Status) { + if kv := status.KV(); kv != nil { + KeepStatus(ctx, kv, r.logf)(s) + } + } + } + r.Ask(ctx, said) +} diff --git a/go/views/tools.go b/go/views/tools.go index e14f3e3..20b164e 100644 --- a/go/views/tools.go +++ b/go/views/tools.go @@ -22,7 +22,7 @@ func (k KeptSource) Get(key string) (*Entry, bool, error) { if err != nil || e == nil { return nil, false, err } - return &Entry{Value: e.Value, Revision: e.Revision}, true, nil + return &Entry{Value: e.Value, Revision: e.Revision, Written: e.At}, true, nil } // ParseCallers is a callers list as a manifest writes it into an env value: comma-separated, each a module (on any diff --git a/go/views/views.go b/go/views/views.go index 2be1f32..287f058 100644 --- a/go/views/views.go +++ b/go/views/views.go @@ -146,6 +146,9 @@ type Answer struct { Values map[string]json.RawMessage `json:"values"` Next string `json:"next,omitempty"` At time.Time `json:"at"` + // Written is when the owner's bus wrote each key of Values, as its state keeps it; a key whose time the owner's + // source does not say is absent. + Written map[string]time.Time `json:"written,omitempty"` } // DecodeAnswer reads an answer of this version. @@ -160,10 +163,11 @@ func DecodeAnswer(data []byte) (Answer, error) { return a, nil } -// Entry is one key's value and revision, as a state read gives it. +// Entry is one key's value and revision, as a state read gives it, and when the bus wrote it (zero when unsaid). type Entry struct { Value json.RawMessage Revision uint64 + Written time.Time } // Source is the read side of one state, as a tools bundle reaches its own state (stdio.KeptState's Keys and Get). @@ -212,6 +216,12 @@ func FirstView(owner, state string, values, marks Source, after string, now time break } a.Values[k] = e.Value + if !e.Written.IsZero() { + if a.Written == nil { + a.Written = map[string]time.Time{} + } + a.Written[k] = e.Written.UTC() + } size += len(k) + len(e.Value) } return a, nil diff --git a/go/views/views_test.go b/go/views/views_test.go index 3625195..d4187a3 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -42,14 +42,19 @@ func markOf(state string, rev uint64, made time.Time) Entry { func TestViews(t *testing.T) { made := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) now := func() time.Time { return made.Add(time.Hour) } - values := mapSource{"a": {Value: json.RawMessage(`{"n":1}`), Revision: 7}, "b": {Value: json.RawMessage(`{"n":2}`), Revision: 9}} + written := made.Add(-time.Minute) + values := mapSource{"a": {Value: json.RawMessage(`{"n":1}`), Revision: 7, Written: written}, "b": {Value: json.RawMessage(`{"n":2}`), Revision: 9}} marks := mapSource{"modules": markOf("modules", 9, made)} - t.Run("a first view answers every value at the mark's revision, the mark read first", func(t *testing.T) { + t.Run("a first view answers every value at the mark's revision, the mark read first, with each key's written time", func(t *testing.T) { a, err := FirstView("o", "modules", values, marks, "", now()) if err != nil || a.Revision != 9 || !a.Created.Equal(made) || len(a.Values) != 2 || a.Next != "" { t.Fatalf("answer = %+v, %v", a, err) } + raw, _ := json.Marshal(a) + if back, err := DecodeAnswer(raw); err != nil || !back.Written["a"].Equal(written) || len(back.Written) != 1 { + t.Errorf("written times = %v (%v), want a's alone", back.Written, err) + } if a, _ := FirstView("o", "modules", values, mapSource{}, "", now()); a.Revision != 0 { t.Errorf("a state not marked answers revision %d, want 0", a.Revision) } @@ -260,6 +265,55 @@ func TestViews(t *testing.T) { t.Errorf("asked again by hand: %v, %v", again, err) } }) + t.Run("a process's reader reads its owner's state as a read-only bucket, each key's written time handed on, and opens no other", func(t *testing.T) { + r := &ProcessReader{Module: "reader", Bound: time.Nanosecond, Wait: time.Millisecond, Now: now, + Call: func(tool string, body any) (json.RawMessage, error) { + if m, _ := body.(map[string]any); tool != "mesh-delivery-indexer.indexer_read_modules" || m["values"] != true { + return nil, fmt.Errorf("asked %s with %v", tool, body) + } + a, err := FirstView("o", "modules", values, marks, "", now()) + raw, _ := json.Marshal(map[string]any{"result": a}) + return raw, err + }} + p := r.Follow("mesh-delivery-indexer", "modules") + if _, err := p.Get(context.Background(), "a"); err == nil { + t.Errorf("a picture with no first view is read") + } + if _, err := r.KeyValue(context.Background(), "mesh-delivery-indexer_other"); err == nil { + t.Errorf("a bucket not followed is opened") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r.Ask(ctx, nil) + <-r.Ready() + kv, err := r.KeyValue(ctx, "mesh-delivery-indexer_modules") + if err != nil { + t.Fatal(err) + } + e, err := kv.Get(ctx, "a") + if err != nil || string(e.Value()) != `{"n":1}` || !e.Created().Equal(written) { + t.Fatalf("a = %v, %v", e, err) + } + if _, err := kv.Put(ctx, "a", nil); !errors.Is(err, ErrReadOnly) { + t.Errorf("a write to a picture: %v", err) + } + w, _ := kv.Watch(ctx, "*") + var seen []string + for e := range w.Updates() { + if e == nil { + break + } + seen = append(seen, e.Key()) + } + at := made.Add(2 * time.Minute) + ch := ev(10, "c", `{"n":3}`) + ch.At = at + p.Follower().Apply(ch) + if e := <-w.Updates(); strings.Join(seen, ",") != "a,b" || e.Key() != "c" || !e.Created().Equal(at) { + t.Errorf("watch handed %v then %v", seen, e) + } + _ = w.Stop() + }) t.Run("an ask through a tool reads the answer bare or in the tool runner's envelope, and its error", func(t *testing.T) { bare := `{"v":1,"owner":"o","state":"s","revision":3,"values":{"k":"1"}}` for name, c := range map[string]struct { diff --git a/go/views/viewstest/owner.go b/go/views/viewstest/owner.go new file mode 100644 index 0000000..eb5a1f8 --- /dev/null +++ b/go/views/viewstest/owner.go @@ -0,0 +1,107 @@ +// Package viewstest is an owner of a state as a reader's whole-module test needs it (novox/hq ADR 0615): the +// owner's read tool answering the first view from its bucket at its mark, and its views family said by the real +// views emitter onto its views stream, which the reader's durable consumer reads. +package viewstest + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// Serve is owner's state on the bus with the owner's side of it: the bucket `_` (made when absent, history +// 1), the owner's marks, its views stream with a durable consumer per reader (`_`), the emitter saying +// each change, and the read tool (views.OwnerReadTool) answering on nc. It returns the bucket the test writes. +func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state string, readers ...string) jetstream.KeyValue { + t.Helper() + bucket := owner + "_" + state + kv, err := js.KeyValue(ctx, bucket) + if err != nil { + if kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 1}); err != nil { + t.Fatal(err) + } + } + marks, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: owner + "_" + views.MarksState, History: 1}) + if err != nil { + t.Fatal(err) + } + stream := views.StreamOf(owner) + if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{process.ModuleStreamSubject(owner, views.EventPrefix+">")}}); err != nil { + t.Fatal(err) + } + for _, r := range readers { + if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: r, AckPolicy: jetstream.AckExplicitPolicy}); err != nil { + t.Fatal(err) + } + } + em := views.Emitter{Owner: owner, Subject: func(e string) string { return process.ModuleStreamSubject(owner, e) }, + Publish: views.JetStreamPublish(js), Marks: marks} + if err := em.Start(ctx, state, kv, nil); err != nil { + t.Fatal(err) + } + tool := strings.TrimPrefix(views.OwnerReadTool(owner, state), owner+".") + sub, err := nc.Subscribe("mesh.mod."+owner+".call."+tool+".>", func(m *nats.Msg) { + var in struct { + After string `json:"after"` + } + _ = json.Unmarshal(m.Data, &in) + a, err := views.FirstView(owner, state, Source{ctx, kv}, Source{ctx, marks}, in.After, time.Now()) + out := map[string]any{"result": a, "node": "n0"} + if err != nil { + out = map[string]any{"error": err.Error()} + } + raw, _ := json.Marshal(out) + _ = m.Respond(raw) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + return kv +} + +// Grants are the subjects a reader's account publishes to follow owner's state (its `invokes` and `consumes`): the +// read tool's call subjects, its durable consumer's info, next and acknowledgements on the owner's views stream. +func Grants(owner, state, durable string) []string { + tool := strings.TrimPrefix(views.OwnerReadTool(owner, state), owner+".") + stream := views.StreamOf(owner) + return []string{"mesh.mod." + owner + ".call." + tool + ".>", "mesh.mod." + owner + ".call." + tool, + "$JS.API.CONSUMER.INFO." + stream + "." + durable, "$JS.API.CONSUMER.MSG.NEXT." + stream + "." + durable, + "$JS.ACK." + stream + "." + durable + ".>"} +} + +// Source is a bucket as a views.Source. +type Source struct { + Ctx context.Context + KV jetstream.KeyValue +} + +// Keys is every key; none is no error. +func (s Source) Keys() ([]string, error) { + keys, err := s.KV.Keys(s.Ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + return keys, err +} + +// Get is one key's value, revision and the time the bus wrote it. +func (s Source) Get(key string) (*views.Entry, bool, error) { + e, err := s.KV.Get(s.Ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &views.Entry{Value: e.Value(), Revision: e.Revision(), Written: e.Created()}, true, nil +} -- 2.54.0 From 5bb0662d19e51c277b209ef99e8714914bd9e4a4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:23:40 +0100 Subject: [PATCH 055/144] J3 (A2g ruling): a reader's again tool records its hand act through mesh-hand-acts before it asks StatusTools takes the reader's handacts.Recorder: again records the act through seat:mesh-hand-acts.hand-act before it writes the hand ask, and refuses, asking nothing, when the seat does not take it, as the stdio Reader's again does. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/status.go | 11 +++++++++-- go/views/views_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/go/views/status.go b/go/views/status.go index 19907d3..d60b89b 100644 --- a/go/views/status.go +++ b/go/views/status.go @@ -12,6 +12,7 @@ import ( "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" ) // StatusState is a reader's own state of its first views (design 25 § 12.2): at StatusKey each first view's Status, as @@ -92,8 +93,10 @@ func HearAgain(ctx context.Context, kv jetstream.KeyValue, followers []*Follower // StatusTools are a reader's read tool of its first views (read) and its re-read tool (again), in its tools bundle, // over its StatusState: read lists what the process last said of each; again writes a hand ask the process takes, -// naming the caller the runtime proved, and is recorded in the status's by-hand count and why. -func StatusTools(reader, read, again string, st stdio.KeptState, now func() time.Time) []stdio.Tool { +// naming the caller the runtime proved, and is recorded in the status's by-hand count and why. again is a hand act: +// acts records it through the seat mesh-hand-acts before the ask is written, and one the seat does not take is said and +// nothing is asked (the zero Recorder records nothing, so again refuses). +func StatusTools(reader, read, again string, st stdio.KeptState, acts handacts.Recorder, now func() time.Time) []stdio.Tool { return []stdio.Tool{ {Name: read, Description: fmt.Sprintf("%s's first views of the indexer's and the assigner's states (novox/hq ADR 0615): per owner and state, "+ @@ -136,6 +139,10 @@ func StatusTools(reader, read, again string, st stdio.KeptState, now func() time return nil, errors.New("again names the owner, the state and why") } a := HandAsk{V: Version, Owner: owner, State: state, Why: strings.TrimSpace(why), By: stdio.CallerOf(args), At: now().UTC()} + if _, err := acts.Record(handacts.Request{What: fmt.Sprintf("asked %s's first view of %s's %s again by hand", reader, owner, state), + Why: a.Why, Cause: "first-view-again", Verb: again}); err != nil { + return nil, fmt.Errorf("the hand act was not recorded through mesh-hand-acts, so nothing was asked again: %w", err) + } if _, err := st.Put(AgainKey(owner, state), a); err != nil { return nil, fmt.Errorf("the hand ask is not written: %w", err) } diff --git a/go/views/views_test.go b/go/views/views_test.go index d4187a3..7c4b703 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -314,6 +314,33 @@ func TestViews(t *testing.T) { } _ = w.Stop() }) + t.Run("a reader's again tool records its hand act through mesh-hand-acts before it writes the ask, and asks nothing when the seat does not take it", func(t *testing.T) { + var recorded []handacts.Request + take := errors.New("no responders") + acts := handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) { + if key != "seat:mesh-hand-acts.hand-act" { + return nil, fmt.Errorf("asked %s", key) + } + raw, _ := json.Marshal(body) + var q handacts.Request + _ = json.Unmarshal(raw, &q) + recorded = append(recorded, q) + if take != nil { + return nil, take + } + return json.RawMessage(`{"log":"mesh-hand-acts"}`), nil + }} + again := StatusTools("reader", "reader_first_views", "reader_first_view_again", stdio.State(StatusState), acts, now)[1] + args := map[string]any{"owner": "o", "state": "modules", "why": "doubted"} + if _, err := again.Run(args); err == nil || !strings.Contains(err.Error(), "nothing was asked again") || len(recorded) != 1 { + t.Errorf("a hand act the seat did not take: %v, recorded %v", err, recorded) + } + take = nil // taken: the ask is written next (here refused, for no runtime serves this test) + if _, err := again.Run(args); err == nil || !strings.Contains(err.Error(), "hand ask is not written") || len(recorded) != 2 || + recorded[1].Why != "doubted" || recorded[1].Verb != "reader_first_view_again" || !strings.Contains(recorded[1].What, "o's modules") { + t.Errorf("a hand act taken: %v, recorded %+v", err, recorded) + } + }) t.Run("an ask through a tool reads the answer bare or in the tool runner's envelope, and its error", func(t *testing.T) { bare := `{"v":1,"owner":"o","state":"s","revision":3,"values":{"k":"1"}}` for name, c := range map[string]struct { -- 2.54.0 From 1a3ef8101f9dedf5431c29c605c82848fab17fe4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:25:25 +0100 Subject: [PATCH 056/144] J3 (A2f findings 2, 4): a leaving module's accepted event is named by its module; a user naming no series accepts the first numbered one ManifestAccepted.EventID names the module when the digest is empty, so the indexer's own id for a module leaving the trunk can go and the id lives in one place. Manifest.AcceptedSeries takes the series the package has a numbered version in and answers the lowest, the series of its first numbered version (design 32), DefaultSeries while none is. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/config_test.go | 1 + go/catalogue/manifest.go | 34 ++++++++++++++++++++++++++++++---- go/delivery/verdicts.go | 6 +++++- go/delivery/verdicts_test.go | 5 +++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/go/catalogue/config_test.go b/go/catalogue/config_test.go index cc482dc..01e6925 100644 --- a/go/catalogue/config_test.go +++ b/go/catalogue/config_test.go @@ -145,6 +145,7 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { } if m.Settings["font"].RenamedFrom != "font-name" || m.AcceptedSeries("mesh-sdk") != "0.2" || m.AcceptedSeries("mesh-contracts") != DefaultSeries || m.Build.Artifacts[0].SeriesOf() != "0.2" || + m.AcceptedSeries("mesh-sdk", "0.1") != "0.2" || m.AcceptedSeries("mesh-contracts", "0.10", "x", "0.3", "1.0") != "0.3" || m.Build.Artifacts[0].GeneratedFrom != "seats" || m.Build.Artifacts[1].SeriesOf() != "0.1" { t.Fatalf("read %+v", m) } diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 5aecd73..94046c1 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -1243,13 +1243,39 @@ type Artifact struct { GeneratedFrom string `json:"generated-from,omitempty"` } -// AcceptedSeries is the series of the package of that name this module accepts: the one Packages names, or -// DefaultSeries. -func (m Manifest) AcceptedSeries(name string) string { +// AcceptedSeries is the series of the package of that name this module accepts: the one Packages names, or else the +// series of the package's first numbered version (design 32), the lowest of numbered, the series the package has a +// numbered version in (the indexer's to say; a series is only ever raised); DefaultSeries while none is numbered. +func (m Manifest) AcceptedSeries(name string, numbered ...string) string { if s := m.Packages[name]; s != "" { return s } - return DefaultSeries + first := "" + for _, s := range numbered { + if seriesOrder(s) != nil && (first == "" || compareSeries(s, first) < 0) { + first = s + } + } + if first == "" { + return DefaultSeries + } + return first +} + +// seriesOrder is a series' major and minor, or nil when it is no `.`. +func seriesOrder(s string) []int { + major, minor, ok := strings.Cut(s, ".") + a, err1 := strconv.Atoi(major) + b, err2 := strconv.Atoi(minor) + if !ok || err1 != nil || err2 != nil || a < 0 || b < 0 { + return nil + } + return []int{a, b} +} + +// compareSeries orders two well-formed series by major, then minor. +func compareSeries(a, b string) int { + return slices.Compare(seriesOrder(a), seriesOrder(b)) } // DefaultSeries is the series of an artifact or an accepted package that names none (design 32). diff --git a/go/delivery/verdicts.go b/go/delivery/verdicts.go index a6cede6..f54c0f3 100644 --- a/go/delivery/verdicts.go +++ b/go/delivery/verdicts.go @@ -234,7 +234,11 @@ func AcceptedOf(digest string, v ManifestVerdict, m catalogue.Manifest) Manifest } // EventID is the event's id, one per manifest digest and judge, so the bus takes it once: -// `manifest-accepted--`. +// `manifest-accepted--`. The empty one of a module leaving the trunk carries no digest, so it is named by +// its module: `manifest-accepted-left--`. func (a ManifestAccepted) EventID() string { + if a.Digest == "" { + return ManifestAcceptedEvent + "-left-" + a.Module + "-" + a.Judge + } return ManifestAcceptedEvent + "-" + a.Digest + "-" + a.Judge } diff --git a/go/delivery/verdicts_test.go b/go/delivery/verdicts_test.go index 90af075..dc48d77 100644 --- a/go/delivery/verdicts_test.go +++ b/go/delivery/verdicts_test.go @@ -103,6 +103,11 @@ func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { if id := heard.EventID(); id != "manifest-accepted-"+digest+"-src2:j" { t.Fatalf("the accepted event's id is %q", id) } + // The empty one of a module leaving the trunk has no digest: its id names the module, so two modules' differ. + if a, b := (ManifestAccepted{Module: "a", Judge: "j"}).EventID(), (ManifestAccepted{Module: "b", Judge: "j"}).EventID(); a == b || + a != "manifest-accepted-left-a-j" { + t.Fatalf("the ids of two modules leaving are %q and %q", a, b) + } // Both verdict events carry the indexer's revision (ADR 0622 §2), and one without it reads as revision 0. for _, ev := range []any{ManifestAccepted{Module: "m", Revision: 41}, ManifestRefused{Module: "m", Revision: 41}} { raw, _ := json.Marshal(ev) -- 2.54.0 From 635aed57fdfb83690abc068b7335e80955aedb29 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:26:26 +0100 Subject: [PATCH 057/144] J3 (A2i finding 2): a build's end names the generated files its test step found out of step delivery.BuildLogEntry gains out-of-step, an end's alone, as the build module's test step answers it, so the indexer reads the field from the entry, not the raw message, and its trunk finding can be raised once the builder writes it. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/history_records.go | 13 +++++++++---- go/delivery/published_packages_test.go | 15 +++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/go/delivery/history_records.go b/go/delivery/history_records.go index 8f8f23a..fd4b5bd 100644 --- a/go/delivery/history_records.go +++ b/go/delivery/history_records.go @@ -179,6 +179,10 @@ type BuildLogEntry struct { // sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer // keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's. Imports []string `json:"imports,omitempty"` + // OutOfStep are, at a build's end, the generated files its test step found out of step with what their generator + // writes, as the build module answered them (its test step's `out-of-step`): the indexer raises a finding naming them + // for a trunk build, and only a clean build of the same module clears it. A start or a narration names none. + OutOfStep []string `json:"out-of-step,omitempty"` // Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone. Line string `json:"line,omitempty"` Why string `json:"why,omitempty"` @@ -225,8 +229,8 @@ func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) { switch { case e.Module == "" || e.FP == "" || e.At.IsZero() || e.Line == "": return nil, fmt.Errorf("delivery: a build's narration names its module, fingerprint, line and time") - case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0: - return nil, fmt.Errorf("delivery: a build's narration names no outcome, manifest, base, artifact or import") + case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || len(e.OutOfStep) > 0: + return nil, fmt.Errorf("delivery: a build's narration names no outcome, manifest, base, artifact, import or file out of step") } return json.Marshal(e) case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent: @@ -237,8 +241,9 @@ func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) { return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time") case e.Event == BuildEndedEvent && e.Outcome == "": return nil, fmt.Errorf("delivery: a build's end names its outcome") - case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0): - return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base, artifact or import") + case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || + len(e.OutOfStep) > 0): + return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base, artifact, import or file out of step") } if err := CheckImports(e.Imports); err != nil { return nil, err diff --git a/go/delivery/published_packages_test.go b/go/delivery/published_packages_test.go index bd44b58..773f022 100644 --- a/go/delivery/published_packages_test.go +++ b/go/delivery/published_packages_test.go @@ -57,17 +57,23 @@ func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) { } imports := []string{sdk, sdk + "/announce"} + outOfStep := []string{"go/seats/meshbroker/contract.go", "ts/seats/meshbroker/src/index.ts"} end := BuildLogEntry{Event: BuildEndedEvent, Module: "netcheck", FP: "src2:x", Repository: "novox/mesh-catalog", Commit: commit, - Node: "ace", At: good.At, Outcome: "done", Imports: imports} + Node: "ace", At: good.At, Outcome: "done", Imports: imports, OutOfStep: outOfStep} raw, err = EncodeBuildLogEntry(end) if err != nil { t.Fatal(err) } - if e, err := DecodeBuildLogEntry(raw); err != nil || !reflect.DeepEqual(e.Imports, imports) { - t.Fatalf("build end round trip: %v, %v", err, e.Imports) + if e, err := DecodeBuildLogEntry(raw); err != nil || !reflect.DeepEqual(e.Imports, imports) || !reflect.DeepEqual(e.OutOfStep, outOfStep) || + !strings.Contains(string(raw), `"out-of-step":["go/seats/meshbroker/contract.go",`) { + t.Fatalf("build end round trip: %v, %v, %v in %s", err, e.Imports, e.OutOfStep, raw) } start := end - start.Event, start.Outcome = BuildStartedEvent, "" + start.Event, start.Outcome, start.Imports = BuildStartedEvent, "", nil + if _, err := EncodeBuildLogEntry(start); err == nil { + t.Error("a build's start with files out of step encoded") + } + start.Imports, start.OutOfStep = imports, nil if _, err := EncodeBuildLogEntry(start); err == nil { t.Error("a build's start with imports encoded") } @@ -87,6 +93,7 @@ func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) { for name, mutate := range map[string]func(e *BuildLogEntry){ "a narration with no line": func(e *BuildLogEntry) { e.Line = "" }, "a narration with an outcome": func(e *BuildLogEntry) { e.Outcome = "done" }, + "a narration out of step": func(e *BuildLogEntry) { e.OutOfStep = outOfStep }, "an end with a line": func(e *BuildLogEntry) { e.Event, e.Outcome, e.Node, e.Repository, e.Commit = BuildEndedEvent, "done", "ace", "novox/mesh-catalog", commit }, -- 2.54.0 From 3218da4ee4b17c53632dfa5a6bf41b59369a01a8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:28:23 +0100 Subject: [PATCH 058/144] J3: mesh-sdk within its test budget, each file of declaration and the provider harness one test of subtests The unit test functions fall from 587 to 476 (the budget is 500): the cases of each of twenty test files in declaration and provider/harness become subtests of one test named for the file, under their own names, their bodies unchanged. Integration tests stay at 30 (budget 50). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/declaration/adoption_test.go | 34 +++++--- go/declaration/block_test.go | 10 ++- go/declaration/cron_test.go | 16 +++- go/declaration/declaration_test.go | 91 ++++++++++++++------- go/declaration/filereference_test.go | 22 +++-- go/declaration/groups_test.go | 10 ++- go/declaration/identity_test.go | 10 ++- go/declaration/module_test.go | 13 ++- go/declaration/order_test.go | 13 ++- go/declaration/ownership_test.go | 19 +++-- go/declaration/process_test.go | 34 +++++--- go/declaration/provisioner_test.go | 10 ++- go/declaration/replaces_test.go | 19 +++-- go/declaration/schedule_declaration_test.go | 13 ++- go/declaration/userscope_test.go | 10 ++- go/provider/harness/grants_moved_test.go | 16 +++- go/provider/harness/harness_test.go | 22 +++-- go/provider/harness/moved_test.go | 28 +++++-- go/provider/harness/retirement_test.go | 55 +++++++++---- go/provider/harness/standing_test.go | 28 +++++-- 20 files changed, 342 insertions(+), 131 deletions(-) diff --git a/go/declaration/adoption_test.go b/go/declaration/adoption_test.go index e422516..1889643 100644 --- a/go/declaration/adoption_test.go +++ b/go/declaration/adoption_test.go @@ -16,7 +16,7 @@ const adoptedResources = `"resources":[ const sixtyFour = "0000000000000000000000000000000000000000000000000000000000000000" -func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) { +func testAnAdoptionIsReadWithTheDeclaration(t *testing.T) { d, err := Parse([]byte(`{"adoption":{"taken":["postgres"],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}}, "declaration":1,` + adoptedResources + `}`)) if err != nil { @@ -36,7 +36,7 @@ func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) { } } -func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) { +func testADeclarationWithNoAdoptionIsConverged(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,` + adoptedResources + `}`)) if err != nil { t.Fatal(err) @@ -49,7 +49,7 @@ func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) { } } -func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) { +func testAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) { refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.missing"]}}, "declaration":1,`+adoptedResources+`}`) if !strings.Contains(strings.Join(refusal.Problems, "\n"), "hello-web.missing") { @@ -57,7 +57,7 @@ func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) { } } -func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) { +func testAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) { // A directory, a service or an action can reach what was found as surely as a file can, so // the controller lists every resource of an untaken module (novox/hq ADR 0103). d, err := Parse([]byte(`{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.data"]}}, @@ -70,7 +70,7 @@ func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) { } } -func TestAnIDUnderTwoModulesIsRefused(t *testing.T) { +func testAnIDUnderTwoModulesIsRefused(t *testing.T) { refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"a":["hello-web.page"],"b":["hello-web.page"]}}, "declaration":1,`+adoptedResources+`}`) if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both") { @@ -78,7 +78,7 @@ func TestAnIDUnderTwoModulesIsRefused(t *testing.T) { } } -func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) { +func testAModuleBothTakenAndUntakenIsRefused(t *testing.T) { refusal := refusalFor(t, `{"adoption":{"taken":["hello-web"],"untaken":{"hello-web":["hello-web.page"]}}, "declaration":1,`+adoptedResources+`}`) if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both taken and untaken") { @@ -86,7 +86,7 @@ func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) { } } -func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) { +func testTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) { refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"x":["adoption.guard"]}}, "declaration":1,"resources":[ {"id":"adoption.guard","type":"file","path":"/etc/mesh/guard.nft","content":"x"}]}`) @@ -95,11 +95,11 @@ func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) { } } -func TestAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) { +func testAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) { refusalFor(t, `{"adoption":{"taken":[],"held":["x"]},"declaration":1,`+adoptedResources+`}`) } -func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) { +func testACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) { _, err := ParseTrusted([]byte(`{"adoption":{"taken":[]},"declaration":1,` + adoptedResources + `}`)) if err == nil || !strings.Contains(err.Error(), "only the mesh can say") { t.Fatalf("a bundle claiming adoption was not refused: %v", err) @@ -107,7 +107,7 @@ func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) { } // novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node. -func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) { +func testTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) { adoptedWith := func(service string) error { _, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`)) return err @@ -130,3 +130,17 @@ func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) { } } } + +// TestAdoption holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestAdoption(t *testing.T) { + t.Run("AnAdoptionIsReadWithTheDeclaration", testAnAdoptionIsReadWithTheDeclaration) + t.Run("ADeclarationWithNoAdoptionIsConverged", testADeclarationWithNoAdoptionIsConverged) + t.Run("AnAdoptionNamingAnUnknownIDIsRefused", testAnAdoptionNamingAnUnknownIDIsRefused) + t.Run("AnAdoptionMayNameAResourceOfAnyKind", testAnAdoptionMayNameAResourceOfAnyKind) + t.Run("AnIDUnderTwoModulesIsRefused", testAnIDUnderTwoModulesIsRefused) + t.Run("AModuleBothTakenAndUntakenIsRefused", testAModuleBothTakenAndUntakenIsRefused) + t.Run("TheMeshsOwnResourcesAreNeverUntaken", testTheMeshsOwnResourcesAreNeverUntaken) + t.Run("AnAdoptionWithAnUnknownFieldIsRefused", testAnAdoptionWithAnUnknownFieldIsRefused) + t.Run("ACarriedBundleCannotSayTheNodeIsAdopted", testACarriedBundleCannotSayTheNodeIsAdopted) + t.Run("TakingOverATunnelIsSaidWholeAndForARunningService", testTakingOverATunnelIsSaidWholeAndForARunningService) +} diff --git a/go/declaration/block_test.go b/go/declaration/block_test.go index 1512a00..9a726ed 100644 --- a/go/declaration/block_test.go +++ b/go/declaration/block_test.go @@ -8,7 +8,7 @@ import ( // Defends novox/hq issue 128: a file written into a block carries only its lines, in content, // never a line the host keeps as its own marker, and says where a new region goes only as a block. -func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) { +func testAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) { for name, c := range map[string]struct{ resource, refusal string }{ "a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"}, "an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"}, @@ -30,7 +30,7 @@ func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) { } } -func TestAFileWrittenIntoABlockIsRead(t *testing.T) { +func testAFileWrittenIntoABlockIsRead(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"}, {"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"}, @@ -50,3 +50,9 @@ func TestAFileWrittenIntoABlockIsRead(t *testing.T) { t.Errorf("the markers are %q and %q", begin, end) } } + +// TestBlock holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestBlock(t *testing.T) { + t.Run("AFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines", testAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines) + t.Run("AFileWrittenIntoABlockIsRead", testAFileWrittenIntoABlockIsRead) +} diff --git a/go/declaration/cron_test.go b/go/declaration/cron_test.go index 51e3dc0..9340c91 100644 --- a/go/declaration/cron_test.go +++ b/go/declaration/cron_test.go @@ -9,7 +9,7 @@ import ( // arrival and computes the next due minute the scheduler fires on (novox/hq ADR 0053). These test // the parsing and the evaluation; the firing is tested against the scheduler. -func TestParseCronRefusesMalformed(t *testing.T) { +func testParseCronRefusesMalformed(t *testing.T) { for _, expr := range []string{ "", // nothing "* * * *", // four fields @@ -31,7 +31,7 @@ func TestParseCronRefusesMalformed(t *testing.T) { } } -func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) { +func testParseCronAcceptsTheOrdinaryForms(t *testing.T) { for _, expr := range []string{ "* * * * *", // every minute "0 3 * * *", // 03:00 daily @@ -47,7 +47,7 @@ func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) { } } -func TestCronNextIsTheNextMatchingMinute(t *testing.T) { +func testCronNextIsTheNextMatchingMinute(t *testing.T) { // A Monday at 12:00:30, so "next" must round up to a whole minute and land on the first match // strictly after now — never re-firing the minute we are already in. now := time.Date(2026, 9, 7, 12, 0, 30, 0, time.UTC) // 2026-09-07 is a Monday @@ -77,7 +77,7 @@ func TestCronNextIsTheNextMatchingMinute(t *testing.T) { } } -func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) { +func testCronDayFieldsAreOredWhenBothRestricted(t *testing.T) { // The standard cron surprise: when day-of-month and day-of-week are both set, a day matches if // EITHER does. "1 * 13 * 5" is due on the 13th OR on any Friday. 2026-09-07 is a Monday the 7th // — neither — and 2026-09-11 is a Friday, and 2026-11-13 is the 13th. @@ -98,3 +98,11 @@ func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) { t.Error("a Monday the 7th matched a cron restricted to the 13th OR Friday") } } + +// TestCron holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestCron(t *testing.T) { + t.Run("ParseCronRefusesMalformed", testParseCronRefusesMalformed) + t.Run("ParseCronAcceptsTheOrdinaryForms", testParseCronAcceptsTheOrdinaryForms) + t.Run("CronNextIsTheNextMatchingMinute", testCronNextIsTheNextMatchingMinute) + t.Run("CronDayFieldsAreOredWhenBothRestricted", testCronDayFieldsAreOredWhenBothRestricted) +} diff --git a/go/declaration/declaration_test.go b/go/declaration/declaration_test.go index 36063d4..8b3354e 100644 --- a/go/declaration/declaration_test.go +++ b/go/declaration/declaration_test.go @@ -30,7 +30,7 @@ func refusalFor(t *testing.T, raw string) *RefusalError { return refusal } -func TestAValidDeclarationParsesInOrder(t *testing.T) { +func testAValidDeclarationParsesInOrder(t *testing.T) { d, err := Parse([]byte(valid())) if err != nil { t.Fatalf("unexpected refusal: %v", err) @@ -45,7 +45,7 @@ func TestAValidDeclarationParsesInOrder(t *testing.T) { } } -func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) { +func testAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) { // The property everything else rests on. A host that skipped what it did not understand // would apply most of a declaration and report success — a node that looks configured and // is not, which is 04-ISSUES/003 with the declaration on the other side of the wire. @@ -64,7 +64,7 @@ func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) { } } -func TestAnUnknownFieldIsRefused(t *testing.T) { +func testAnUnknownFieldIsRefused(t *testing.T) { // A field the host does not know is a thing the control plane believes it asked for. refusal := refusalFor(t, `{"declaration":1,"resources":[ {"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true} @@ -74,7 +74,7 @@ func TestAnUnknownFieldIsRefused(t *testing.T) { } } -func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) { +func testAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) { // The same fault in miniature: set and ignored means the control plane believes it asked // for something the host will never do. refusal := refusalFor(t, `{"declaration":1,"resources":[ @@ -86,7 +86,7 @@ func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) { } } -func TestAnUnknownVersionIsRefusedWhole(t *testing.T) { +func testAnUnknownVersionIsRefusedWhole(t *testing.T) { // An older host handed a newer vocabulary must not quietly do half of it. refusal := refusalFor(t, `{"declaration":99,"resources":[ {"id":"a","type":"directory","path":"/etc/mesh"} @@ -102,7 +102,7 @@ func TestAnUnknownVersionIsRefusedWhole(t *testing.T) { } } -func TestEveryProblemIsReportedAtOnce(t *testing.T) { +func testEveryProblemIsReportedAtOnce(t *testing.T) { // A declaration is generated, so a person reading a refusal is debugging the generator. // Fixing one problem at a time and re-running to find the next wastes their afternoon. refusal := refusalFor(t, `{"declaration":1,"resources":[ @@ -115,7 +115,7 @@ func TestEveryProblemIsReportedAtOnce(t *testing.T) { } } -func TestIdentityIsRequiredAndUnique(t *testing.T) { +func testIdentityIsRequiredAndUnique(t *testing.T) { // Identity is what lets the store know this is the same resource it applied last time, // which is what makes removal possible at all. refusal := refusalFor(t, `{"declaration":1,"resources":[ @@ -127,7 +127,7 @@ func TestIdentityIsRequiredAndUnique(t *testing.T) { } } -func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) { +func testModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) { // "644" and "0644" differ, and the one that looks right in a manifest is the four-digit // form. Accepting both would make a mode mean two things. for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} { @@ -145,7 +145,7 @@ func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) { } } -func TestARefusalSaysNothingWasApplied(t *testing.T) { +func testARefusalSaysNothingWasApplied(t *testing.T) { // The reader's first question is whether the machine was left half-changed. refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`) if !strings.Contains(refusal.Error(), "none of it was applied") { @@ -153,13 +153,13 @@ func TestARefusalSaysNothingWasApplied(t *testing.T) { } } -func TestAnEmptyDeclarationIsAMistake(t *testing.T) { +func testAnEmptyDeclarationIsAMistake(t *testing.T) { refusalFor(t, `{"declaration":1,"resources":[]}`) } // --- the vocabulary the foundation bootstrap needs (novox/hq 07-the-foundation.md) --- -func TestAnActionOverTheLinkIsRefused(t *testing.T) { +func testAnActionOverTheLinkIsRefused(t *testing.T) { // novox/hq ADR 0005. The link may push declarations of known shape and never a command to // run. This is the boundary the whole security argument rests on, so it is asserted // directly rather than inferred from the type list. @@ -179,7 +179,7 @@ func TestAnActionOverTheLinkIsRefused(t *testing.T) { } } -func TestAnActionWithoutVerifyIsRefused(t *testing.T) { +func testAnActionWithoutVerifyIsRefused(t *testing.T) { // An action that runs and reports success without reading anything back is the fault this // host exists to prevent. Verify is also the idempotency check, so an action without one // cannot be applied twice safely either. @@ -194,7 +194,7 @@ func TestAnActionWithoutVerifyIsRefused(t *testing.T) { } } -func TestAnImageMustBePinnedByDigest(t *testing.T) { +func testAnImageMustBePinnedByDigest(t *testing.T) { // novox/hq ADR 0006: reproducibility comes from pinning the identity of a thing. A bundle // naming a tag pins nothing — it names whatever that tag points at on the day it runs. for _, image := range []string{ @@ -226,7 +226,7 @@ func TestAnImageMustBePinnedByDigest(t *testing.T) { // can start. The mesh's own control plane is built from source and lives in no public registry; a // machine that built it, or was handed it, names it by what it is — a content address, not a name, // and immutable in exactly the way the rule asks for. -func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) { +func testAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) { held := "sha256:" + strings.Repeat("b", 64) if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[ {"id":"control","type":"container","name":"mesh-controller","image":"` + held + `"} @@ -245,7 +245,7 @@ func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) { } } -func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { +func testAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { // The field-set check must cover the types added last, not only the three it was written // for. A package that carries a `content` is a control plane believing it asked for // something that will never happen. @@ -266,7 +266,7 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) { } } -func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) { +func testTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) { // Six of them the bootstrap uses (novox/hq 07-the-foundation.md), and removing one is a // failing test rather than a discovery during a first-node install. // @@ -320,7 +320,7 @@ func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) { } } -func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) { +func testADeclarationFromDiskMayBeAnnotated(t *testing.T) { // The bundle in this repository is mostly explanation of why each digest is what it is, and // it could be built into a binary and not applied from disk — two readers for one file. The // failure was `invalid character '/'`, which names the symptom and not the cause. @@ -346,7 +346,7 @@ func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) { } } -func TestSomethingInsideAValueIsNotAComment(t *testing.T) { +func testSomethingInsideAValueIsNotAComment(t *testing.T) { // Every image reference has a `//` in it somewhere near. Only whole lines are dropped. d, err := ParseFileTrusted([]byte(`{"declaration":1,"resources":[ {"id":"f","type":"file","path":"/etc/x","content":"see https://example.invalid/ for why"}]}`)) @@ -370,7 +370,7 @@ func TestSomethingInsideAValueIsNotAComment(t *testing.T) { // // Not hypothetical: a test harness appended a line to the foundation bundle by accident, every // apply kept working, and nothing said so for the entire time it was wrong. -func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) { +func testSomethingAfterTheDeclarationIsRefused(t *testing.T) { good := `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a",` + `"content":"x","mode":"0644"}]}` if _, err := ParseFileTrusted([]byte(good)); err != nil { @@ -394,7 +394,7 @@ func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) { } // A name that would read as a reference to something else is refused before it reaches a runtime. -func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) { +func testANetworkNameIsRefusedIfItIsNotOne(t *testing.T) { for _, name := range []string{"", "mail/private", "host:mail", "a b"} { refusal := refusalFor(t, `{"declaration":1,"resources":[ {"id":"private","type":"network","name":"`+name+`"} @@ -409,7 +409,7 @@ func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) { // // The alternative is a configuration file containing the literal `${secret:x}`, which the program // reads as a value and fails on somewhere with no connection to this. -func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) { +func testAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) { refusal := refusalFor(t, `{"declaration":1,"resources":[ {"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"} ]}`) @@ -419,7 +419,7 @@ func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) { } // And a secret nobody asked for, because somebody believes it is in a file where it is not. -func TestASecretTheContentNeverUsesIsRefused(t *testing.T) { +func testASecretTheContentNeverUsesIsRefused(t *testing.T) { refusal := refusalFor(t, `{"declaration":1,"resources":[ {"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}} ]}`) @@ -431,7 +431,7 @@ func TestASecretTheContentNeverUsesIsRefused(t *testing.T) { // The runtime's resolver and address flags take only addresses; a name would be refused at // create, after the old container was already gone. Refused on arrival instead — and an address // without a user-defined network is refused for the same reason. -func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) { +func testAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) { refused := func(body string) []string { _, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`)) if err == nil { @@ -452,7 +452,7 @@ func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) { } } -func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) { +func testAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) { // A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so // the node drops what it last held — distinct from an accidental empty body, which is refused. if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil { @@ -467,7 +467,7 @@ func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) { // A container's kept networks are names, not its own network, and not for a step (novox/hq ADR // 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not. -func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) { +func testKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) { pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64) d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[ {"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]} @@ -506,7 +506,7 @@ func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) { } // A container may ask for a capability by name, and nothing else (novox/hq ADR 0170). -func TestACapabilityIsNamedOrRefused(t *testing.T) { +func testACapabilityIsNamedOrRefused(t *testing.T) { image := "postgres@sha256:" + strings.Repeat("a", 64) d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]} @@ -527,7 +527,7 @@ func TestACapabilityIsNamedOrRefused(t *testing.T) { // A container may send its output to the machine's journal, and nowhere else but the runtime's own // file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too. -func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) { +func testAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) { image := "postgres@sha256:" + strings.Repeat("a", 64) d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"} @@ -548,7 +548,7 @@ func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) { // Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write. -func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) { +func testADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]}, {"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"} @@ -570,7 +570,7 @@ func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) { } } -func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) { +func testBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) { digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"` cases := map[string]string{ "bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`, @@ -593,3 +593,36 @@ func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) { }) } } + +// TestDeclaration holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestDeclaration(t *testing.T) { + t.Run("AValidDeclarationParsesInOrder", testAValidDeclarationParsesInOrder) + t.Run("AnUnknownTypeRefusesTheWholeDeclaration", testAnUnknownTypeRefusesTheWholeDeclaration) + t.Run("AnUnknownFieldIsRefused", testAnUnknownFieldIsRefused) + t.Run("AFieldTheTypeDoesNotUseIsRefusedNotIgnored", testAFieldTheTypeDoesNotUseIsRefusedNotIgnored) + t.Run("AnUnknownVersionIsRefusedWhole", testAnUnknownVersionIsRefusedWhole) + t.Run("EveryProblemIsReportedAtOnce", testEveryProblemIsReportedAtOnce) + t.Run("IdentityIsRequiredAndUnique", testIdentityIsRequiredAndUnique) + t.Run("ModeIsRefusedUnlessItMeansWhatItLooksLike", testModeIsRefusedUnlessItMeansWhatItLooksLike) + t.Run("ARefusalSaysNothingWasApplied", testARefusalSaysNothingWasApplied) + t.Run("AnEmptyDeclarationIsAMistake", testAnEmptyDeclarationIsAMistake) + t.Run("AnActionOverTheLinkIsRefused", testAnActionOverTheLinkIsRefused) + t.Run("AnActionWithoutVerifyIsRefused", testAnActionWithoutVerifyIsRefused) + t.Run("AnImageMustBePinnedByDigest", testAnImageMustBePinnedByDigest) + t.Run("AnImageTheMachineHoldsIsNamedByItsOwnDigest", testAnImageTheMachineHoldsIsNamedByItsOwnDigest) + t.Run("AFieldTheNewTypesDoNotUseIsRefused", testAFieldTheNewTypesDoNotUseIsRefused) + t.Run("TheVocabularyIsTheTwelveShapesTheMeshNeeds", testTheVocabularyIsTheTwelveShapesTheMeshNeeds) + t.Run("ADeclarationFromDiskMayBeAnnotated", testADeclarationFromDiskMayBeAnnotated) + t.Run("SomethingInsideAValueIsNotAComment", testSomethingInsideAValueIsNotAComment) + t.Run("SomethingAfterTheDeclarationIsRefused", testSomethingAfterTheDeclarationIsRefused) + t.Run("ANetworkNameIsRefusedIfItIsNotOne", testANetworkNameIsRefusedIfItIsNotOne) + t.Run("AFileAskingForASecretItWasNotGivenIsRefused", testAFileAskingForASecretItWasNotGivenIsRefused) + t.Run("ASecretTheContentNeverUsesIsRefused", testASecretTheContentNeverUsesIsRefused) + t.Run("AContainersResolverAndAddressAreAddressesOrRefused", testAContainersResolverAndAddressAreAddressesOrRefused) + t.Run("AnExplicitlyEmptyDeclarationIsAccepted", testAnExplicitlyEmptyDeclarationIsAccepted) + t.Run("KeptNetworksAndLeftOutModulesAreReadStrictly", testKeptNetworksAndLeftOutModulesAreReadStrictly) + t.Run("ACapabilityIsNamedOrRefused", testACapabilityIsNamedOrRefused) + t.Run("AContainerMayLogToTheJournalAndNowhereElse", testAContainerMayLogToTheJournalAndNowhereElse) + t.Run("ADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles", testADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles) + t.Run("BadUnknownAndKnowsFormsAreRefusedNamingTheResource", testBadUnknownAndKnowsFormsAreRefusedNamingTheResource) +} diff --git a/go/declaration/filereference_test.go b/go/declaration/filereference_test.go index be34ce1..e6fe246 100644 --- a/go/declaration/filereference_test.go +++ b/go/declaration/filereference_test.go @@ -15,7 +15,7 @@ func refFile(extra string) string { } // 1694/AC1: a reference round trips, and an invalid one is refused naming the field. -func TestAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) { +func testAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) { d, err := Parse([]byte(refFile(""))) if err != nil { t.Fatal(err) @@ -49,7 +49,7 @@ func TestAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) { } // 1694/AC2: a declaration as today's SDK writes it reads unchanged and its bytes carry no new key. -func TestAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) { +func testAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) { const in = `{"id":"m.f","type":"file","path":"/e","content":"hello"}` d, err := Parse([]byte(`{"declaration":1,"resources":[` + in + `]}`)) if err != nil { @@ -65,7 +65,7 @@ func TestAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) { } // 1694/AC3: the controller's helper makes a reference of a big file and the engine's check refuses a changed byte. -func TestABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) { +func testABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) { big := strings.Repeat("x", FileReferenceThreshold+1) f := &File{ID: "m.f", Type: TypeFile, Path: "/e", Content: big} if !f.TravelsByDigest() { @@ -95,7 +95,7 @@ func TestABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) { } // 1694/AC4: what stays inline, and bytes files travel decoded. -func TestWhichFilesTravelByDigest(t *testing.T) { +func testWhichFilesTravelByDigest(t *testing.T) { small := strings.Repeat("x", FileReferenceThreshold) big := strings.Repeat("x", FileReferenceThreshold+1) for name, f := range map[string]File{ @@ -126,7 +126,7 @@ func TestWhichFilesTravelByDigest(t *testing.T) { } } -func TestTheSharedNames(t *testing.T) { +func testTheSharedNames(t *testing.T) { if FileBucketPrefix != "declaration-files-" || FileReferencesCapability != "file-references" || FileReferenceThreshold != 65536 { t.Fatal("a name changed") } @@ -136,7 +136,7 @@ func TestTheSharedNames(t *testing.T) { } // 1694/AC6: each node has a bucket of its own, so a grant names it and never needs a `*` (ADR 0486 §2). -func TestEachNodeHasABucketOfItsOwn(t *testing.T) { +func testEachNodeHasABucketOfItsOwn(t *testing.T) { for _, n := range []string{"ace", "g14", "novox", "shanks"} { b, err := FileBucketFor(n) if err != nil || b != "declaration-files-"+n { @@ -156,3 +156,13 @@ func TestEachNodeHasABucketOfItsOwn(t *testing.T) { } } } + +// TestFilereference holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestFilereference(t *testing.T) { + t.Run("AFileReferenceRoundTripsAndABadOneNamesItsField", testAFileReferenceRoundTripsAndABadOneNamesItsField) + t.Run("AnInlineFileIsReadAndWrittenAsBefore", testAnInlineFileIsReadAndWrittenAsBefore) + t.Run("ABigFileBecomesAReferenceAndAChangedByteFailsTheCheck", testABigFileBecomesAReferenceAndAChangedByteFailsTheCheck) + t.Run("WhichFilesTravelByDigest", testWhichFilesTravelByDigest) + t.Run("TheSharedNames", testTheSharedNames) + t.Run("EachNodeHasABucketOfItsOwn", testEachNodeHasABucketOfItsOwn) +} diff --git a/go/declaration/groups_test.go b/go/declaration/groups_test.go index 193d8f5..c648223 100644 --- a/go/declaration/groups_test.go +++ b/go/declaration/groups_test.go @@ -9,7 +9,7 @@ import ( // A container may be given supplementary groups (novox/hq ADR 0480 clause 7, issue 1636): numbers, each above zero. // 1636/AC1: a declaration with groups decodes, is written back with them, and a declaration without them has none. -func TestAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) { +func testAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"n8n.server","type":"container","name":"n8n","image":"` + anImage + `","groups":[2000,2001]}, {"id":"n8n.plain","type":"container","name":"plain","image":"` + anImage + `"}]}`)) @@ -34,7 +34,7 @@ func TestAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) { } // 1636/AC2: zero, a number below it, one the kernel keeps for "no group" and a number twice are refused, naming why. -func TestAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) { +func testAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) { for want, groups := range map[string]string{ "never root": `[0]`, "below zero": `[-5]`, @@ -53,3 +53,9 @@ func TestAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) { t.Error("a group named by text was accepted") } } + +// TestGroups holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestGroups(t *testing.T) { + t.Run("AContainerWithGroupsDecodesAndIsWrittenBack", testAContainerWithGroupsDecodesAndIsWrittenBack) + t.Run("AContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated", testAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated) +} diff --git a/go/declaration/identity_test.go b/go/declaration/identity_test.go index e97225f..ca4351c 100644 --- a/go/declaration/identity_test.go +++ b/go/declaration/identity_test.go @@ -8,7 +8,7 @@ import ( const anImage = "docker.io/library/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // The fields ADR 0325 adds, read as the controller sends them (novox/hq ADR 0325 §2, §3, §5, issue 567). -func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) { +func testIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) { d, err := Parse([]byte(`{"declaration":1, "identities":[{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}], "resources":[ @@ -35,7 +35,7 @@ func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) { // What the engine refuses on arrival: a root number, an identity not named for its module, a field it does not // know, a user that writes a read access, a variable set twice. -func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) { +func testWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) { for want, raw := range map[string]string{ "never root": `"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]`, "named mesh-": `"identities":[{"module":"m","name":"m","uid":61001,"gid":61001}],` + @@ -59,3 +59,9 @@ func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) { } } } + +// TestIdentity holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestIdentity(t *testing.T) { + t.Run("IdentitiesUsersAndARunAsAreReadAsSent", testIdentitiesUsersAndARunAsAreReadAsSent) + t.Run("WhatADeclarationMayNotSayOfAnIdentity", testWhatADeclarationMayNotSayOfAnIdentity) +} diff --git a/go/declaration/module_test.go b/go/declaration/module_test.go index cdd4b02..20047f6 100644 --- a/go/declaration/module_test.go +++ b/go/declaration/module_test.go @@ -4,7 +4,7 @@ import "testing" // 1132/AC1: a resource says its module, and the declaration says each module's build; a resource that does // not say gets today's guess, so an older controller still works. -func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) { +func testAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) { d, err := Parse([]byte(`{"declaration":1, "builds":{"novox.be":"b-9","web":"b-4"}, "resources":[ @@ -30,7 +30,7 @@ func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) { } // 1132/AC1: the module the declaration gives wins over the guess, even when the id would guess another. -func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) { +func testTheDeclaredModuleWinsOverTheGuess(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"a.b.c","type":"directory","path":"/srv/a","module":"a.b"}, {"id":"x.y","type":"directory","path":"/srv/x","module":"other"}]}`)) @@ -43,7 +43,7 @@ func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) { } // 1132/AC1: every kind accepts the field. -func TestEveryKindAcceptsAModule(t *testing.T) { +func testEveryKindAcceptsAModule(t *testing.T) { for _, typ := range []Type{TypeDirectory, TypeFile, TypeUser, TypeNetwork, TypeAccess, TypeArchive, TypeProcess, TypeService, TypeOpening, TypePackage, TypeContainer, TypeAction} { r := newOf(typ) @@ -58,3 +58,10 @@ func TestEveryKindAcceptsAModule(t *testing.T) { } } } + +// TestModule holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestModule(t *testing.T) { + t.Run("AResourceSaysItsModuleAndTheDeclarationItsBuilds", testAResourceSaysItsModuleAndTheDeclarationItsBuilds) + t.Run("TheDeclaredModuleWinsOverTheGuess", testTheDeclaredModuleWinsOverTheGuess) + t.Run("EveryKindAcceptsAModule", testEveryKindAcceptsAModule) +} diff --git a/go/declaration/order_test.go b/go/declaration/order_test.go index 4d2c93d..56cf9e4 100644 --- a/go/declaration/order_test.go +++ b/go/declaration/order_test.go @@ -12,7 +12,7 @@ func withOrder(order string) string { return `{"declaration":1,` + order + `"resources":[{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"}]}` } -func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) { +func testADeclarationCarriesItsEpochAndSequence(t *testing.T) { d, err := Parse([]byte(withOrder(`"epoch":57,"sequence":3,`))) if err != nil { t.Fatalf("a declaration carrying its order was refused: %v", err) @@ -27,7 +27,7 @@ func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) { } } -func TestAnOrderBelowZeroIsRefused(t *testing.T) { +func testAnOrderBelowZeroIsRefused(t *testing.T) { for _, order := range []string{`"epoch":-1,`, `"sequence":-4,`} { refusal := refusalFor(t, withOrder(order)) if !strings.Contains(refusal.Error(), "below zero") { @@ -38,7 +38,7 @@ func TestAnOrderBelowZeroIsRefused(t *testing.T) { // **The assignment generation it was composed from, and whether it is a put-back** (novox/hq issue 234): // absent is no claim; below zero is none the mesh assigns; a carried bundle puts nothing back. -func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) { +func testADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) { d, err := Parse([]byte(withOrder(`"sequence":3,"generation":41,"put_back":true,`))) if err != nil { t.Fatalf("a declaration carrying its generation was refused: %v", err) @@ -57,3 +57,10 @@ func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) { t.Errorf("a carried bundle said it was a put-back and was not refused for it: %v", err) } } + +// TestOrder holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestOrder(t *testing.T) { + t.Run("ADeclarationCarriesItsEpochAndSequence", testADeclarationCarriesItsEpochAndSequence) + t.Run("AnOrderBelowZeroIsRefused", testAnOrderBelowZeroIsRefused) + t.Run("ADeclarationCarriesItsGenerationAndWhetherItIsAPutBack", testADeclarationCarriesItsGenerationAndWhetherItIsAPutBack) +} diff --git a/go/declaration/ownership_test.go b/go/declaration/ownership_test.go index 76b0674..c8ad64c 100644 --- a/go/declaration/ownership_test.go +++ b/go/declaration/ownership_test.go @@ -9,7 +9,7 @@ import ( // — owned, seed or managed — and the declaration names the files the provisioner fills. The words are // read and judged here before any controller sends them. -func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) { +func testAFileSaysWhoOwnsItAtRuntime(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"o","type":"file","path":"/etc/o","content":"a","owner-at-runtime":"owned"}, {"id":"s","type":"file","path":"/etc/s","content":"a","owner-at-runtime":"seed"}, @@ -31,7 +31,7 @@ func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) { } // A file that does not say is owned by its old form, so the word means exactly what that form does. -func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) { +func testAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"o","type":"file","path":"/etc/o","content":"a"}, {"id":"s","type":"file","path":"/etc/s","content":"a","create-once":true}, @@ -53,7 +53,7 @@ func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) { } } -func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) { +func testAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) { for name, c := range map[string]struct{ resource, refusal string }{ "another word": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"mine"}`, `owner-at-runtime "mine"`}, @@ -78,7 +78,7 @@ func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) { } } -func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) { +func testTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"filled_files":["/etc/a","/etc/b"],"resources":[ {"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}, {"id":"b","type":"file","path":"/etc/b","into":"block","content":"b\n","owner-at-runtime":"managed"}, @@ -104,7 +104,7 @@ func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) { } } -func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) { +func testAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) { for name, c := range map[string]struct{ filled, resources, refusal string }{ "not declared": {`["/etc/none"]`, `{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`, @@ -128,3 +128,12 @@ func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) { } } } + +// TestOwnership holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestOwnership(t *testing.T) { + t.Run("AFileSaysWhoOwnsItAtRuntime", testAFileSaysWhoOwnsItAtRuntime) + t.Run("AFileThatDoesNotSayIsOwnedByItsOldForm", testAFileThatDoesNotSayIsOwnedByItsOldForm) + t.Run("AnOwnershipThatContradictsTheFileIsRefused", testAnOwnershipThatContradictsTheFileIsRefused) + t.Run("TheDeclarationNamesTheFilesTheProvisionerFills", testTheDeclarationNamesTheFilesTheProvisionerFills) + t.Run("AFilledFileMustBeADeclaredFileThatSaysItsOwner", testAFilledFileMustBeADeclaredFileThatSaysItsOwner) +} diff --git a/go/declaration/process_test.go b/go/declaration/process_test.go index 02eaff1..753771c 100644 --- a/go/declaration/process_test.go +++ b/go/declaration/process_test.go @@ -15,7 +15,7 @@ func aProcess() *Process { } // A process is part of the vocabulary, or a declaration carrying one is refused whole. -func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) { +func testAProcessIsSomethingTheHostSpeaks(t *testing.T) { var found bool for _, kind := range Vocabulary() { if kind == TypeProcess { @@ -33,7 +33,7 @@ func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) { // **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a // reference somebody can repoint is not pinned, and it is the one thing on a machine that would // then be running code nobody reviewed. -func TestAProcesssBundleMustBePinned(t *testing.T) { +func testAProcesssBundleMustBePinned(t *testing.T) { for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { d := aProcess() d.Digest = bad @@ -45,7 +45,7 @@ func TestAProcesssBundleMustBePinned(t *testing.T) { // What to run is named, never inferred. Guessing an entrypoint from which files are present makes // a process change what it runs when somebody adds a file. -func TestAProcessMustSayWhatToRun(t *testing.T) { +func testAProcessMustSayWhatToRun(t *testing.T) { d := aProcess() d.Run = nil if problems := d.validate("a process", false); len(problems) == 0 { @@ -54,7 +54,7 @@ func TestAProcessMustSayWhatToRun(t *testing.T) { } // Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. -func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) { +func testAProcesssNameCannotEscapeItsUnit(t *testing.T) { // "." and ".." are one path element each, and the mesh's own bundle directory and its parent: // removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR // 0118). A leading dash is an option to the service manager, not a unit. @@ -68,7 +68,7 @@ func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) { } // And a well-formed one is accepted, or the tests above prove only that everything is refused. -func TestAWellFormedDaemonIsAccepted(t *testing.T) { +func testAWellFormedDaemonIsAccepted(t *testing.T) { if problems := aProcess().validate("a process", false); len(problems) != 0 { t.Fatalf("a well-formed daemon was refused: %v", problems) } @@ -78,7 +78,7 @@ func TestAWellFormedDaemonIsAccepted(t *testing.T) { // once does not run on a schedule; something not running between fires cannot be restarted when a // file changes. A container's modes carry the same rule, and this is the same rule because it is // the same thing hosted differently. -func TestTheModesAreExclusive(t *testing.T) { +func testTheModesAreExclusive(t *testing.T) { both := aProcess() both.RunOnce = true both.Schedule = "0 3 * * *" @@ -110,7 +110,7 @@ func TestTheModesAreExclusive(t *testing.T) { // A cadence that is not a cadence is refused near its author, rather than by a machine at the far // end of a declaration. -func TestAScheduleMustBeACadence(t *testing.T) { +func testAScheduleMustBeACadence(t *testing.T) { for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} { p := aProcess() p.Schedule = bad @@ -121,7 +121,7 @@ func TestAScheduleMustBeACadence(t *testing.T) { } // And each mode on its own is accepted, or the tests above prove only that everything is refused. -func TestEachModeOnItsOwnIsAccepted(t *testing.T) { +func testEachModeOnItsOwnIsAccepted(t *testing.T) { once := aProcess() once.RunOnce = true if problems := once.validate("a process", false); len(problems) != 0 { @@ -148,7 +148,7 @@ func TestEachModeOnItsOwnIsAccepted(t *testing.T) { // // So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of // a declaration. -func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) { +func testAnEnvironmentValueCannotCarryALineBreak(t *testing.T) { for _, bad := range []string{ "safe\nExecStart=/usr/bin/whatever", "carriage\rreturn", @@ -173,10 +173,24 @@ func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) { // And ordinary awkward values are accepted, because escaping is what handles those — refusing them // too would make a module unable to hold a generated password. -func TestOrdinaryAwkwardValuesAreAccepted(t *testing.T) { +func testOrdinaryAwkwardValuesAreAccepted(t *testing.T) { p := aProcess() p.Env = map[string]string{"PASSWORD": `a%H b"c\d`} if problems := p.validate("a process", false); len(problems) != 0 { t.Fatalf("a password containing the characters passwords contain was refused: %v", problems) } } + +// TestProcess holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestProcess(t *testing.T) { + t.Run("AProcessIsSomethingTheHostSpeaks", testAProcessIsSomethingTheHostSpeaks) + t.Run("AProcesssBundleMustBePinned", testAProcesssBundleMustBePinned) + t.Run("AProcessMustSayWhatToRun", testAProcessMustSayWhatToRun) + t.Run("AProcesssNameCannotEscapeItsUnit", testAProcesssNameCannotEscapeItsUnit) + t.Run("AWellFormedDaemonIsAccepted", testAWellFormedDaemonIsAccepted) + t.Run("TheModesAreExclusive", testTheModesAreExclusive) + t.Run("AScheduleMustBeACadence", testAScheduleMustBeACadence) + t.Run("EachModeOnItsOwnIsAccepted", testEachModeOnItsOwnIsAccepted) + t.Run("AnEnvironmentValueCannotCarryALineBreak", testAnEnvironmentValueCannotCarryALineBreak) + t.Run("OrdinaryAwkwardValuesAreAccepted", testOrdinaryAwkwardValuesAreAccepted) +} diff --git a/go/declaration/provisioner_test.go b/go/declaration/provisioner_test.go index 233aa40..91dc265 100644 --- a/go/declaration/provisioner_test.go +++ b/go/declaration/provisioner_test.go @@ -8,7 +8,7 @@ import ( // Defends novox/hq ADR 0349 §4 (issue 668): the declaration names the account the provisioner's client runs as, // the only one whose write of a filled file the node-engine accepts. Read here before any controller sends it. -func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) { +func testTheDeclarationNamesTheProvisionersAccount(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":["/etc/a"], "resources":[{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}]}`)) if err != nil { @@ -26,7 +26,7 @@ func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) { } } -func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) { +func testAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) { for name, account := range map[string]string{"root": "root", "not a name": "a b", "a path": "../x"} { _, err := Parse([]byte(`{"declaration":1,"provisioner_account":"` + account + `", "resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`)) @@ -35,3 +35,9 @@ func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) { } } } + +// TestProvisioner holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestProvisioner(t *testing.T) { + t.Run("TheDeclarationNamesTheProvisionersAccount", testTheDeclarationNamesTheProvisionersAccount) + t.Run("AProvisionersAccountThatIsNoAccountIsRefused", testAProvisionersAccountThatIsNoAccountIsRefused) +} diff --git a/go/declaration/replaces_test.go b/go/declaration/replaces_test.go index c470505..017ea11 100644 --- a/go/declaration/replaces_test.go +++ b/go/declaration/replaces_test.go @@ -15,7 +15,7 @@ func replacing(extra string) string { return `{"declaration":1,"resources":[` + strings.Replace(aReplacingProcess, "%s", extra, 1) + `]}` } -func TestAProcessMayNameWhatItReplaces(t *testing.T) { +func testAProcessMayNameWhatItReplaces(t *testing.T) { d, err := Parse([]byte(replacing(`,"replaces":["m.server"]`))) if err != nil { t.Fatalf("a process replacing an undeclared container was refused: %v", err) @@ -27,7 +27,7 @@ func TestAProcessMayNameWhatItReplaces(t *testing.T) { // What is replaced is what the declaration no longer says. A resource still declared is applied, // and handing it over as well would remove something the same declaration asks to keep. -func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) { +func testAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) { raw := `{"declaration":1,"resources":[ {"id":"m.server","type":"directory","path":"/tmp/x"}, ` + strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + `]}` @@ -36,7 +36,7 @@ func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) { } } -func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) { +func testOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) { for _, mode := range []string{`,"run-once":true`, `,"schedule":"0 3 * * *"`} { if _, err := Parse([]byte(replacing(mode + `,"replaces":["m.server"]`))); err == nil { t.Errorf("a process with %s was allowed to replace something", mode) @@ -44,7 +44,7 @@ func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) { } } -func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) { +func testAProcessCannotReplaceItselfOrNothing(t *testing.T) { for _, bad := range []string{`,"replaces":["m.controller"]`, `,"replaces":[""]`} { if _, err := Parse([]byte(replacing(bad))); err == nil { t.Errorf("replaces %s was accepted", bad) @@ -52,7 +52,7 @@ func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) { } } -func TestOneThingHasOneReplacement(t *testing.T) { +func testOneThingHasOneReplacement(t *testing.T) { second := strings.NewReplacer(`"id":"m.controller"`, `"id":"m.other"`, `"name":"m"`, `"name":"other"`). Replace(strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1)) raw := `{"declaration":1,"resources":[` + @@ -61,3 +61,12 @@ func TestOneThingHasOneReplacement(t *testing.T) { t.Fatal("two processes replacing one resource were accepted") } } + +// TestReplaces holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestReplaces(t *testing.T) { + t.Run("AProcessMayNameWhatItReplaces", testAProcessMayNameWhatItReplaces) + t.Run("AProcessMayNotReplaceSomethingStillDeclared", testAProcessMayNotReplaceSomethingStillDeclared) + t.Run("OnlyAProcessThatStaysUpReplacesAnything", testOnlyAProcessThatStaysUpReplacesAnything) + t.Run("AProcessCannotReplaceItselfOrNothing", testAProcessCannotReplaceItselfOrNothing) + t.Run("OneThingHasOneReplacement", testOneThingHasOneReplacement) +} diff --git a/go/declaration/schedule_declaration_test.go b/go/declaration/schedule_declaration_test.go index d87d1fb..5996c8f 100644 --- a/go/declaration/schedule_declaration_test.go +++ b/go/declaration/schedule_declaration_test.go @@ -11,7 +11,7 @@ import ( func schedulePinned() string { return "registry.example/runtime@sha256:" + strings.Repeat("a", 64) } -func TestAScheduledContainerCarriesTheCronField(t *testing.T) { +func testAScheduledContainerCarriesTheCronField(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"0 3 * * *"} ]}`)) @@ -27,7 +27,7 @@ func TestAScheduledContainerCarriesTheCronField(t *testing.T) { } } -func TestAMalformedScheduleIsRefused(t *testing.T) { +func testAMalformedScheduleIsRefused(t *testing.T) { _, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"every night"} ]}`)) @@ -39,7 +39,7 @@ func TestAMalformedScheduleIsRefused(t *testing.T) { } } -func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { +func testAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { // A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053). _, err := Parse([]byte(`{"declaration":1,"resources":[ {"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","run-once":true,"schedule":"0 3 * * *"} @@ -51,3 +51,10 @@ func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { t.Errorf("refused for the wrong reason: %v", err) } } + +// TestScheduleDeclaration holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestScheduleDeclaration(t *testing.T) { + t.Run("AScheduledContainerCarriesTheCronField", testAScheduledContainerCarriesTheCronField) + t.Run("AMalformedScheduleIsRefused", testAMalformedScheduleIsRefused) + t.Run("AContainerCannotBeBothRunOnceAndScheduled", testAContainerCannotBeBothRunOnceAndScheduled) +} diff --git a/go/declaration/userscope_test.go b/go/declaration/userscope_test.go index fdce635..25e46b1 100644 --- a/go/declaration/userscope_test.go +++ b/go/declaration/userscope_test.go @@ -7,7 +7,7 @@ import ( // novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names // the account, a system one may not, and any other word is refused. -func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) { +func testAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) { cases := []struct{ scope, user, wants string }{ {"user", "ops", ""}, {"", "", ""}, @@ -30,7 +30,7 @@ func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) { } // novox/hq ADR 0177: lingering is a field of the account, absent meaning nothing asserted. -func TestAnAccountMayBeDeclaredToLinger(t *testing.T) { +func testAnAccountMayBeDeclaredToLinger(t *testing.T) { d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops","linger":true}]}`)) if err != nil { t.Fatal(err) @@ -47,3 +47,9 @@ func TestAnAccountMayBeDeclaredToLinger(t *testing.T) { t.Fatal("an account that said nothing about lingering asserts it") } } + +// TestUserscope holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestUserscope(t *testing.T) { + t.Run("AUserScopedUnitNamesItsAccountAndASystemOneMayNot", testAUserScopedUnitNamesItsAccountAndASystemOneMayNot) + t.Run("AnAccountMayBeDeclaredToLinger", testAnAccountMayBeDeclaredToLinger) +} diff --git a/go/provider/harness/grants_moved_test.go b/go/provider/harness/grants_moved_test.go index 920bb7a..cf43517 100644 --- a/go/provider/harness/grants_moved_test.go +++ b/go/provider/harness/grants_moved_test.go @@ -46,7 +46,7 @@ func (w *movedWorld) settled() { // The logins the grant file gives are asked for: made as the grant path makes them, never offered for retirement, // beside a moved consumer the helper makes from its plan. -func TestAConsumerOnlyTheGrantFileGivesIsAskedForAndMade(t *testing.T) { +func testAConsumerOnlyTheGrantFileGivesIsAskedForAndMade(t *testing.T) { bound, logins := bindings(1) w := newMovedWorld(t, bound) w.a.inv.Active = []string{"mesh_ace_baserow", "mesh_ace_letta", logins[0]} @@ -78,7 +78,7 @@ func TestAConsumerOnlyTheGrantFileGivesIsAskedForAndMade(t *testing.T) { } // A login both a plan and the grant file name is the plan's: made once, with the plan's credential. -func TestALoginAPlanNamesIsNotMadeFromTheGrantFile(t *testing.T) { +func testALoginAPlanNamesIsNotMadeFromTheGrantFile(t *testing.T) { bound, logins := bindings(1) w := newMovedWorld(t, bound) w.bus.bind("ace", "app1", nil) @@ -93,7 +93,7 @@ func TestALoginAPlanNamesIsNotMadeFromTheGrantFile(t *testing.T) { } // An unreadable grant file is not nobody asking (issue 241): nothing is retired while it cannot be read. -func TestAnUnreadableGrantFileRetiresNothing(t *testing.T) { +func testAnUnreadableGrantFileRetiresNothing(t *testing.T) { bound, logins := bindings(1) w := newMovedWorld(t, bound) w.a.inv.Active = []string{"mesh_ace_baserow", logins[0]} @@ -107,7 +107,7 @@ func TestAnUnreadableGrantFileRetiresNothing(t *testing.T) { // A login the grant file stops giving is not retired by the provider itself (novox/hq ADR 0570 clause 5): every // retirement waits for the operator's yes on a provision's key. -func TestALoginTheGrantFileStopsGivingIsRetired(t *testing.T) { +func testALoginTheGrantFileStopsGivingIsRetired(t *testing.T) { bound, _ := bindings(1) w := newMovedWorld(t, bound) w.bus.bind("ace", "app1", nil) @@ -122,3 +122,11 @@ func TestALoginTheGrantFileStopsGivingIsRetired(t *testing.T) { t.Fatalf("retired %v, deleted %v, by itself", w.a.removed, w.a.deleted) } } + +// TestGrantsMoved holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestGrantsMoved(t *testing.T) { + t.Run("AConsumerOnlyTheGrantFileGivesIsAskedForAndMade", testAConsumerOnlyTheGrantFileGivesIsAskedForAndMade) + t.Run("ALoginAPlanNamesIsNotMadeFromTheGrantFile", testALoginAPlanNamesIsNotMadeFromTheGrantFile) + t.Run("AnUnreadableGrantFileRetiresNothing", testAnUnreadableGrantFileRetiresNothing) + t.Run("ALoginTheGrantFileStopsGivingIsRetired", testALoginTheGrantFileStopsGivingIsRetired) +} diff --git a/go/provider/harness/harness_test.go b/go/provider/harness/harness_test.go index d90c7b4..2e84e6d 100644 --- a/go/provider/harness/harness_test.go +++ b/go/provider/harness/harness_test.go @@ -45,7 +45,7 @@ func (w *world) give(given ...map[string]any) { } } -func TestAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword(t *testing.T) { +func testAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword(t *testing.T) { w := newWorld(t) w.give(map[string]any{"as": "mesh_ace_letta", "node": "ace", "values": map[string]any{"name": "letta"}}) w.h.Reconcile(ctx) @@ -59,7 +59,7 @@ func TestAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword(t *testing.T) { } } -func TestAConsumerNoLongerAskedForIsRetiredOnceStable(t *testing.T) { +func testAConsumerNoLongerAskedForIsRetiredOnceStable(t *testing.T) { w := newWorld(t) w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"}) w.h.Reconcile(ctx) @@ -76,7 +76,7 @@ func TestAConsumerNoLongerAskedForIsRetiredOnceStable(t *testing.T) { } } -func TestNothingReadIsNotNobodyAsking(t *testing.T) { +func testNothingReadIsNotNobodyAsking(t *testing.T) { for name, content := range map[string]string{ "unreadable": "", "not JSON": "{", @@ -100,7 +100,7 @@ func TestNothingReadIsNotNobodyAsking(t *testing.T) { } } -func TestALostConsumerIsAppliedAgainAndBraked(t *testing.T) { +func testALostConsumerIsAppliedAgainAndBraked(t *testing.T) { w := newWorld(t) w.give(map[string]any{"as": "a"}) w.h.Reconcile(ctx) @@ -120,7 +120,7 @@ func TestALostConsumerIsAppliedAgainAndBraked(t *testing.T) { } } -func TestAFailingCreateIsRetriedAndSaidOnce(t *testing.T) { +func testAFailingCreateIsRetriedAndSaidOnce(t *testing.T) { w := newWorld(t) w.a.failing = &pgErr{"extension \"nope\" refused, password pw-a"} w.give(map[string]any{"as": "a"}) @@ -147,8 +147,18 @@ type pgErr struct{ s string } func (e *pgErr) Error() string { return e.s } -func TestScrubRemovesThePassword(t *testing.T) { +func testScrubRemovesThePassword(t *testing.T) { if got := scrub(&pgErr{"bad pw a/b c and a%2Fb+c"}, "a/b c"); strings.Contains(got, "a/b c") || strings.Contains(got, "a%2Fb+c") { t.Fatal(got) } } + +// TestHarness holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestHarness(t *testing.T) { + t.Run("AConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword", testAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword) + t.Run("AConsumerNoLongerAskedForIsRetiredOnceStable", testAConsumerNoLongerAskedForIsRetiredOnceStable) + t.Run("NothingReadIsNotNobodyAsking", testNothingReadIsNotNobodyAsking) + t.Run("ALostConsumerIsAppliedAgainAndBraked", testALostConsumerIsAppliedAgainAndBraked) + t.Run("AFailingCreateIsRetriedAndSaidOnce", testAFailingCreateIsRetriedAndSaidOnce) + t.Run("ScrubRemovesThePassword", testScrubRemovesThePassword) +} diff --git a/go/provider/harness/moved_test.go b/go/provider/harness/moved_test.go index bce61ff..92ae2b1 100644 --- a/go/provider/harness/moved_test.go +++ b/go/provider/harness/moved_test.go @@ -188,7 +188,7 @@ func bindings(n int) (map[[2]string]string, []string) { } // The consumers' file makes the same calls on the adapter as the equivalent grant files do (the harness). -func TestTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) { +func testTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) { letta := provider.ConsumerIdentity("ace", "letta") plain := provider.ConsumerIdentity("ace", "plain") @@ -242,7 +242,7 @@ func TestTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) { // A consumer whose plan goes retires nothing by itself, however long (novox/hq ADR 0570 clause 5): its login is retired, // never dropped, and its entry removed only once its provision is retiring with the operator's yes. -func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { +func testAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { bound, logins := bindings(2) w := newMovedWorld(t, bound) w.bus.bind("ace", "app1", nil) @@ -271,7 +271,7 @@ func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { } // A plan list that was never read is not nobody asking. -func TestNoPlansReadRetiresNothing(t *testing.T) { +func testNoPlansReadRetiresNothing(t *testing.T) { bound, _ := bindings(1) w := newMovedWorld(t, bound) w.bus.bind("ace", "app1", nil) @@ -288,7 +288,7 @@ func TestNoPlansReadRetiresNothing(t *testing.T) { } // A consumer whose credential is not in the file yet is waited for: nothing is made for it. -func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { +func testAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { w := newMovedWorld(t, map[[2]string]string{}) w.bus.bind("ace", "letta", map[string]any{"name": "letta"}) outcomes := w.pass() @@ -301,7 +301,7 @@ func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { // The grant path restarts itself when the consumers file is first filled. // A consumer whose module has a slug keeps the login its plan names, the slug first (issue 788): the adapter makes it // under that login, and the retirement counts it as asked for, so a pass retires nothing. -func TestASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) { +func testASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) { agent := provider.ConsumerIdentity("ace", "agent") w := newMovedWorld(t, map[[2]string]string{{"ace", "claude-code"}: "pw-agent"}) w.bus.putPlan("ace", "claude-code", map[string]any{"provision": ProvisionName, "provider": "novox", @@ -328,7 +328,7 @@ func TestASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) { // A provider whose spec names the plan's `as` as the login and no grant file (the keycloak shape) makes the consumer // under that name with the values its spec composes, and a grant file that cannot be read does not hold up its // retirement pass. -func TestASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile(t *testing.T) { +func testASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile(t *testing.T) { a := &recorder{} bus := &bucketBus{buckets: map[string]map[string][]byte{}} now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) @@ -391,7 +391,7 @@ var keycloakSpec = MovedSpec{Module: "keycloak", Provision: ProvisionName, // The consumers' file makes the same call on the adapter as the equivalent grant file does (the harness), for a // provider of the keycloak shape: the same client id, secret and values; and the entry tells the consumer what the // provider serves. -func TestTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape(t *testing.T) { +func testTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape(t *testing.T) { grafana := provider.ConsumerIdentity("home", "grafana") values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]} for k, v := range grafanaContributes { @@ -439,7 +439,7 @@ func TestTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape(t *testin } // A consumer whose plan names its client id (a slugged module) keeps it, and the retirement counts it as asked for. -func TestAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) { +func testAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) { agent := provider.ConsumerIdentity("home", "dashboard") w := newMovedWorldOf(t, map[[2]string]string{{"home", "grafana-2"}: "pw"}, keycloakSpec) w.bus.putPlan("home", "grafana-2", map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak", @@ -462,3 +462,15 @@ func TestAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) { t.Fatalf("retired %v: the plan's client id was counted unasked", w.a.removed) } } + +// TestMoved holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestMoved(t *testing.T) { + t.Run("TheConsumersFileMakesWhatTheGrantFilesMake", testTheConsumersFileMakesWhatTheGrantFilesMake) + t.Run("AConsumerMissingFromOneReadRetiresNothing", testAConsumerMissingFromOneReadRetiresNothing) + t.Run("NoPlansReadRetiresNothing", testNoPlansReadRetiresNothing) + t.Run("AConsumerWithoutACredentialIsWaitedFor", testAConsumerWithoutACredentialIsWaitedFor) + t.Run("ASluggedConsumerKeepsTheLoginItsPlanNames", testASluggedConsumerKeepsTheLoginItsPlanNames) + t.Run("ASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile", testASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile) + t.Run("TheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape", testTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape) + t.Run("AConsumerNamedByItsPlanKeepsItsClientId", testAConsumerNamedByItsPlanKeepsItsClientId) +} diff --git a/go/provider/harness/retirement_test.go b/go/provider/harness/retirement_test.go index 5b38920..4fd29e2 100644 --- a/go/provider/harness/retirement_test.go +++ b/go/provider/harness/retirement_test.go @@ -148,7 +148,7 @@ func namesOf(body map[string]any) string { // settle passes every five seconds until the same answer has held for StableFor, and one pass more. func (w *world) settle() { w.passes(StableFor + 5*time.Second) } -func TestATransientEmptyListForFourPassesRetiresNothing(t *testing.T) { +func testATransientEmptyListForFourPassesRetiresNothing(t *testing.T) { w, said := standingWorld(t) given := holding(w, 1) w.give() @@ -164,7 +164,7 @@ func TestATransientEmptyListForFourPassesRetiresNothing(t *testing.T) { // Five identical passes are twenty-five seconds — shorter than a controller restart. Both must hold: // five passes AND ten minutes of the same answer (novox/hq ADR 0230). -func TestFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo(t *testing.T) { +func testFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo(t *testing.T) { w, said := standingWorld(t) holding(w, 1) w.give() @@ -198,7 +198,7 @@ func TestFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo(t *testing.T) { } } -func TestAStableSetIsRetiredAndAskedAgainReEnables(t *testing.T) { +func testAStableSetIsRetiredAndAskedAgainReEnables(t *testing.T) { w, said := standingWorld(t) given := holding(w, 3) w.give(given[:2]...) @@ -229,7 +229,7 @@ func TestAStableSetIsRetiredAndAskedAgainReEnables(t *testing.T) { } } -func TestAnUnreadablePassStartsTheCountAgain(t *testing.T) { +func testAnUnreadablePassStartsTheCountAgain(t *testing.T) { w := newWorld(t) holding(w, 1) w.give() @@ -247,7 +247,7 @@ func TestAnUnreadablePassStartsTheCountAgain(t *testing.T) { } } -func TestADifferentSetStartsTheCountAgain(t *testing.T) { +func testADifferentSetStartsTheCountAgain(t *testing.T) { w := newWorld(t) given := holding(w, 5) w.give(given[:4]...) @@ -263,7 +263,7 @@ func TestADifferentSetStartsTheCountAgain(t *testing.T) { } } -func TestAdditionsAndChangesAreNeverDelayed(t *testing.T) { +func testAdditionsAndChangesAreNeverDelayed(t *testing.T) { w := newWorld(t) holding(w, 1) w.give(map[string]any{"as": "c1", "node": "anchor"}, map[string]any{"as": "c2"}) @@ -278,7 +278,7 @@ func TestAdditionsAndChangesAreNeverDelayed(t *testing.T) { } } -func TestTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet(t *testing.T) { +func testTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet(t *testing.T) { w, said := standingWorld(t) holding(w, 4) w.give() @@ -327,7 +327,7 @@ func TestTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet(t *testing.T) { } } -func TestRejectedIsKeptAndNotAskedAgainUntilTheSetChanges(t *testing.T) { +func testRejectedIsKeptAndNotAskedAgainUntilTheSetChanges(t *testing.T) { w, said := standingWorld(t) given := holding(w, 4) w.give() @@ -372,7 +372,7 @@ func TestRejectedIsKeptAndNotAskedAgainUntilTheSetChanges(t *testing.T) { } } -func TestAWaitingSetAskedForAgainSettles(t *testing.T) { +func testAWaitingSetAskedForAgainSettles(t *testing.T) { w, said := standingWorld(t) given := holding(w, 4) w.give() @@ -387,7 +387,7 @@ func TestAWaitingSetAskedForAgainSettles(t *testing.T) { } } -func TestDeleteRemovesOnlyThatRetiredConsumer(t *testing.T) { +func testDeleteRemovesOnlyThatRetiredConsumer(t *testing.T) { w, said := standingWorld(t) given := holding(w, 5) w.give(given[:3]...) @@ -423,7 +423,7 @@ func TestDeleteRemovesOnlyThatRetiredConsumer(t *testing.T) { } } -func TestTheBound(t *testing.T) { +func testTheBound(t *testing.T) { h := &Harness{} h.init() for _, c := range []struct{ n, held int }{{1, 1}, {1, 2}, {1, 3}, {3, 7}, {3, 6}, {2, 5}} { @@ -438,7 +438,7 @@ func TestTheBound(t *testing.T) { } } -func TestARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled(t *testing.T) { +func testARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled(t *testing.T) { w, said := standingWorld(t) w.a.inv = Inventory{Active: []string{"kept", "orphan"}, Retired: []Retired{ {Consumer: "locked-before", SizeBytes: 7, Kind: KindConsumer}, @@ -458,7 +458,7 @@ func TestARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled(t } } -func TestABackendThatCannotBeListedIsSaidAndAskedAgain(t *testing.T) { +func testABackendThatCannotBeListedIsSaidAndAskedAgain(t *testing.T) { w := newWorld(t) w.a.invErr = errors.New("connection refused") holding(w, 1) @@ -474,7 +474,7 @@ func TestABackendThatCannotBeListedIsSaidAndAskedAgain(t *testing.T) { } } -func TestTheToolsAnswer(t *testing.T) { +func testTheToolsAnswer(t *testing.T) { w, _ := standingWorld(t) holding(w, 4) w.give() @@ -512,7 +512,7 @@ func TestTheToolsAnswer(t *testing.T) { } } -func TestAFailingConsumerRetiredIsSaidRecovered(t *testing.T) { +func testAFailingConsumerRetiredIsSaidRecovered(t *testing.T) { w, said := standingWorld(t) given := holding(w, 2) w.a.held = false @@ -542,7 +542,7 @@ func everyWord(t *testing.T) (*world, *[]announced) { // A restarted provider that finds every consumer it holds asked for says once that nothing waits, so the controller // clears a waiting condition an earlier process raised and this one never knew (issue 937); it says it no more. -func TestARestartedProviderSaysOnceThatNothingWaits(t *testing.T) { +func testARestartedProviderSaysOnceThatNothingWaits(t *testing.T) { w, said := everyWord(t) holding(w, 2) w.pass() @@ -559,7 +559,7 @@ func TestARestartedProviderSaysOnceThatNothingWaits(t *testing.T) { } // A provider whose own set waits does not say that nothing waits. -func TestAProviderThatWaitsDoesNotSayNothingWaits(t *testing.T) { +func testAProviderThatWaitsDoesNotSayNothingWaits(t *testing.T) { w, said := everyWord(t) w.a.inv.Active = []string{"c1", "c2", "c3", "c4", "c5"} w.give(map[string]any{"as": "c1", "node": "anchor"}) @@ -573,3 +573,24 @@ func TestAProviderThatWaitsDoesNotSayNothingWaits(t *testing.T) { t.Fatal("nothing waited: the test does not hold the case") } } + +// TestRetirement holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestRetirement(t *testing.T) { + t.Run("ATransientEmptyListForFourPassesRetiresNothing", testATransientEmptyListForFourPassesRetiresNothing) + t.Run("FivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo", testFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo) + t.Run("AStableSetIsRetiredAndAskedAgainReEnables", testAStableSetIsRetiredAndAskedAgainReEnables) + t.Run("AnUnreadablePassStartsTheCountAgain", testAnUnreadablePassStartsTheCountAgain) + t.Run("ADifferentSetStartsTheCountAgain", testADifferentSetStartsTheCountAgain) + t.Run("AdditionsAndChangesAreNeverDelayed", testAdditionsAndChangesAreNeverDelayed) + t.Run("TooManyWaitsForAPersonAndApproveRetiresExactlyThatSet", testTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet) + t.Run("RejectedIsKeptAndNotAskedAgainUntilTheSetChanges", testRejectedIsKeptAndNotAskedAgainUntilTheSetChanges) + t.Run("AWaitingSetAskedForAgainSettles", testAWaitingSetAskedForAgainSettles) + t.Run("DeleteRemovesOnlyThatRetiredConsumer", testDeleteRemovesOnlyThatRetiredConsumer) + t.Run("TheBound", testTheBound) + t.Run("ARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled", testARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled) + t.Run("ABackendThatCannotBeListedIsSaidAndAskedAgain", testABackendThatCannotBeListedIsSaidAndAskedAgain) + t.Run("TheToolsAnswer", testTheToolsAnswer) + t.Run("AFailingConsumerRetiredIsSaidRecovered", testAFailingConsumerRetiredIsSaidRecovered) + t.Run("ARestartedProviderSaysOnceThatNothingWaits", testARestartedProviderSaysOnceThatNothingWaits) + t.Run("AProviderThatWaitsDoesNotSayNothingWaits", testAProviderThatWaitsDoesNotSayNothingWaits) +} diff --git a/go/provider/harness/standing_test.go b/go/provider/harness/standing_test.go index 106a9fb..44bd548 100644 --- a/go/provider/harness/standing_test.go +++ b/go/provider/harness/standing_test.go @@ -47,7 +47,7 @@ func events(said []announced) string { return strings.Join(out, ",") } -func TestAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass(t *testing.T) { +func testAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass(t *testing.T) { w, said := standingWorld(t) w.a.failing = errors.New(`token request failed: 401 {"error":"invalid_grant","error_description":"Invalid user credentials"}`) w.give(map[string]any{"as": "mesh_home_grafana", "node": "home-server"}) @@ -87,7 +87,7 @@ func TestAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass(t *testing.T) { } } -func TestOneSuccessBetweenFailuresStartsTheRunAgain(t *testing.T) { +func testOneSuccessBetweenFailuresStartsTheRunAgain(t *testing.T) { w, said := standingWorld(t) w.a.failing = errors.New("connection refused") w.give(map[string]any{"as": "a"}) @@ -104,7 +104,7 @@ func TestOneSuccessBetweenFailuresStartsTheRunAgain(t *testing.T) { // The check that failed for a day on 2026-10-05: clients already made, every minute's check refused // at the token. A check that cannot be asked is a failure too. -func TestACheckThatKeepsFailingIsAFailureToo(t *testing.T) { +func testACheckThatKeepsFailingIsAFailureToo(t *testing.T) { w, said := standingWorld(t) w.give(map[string]any{"as": "a"}) w.h.Reconcile(ctx) @@ -120,7 +120,7 @@ func TestACheckThatKeepsFailingIsAFailureToo(t *testing.T) { } } -func TestAnUnreadableSecretIsAnnouncedAsSuch(t *testing.T) { +func testAnUnreadableSecretIsAnnouncedAsSuch(t *testing.T) { w, said := standingWorld(t) w.give(map[string]any{"as": "a"}) os.Remove(w.dir + "/a.secret") @@ -130,7 +130,7 @@ func TestAnUnreadableSecretIsAnnouncedAsSuch(t *testing.T) { } } -func TestAConsumerNoLongerAskedForIsNoLongerFailing(t *testing.T) { +func testAConsumerNoLongerAskedForIsNoLongerFailing(t *testing.T) { w, said := standingWorld(t) w.a.failing = errors.New("boom") w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"}) @@ -146,7 +146,7 @@ func TestAConsumerNoLongerAskedForIsNoLongerFailing(t *testing.T) { } } -func TestAnErrorIsClassedByItsWords(t *testing.T) { +func testAnErrorIsClassedByItsWords(t *testing.T) { for text, want := range map[string]string{ `Keycloak token request failed: 401 {"error":"invalid_grant"}`: ClassCredentials, `FATAL: password authentication failed for user "postgres"`: ClassCredentials, @@ -160,7 +160,7 @@ func TestAnErrorIsClassedByItsWords(t *testing.T) { } } -func TestAnAdapterThatClassesItsOwnErrorsIsBelieved(t *testing.T) { +func testAnAdapterThatClassesItsOwnErrorsIsBelieved(t *testing.T) { w, said := standingWorld(t) w.h.Adapter = classing{w.a} w.a.failing = errors.New("anything") @@ -177,7 +177,7 @@ func (classing) Class(error) string { return "its-own" } // A provider restarted after announcing a failure has forgotten it; its first success for each // consumer is announced, so the controller clears what it kept rather than naming it for ever. -func TestTheFirstSuccessSinceStartIsAnnouncedOnce(t *testing.T) { +func testTheFirstSuccessSinceStartIsAnnouncedOnce(t *testing.T) { w := newWorld(t) var said []announced w.h.Announce = func(e string, b map[string]any) { @@ -191,3 +191,15 @@ func TestTheFirstSuccessSinceStartIsAnnouncedOnce(t *testing.T) { t.Fatalf("%v", said) } } + +// TestStanding holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). +func TestStanding(t *testing.T) { + t.Run("AConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass", testAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass) + t.Run("OneSuccessBetweenFailuresStartsTheRunAgain", testOneSuccessBetweenFailuresStartsTheRunAgain) + t.Run("ACheckThatKeepsFailingIsAFailureToo", testACheckThatKeepsFailingIsAFailureToo) + t.Run("AnUnreadableSecretIsAnnouncedAsSuch", testAnUnreadableSecretIsAnnouncedAsSuch) + t.Run("AConsumerNoLongerAskedForIsNoLongerFailing", testAConsumerNoLongerAskedForIsNoLongerFailing) + t.Run("AnErrorIsClassedByItsWords", testAnErrorIsClassedByItsWords) + t.Run("AnAdapterThatClassesItsOwnErrorsIsBelieved", testAnAdapterThatClassesItsOwnErrorsIsBelieved) + t.Run("TheFirstSuccessSinceStartIsAnnouncedOnce", testTheFirstSuccessSinceStartIsAnnouncedOnce) +} -- 2.54.0 From c10ef07ea3aca2341c318c1bfe453316b0d51d6d Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:29:35 +0100 Subject: [PATCH 059/144] =?UTF-8?q?ADR=200605=20=C2=A79:=20catalogue.SeatR?= =?UTF-8?q?eference=20names=20what=20a=20build=20published=20in=20the=20se?= =?UTF-8?q?at's=20form,=20the=20inverse=20of=20InRegistrySeat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/artifacts.go | 7 +++++++ go/catalogue/artifacts_test.go | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/go/catalogue/artifacts.go b/go/catalogue/artifacts.go index 2a4c7d5..714b7de 100644 --- a/go/catalogue/artifacts.go +++ b/go/catalogue/artifacts.go @@ -60,6 +60,13 @@ func InArtifactStore(reference string) (path string, kept bool) { // (novox/hq ADR 0605 §1, §9). var RegistrySeats = []string{contractseats.ContainerImageRegistry, contractseats.OciArtifactRegistry} +// SeatReference is the reference a build names an image, bundle, archive or upstream copy by once it published it +// through a registry seat (novox/hq ADR 0605 §9): `//@sha256:`, as InRegistrySeat reads +// it back. The store's address is left out, as in the older form: a reader routes it (Routed). +func SeatReference(seat, module, artifact, digest string) string { + return seat + "/" + module + "/" + artifact + "@" + digest +} + // InRegistrySeat reads a reference in the form a registry seat answers, `//@sha256:`: // the seat it came through and the rest. A seat's name holds no dot or port, so no image of an outside registry reads // as one, and a kept `artifact-store://` reference is not one. diff --git a/go/catalogue/artifacts_test.go b/go/catalogue/artifacts_test.go index a59c2e2..9ff124e 100644 --- a/go/catalogue/artifacts_test.go +++ b/go/catalogue/artifacts_test.go @@ -42,4 +42,11 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) { t.Errorf("Routed(%q) = %q, want %q", reference, got, want) } } + // A build module names what it published in the seat's form, and reads it back as the seat it came through. + for _, seat := range RegistrySeats { + ref := SeatReference(seat, "gitea", "config", digest) + if got, path, ok := InRegistrySeat(ref); !ok || got != seat || path != "gitea/config@"+digest { + t.Errorf("SeatReference(%s) = %q, read back as %q %q %v", seat, ref, got, path, ok) + } + } } -- 2.54.0 From 4827df4d48b3ae367b5ae7cc2fddd0f45649b432 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:29:56 +0100 Subject: [PATCH 060/144] J3 (lead's b): two leaves of one module are both kept, the empty accepted event named by its revision Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/verdicts.go | 5 +++-- go/delivery/verdicts_test.go | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/go/delivery/verdicts.go b/go/delivery/verdicts.go index f54c0f3..0f73bfe 100644 --- a/go/delivery/verdicts.go +++ b/go/delivery/verdicts.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "slices" + "strconv" "strings" "time" @@ -235,10 +236,10 @@ func AcceptedOf(digest string, v ManifestVerdict, m catalogue.Manifest) Manifest // EventID is the event's id, one per manifest digest and judge, so the bus takes it once: // `manifest-accepted--`. The empty one of a module leaving the trunk carries no digest, so it is named by -// its module: `manifest-accepted-left--`. +// its module and the indexer's revision: `manifest-accepted-left--`, so two leaves are both kept. func (a ManifestAccepted) EventID() string { if a.Digest == "" { - return ManifestAcceptedEvent + "-left-" + a.Module + "-" + a.Judge + return ManifestAcceptedEvent + "-left-" + a.Module + "-" + strconv.FormatUint(a.Revision, 10) } return ManifestAcceptedEvent + "-" + a.Digest + "-" + a.Judge } diff --git a/go/delivery/verdicts_test.go b/go/delivery/verdicts_test.go index dc48d77..090af8a 100644 --- a/go/delivery/verdicts_test.go +++ b/go/delivery/verdicts_test.go @@ -103,10 +103,11 @@ func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { if id := heard.EventID(); id != "manifest-accepted-"+digest+"-src2:j" { t.Fatalf("the accepted event's id is %q", id) } - // The empty one of a module leaving the trunk has no digest: its id names the module, so two modules' differ. - if a, b := (ManifestAccepted{Module: "a", Judge: "j"}).EventID(), (ManifestAccepted{Module: "b", Judge: "j"}).EventID(); a == b || - a != "manifest-accepted-left-a-j" { - t.Fatalf("the ids of two modules leaving are %q and %q", a, b) + // The empty one of a module leaving the trunk has no digest: its id names the module and the revision, so two + // modules' differ, and so do two leaves of one module. + if a, b, again := (ManifestAccepted{Module: "a", Judge: "j", Revision: 7}).EventID(), (ManifestAccepted{Module: "b", Judge: "j", Revision: 7}).EventID(), + (ManifestAccepted{Module: "a", Judge: "j", Revision: 9}).EventID(); a == b || a == again || a != "manifest-accepted-left-a-7" { + t.Fatalf("the ids of modules leaving are %q, %q and %q", a, b, again) } // Both verdict events carry the indexer's revision (ADR 0622 §2), and one without it reads as revision 0. for _, ev := range []any{ManifestAccepted{Module: "m", Revision: 41}, ManifestRefused{Module: "m", Revision: 41}} { -- 2.54.0 From dae61959b903a63611cd5980b98e9e7f9856b14b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:35:56 +0100 Subject: [PATCH 061/144] J3 (lead's a): go/views names no module; its caller gives the owner's read tool and views stream ProcessReader.Follow(owner, state, tool, stream) takes the owner's read tool and its views stream as the owner's declaration names them, and refuses another owner's tool or a second stream for one owner; OwnerReadTool, StreamOf, IndexerStream and AssignerStream are gone, and viewstest takes the same two values. The second instance of a module name in shared code (the earlier review finding), so the package test now refuses a module's name in go/views. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/packages_list_test.go | 16 ++++++++++++- go/views/process_reader.go | 48 ++++++++++++++++++------------------- go/views/reader.go | 2 +- go/views/status.go | 4 ++-- go/views/views.go | 10 ++++---- go/views/views_test.go | 19 +++++++++++---- go/views/viewstest/owner.go | 17 +++++++------ 7 files changed, 69 insertions(+), 47 deletions(-) diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 6aa3664..e69d9b2 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "slices" "strconv" "strings" @@ -35,6 +36,10 @@ var clientSeats = []string{contractseats.OperatorChannel, contractseats.MeshVaul contractseats.NpmPackageRegistry, contractseats.GoPackageRegistry, contractseats.ContainerImageRegistry, contractseats.OciArtifactRegistry} +// moduleWord is a module's name in a literal (mesh-[-...], or one of mesh-delivery's parts by its short name +// before -views or _read_), but a seat's that go/views records through (mesh-hand-acts) and an import path's mesh-sdk. +var moduleWord = regexp.MustCompile(`\bmesh-(?:delivery|grants|vault|host|config|provisioner|broker|knowledge|enrolment)\b[-a-z]*|\b(?:indexer|assigner|teller|watchman|checker|builder)(?:-views|_read_)`) + // notInSDK are the packages that moved to a seat's client or contract (ADR 0627 §1, §4) and may not come back. var notInSDK = []string{"asks", "asker", "publish"} @@ -72,7 +77,7 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } return false } - var seatNames, pending []string + var seatNames, pending, modules []string clientNamed := func(v string) bool { for _, seat := range clientSeats { if v == seat || strings.HasPrefix(v, "mesh.seat."+seat+".") { @@ -114,6 +119,12 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { ast.Inspect(file, func(n ast.Node) bool { switch n := n.(type) { case *ast.BasicLit: + // views is shared by every owner and reader: it names no module, the caller gives the owner's tool and + // stream from the owner's declaration (the lead's ruling, 2026-11-04, the second instance of the kind). + if v, err := strconv.Unquote(n.Value); err == nil && n.Kind == token.STRING && (dir == "views" || strings.HasPrefix(dir, "views/")) && + moduleWord.MatchString(v) { + modules = append(modules, where+moduleWord.FindString(v)) + } if v, err := strconv.Unquote(n.Value); err == nil && n.Kind == token.STRING && namesSeat(v) { seatNames = append(seatNames, where+v) if !inCatalogue && clientNamed(v) { @@ -186,6 +197,9 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } } slices.Sort(found) + for _, m := range modules { + t.Errorf("%s: go/views names no module; its caller gives the owner's read tool and views stream from the owner's declaration", m) + } for _, dir := range found { if slices.Contains(notInSDK, dir) { pending = append(pending, dir+": a package that moved") diff --git a/go/views/process_reader.go b/go/views/process_reader.go index c12ce8a..a45fcb4 100644 --- a/go/views/process_reader.go +++ b/go/views/process_reader.go @@ -28,14 +28,6 @@ import ( // ErrReadOnly is every write to a picture: the state is its owner's. var ErrReadOnly = errors.New("a first view is read-only: the state is its owner's") -// OwnerReadTool is the owner's ordinary read tool of state, as a reader's `invokes` names it (design 25 § 12.2): -// `._read_`, short the owner's name past its last dash-separated `mesh-delivery-` prefix -// (indexer, assigner) and the state's dashes as underscores. -func OwnerReadTool(owner, state string) string { - short := strings.TrimPrefix(owner, "mesh-delivery-") - return owner + "." + short + "_read_" + strings.ReplaceAll(state, "-", "_") -} - // ProcessReader is one process's pictures of its owners' states. type ProcessReader struct { // Module is the reading module, named in the finding of a first view unanswered past its bound. @@ -51,9 +43,10 @@ type ProcessReader struct { Bound time.Duration Wait time.Duration - mu sync.Mutex - pics map[string]*Picture // by bucket name _ - order []string + mu sync.Mutex + pics map[string]*Picture // by bucket name _ + order []string + streams map[string]string // each owner's views stream, by owner } func (r *ProcessReader) logf(format string, args ...any) { @@ -62,18 +55,27 @@ func (r *ProcessReader) logf(format string, args ...any) { } } -// Follow adds the owner's state to follow through its read tool (OwnerReadTool). Call it before Start. -func (r *ProcessReader) Follow(owner, state string) *Picture { +// Follow adds the owner's state to follow, as the owner declares them (design 25 § 12.2): tool is its ordinary read tool +// of state as the reader's `invokes` names it (`.`, from the owner's manifest's tools), stream its views +// family as the owner's manifest's `streams` names it (its bus name is catalogue.StreamBusName's). views names no owner: +// the caller gives both. An owner has one views stream; a second name for it is refused. Call it before Start. +func (r *ProcessReader) Follow(owner, state, tool, stream string) (*Picture, error) { r.mu.Lock() defer r.mu.Unlock() - if r.pics == nil { - r.pics = map[string]*Picture{} + if owner == "" || state == "" || !strings.HasPrefix(tool, owner+".") || stream == "" { + return nil, fmt.Errorf("%s follows %s's %s by its read tool . and its views stream, not %q and %q", r.Module, owner, state, tool, stream) } + if have, ok := r.streams[owner]; ok && have != stream { + return nil, fmt.Errorf("%s's views stream is %s, not %s", owner, have, stream) + } + if r.pics == nil { + r.pics, r.streams = map[string]*Picture{}, map[string]string{} + } + r.streams[owner] = stream bucket := owner + "_" + state if p := r.pics[bucket]; p != nil { - return p + return p, nil } - tool := OwnerReadTool(owner, state) p := &Picture{bucket: bucket, ctx: context.Background()} call := r.Call ask := AskVia(func(key string, body any) (json.RawMessage, error) { @@ -92,7 +94,7 @@ func (r *ProcessReader) Follow(owner, state string) *Picture { Changed: p.changed} r.pics[bucket] = p r.order = append(r.order, bucket) - return p + return p, nil } // Picture is the picture of the bucket the process used to bind, by its bus name (`_`); nil when not @@ -507,19 +509,15 @@ func (e entry) Created() time.Time { return e.at } func (e entry) Delta() uint64 { return 0 } func (e entry) Operation() jetstream.KeyValueOp { return e.op } -// StreamOf is the bus name of owner's views family (IndexerStream, AssignerStream): `-views` -// as the owner's old `streams` section names it. -func StreamOf(owner string) string { - return catalogue.StreamBusName(strings.TrimPrefix(owner, "mesh-delivery-") + "-views") -} - // Start hears each owner's views family on the process's durable consumer of it (its manifest's `consumes` // `.views.`), keeps each status in the process's state `first-views` and hears the hand asks the tools // bundle's `_first_view_again` writes there (StatusTools), then asks every first view (Ask). A consumer // the bus does not have yet is said: the picture is then the first view's alone, asked again on a gap or by hand. func (r *ProcessReader) Start(ctx context.Context, js jetstream.JetStream, cred process.Account, status *process.AwaitedBucket) { for _, owner := range r.Owners() { - stream := StreamOf(owner) + r.mu.Lock() + stream := catalogue.StreamBusName(r.streams[owner]) + r.mu.Unlock() cons, err := cred.EventConsumer(ctx, js, stream) if err == nil { _, err = process.HearEvents(cons, r.Hear(ctx, owner), r.logf) diff --git a/go/views/reader.go b/go/views/reader.go index 5e5d30b..e98b67b 100644 --- a/go/views/reader.go +++ b/go/views/reader.go @@ -13,7 +13,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/handacts" ) -// Reader is a module's pictures of the states of the indexer and the assigner it used to read (novox/hq ADR 0615 §1): it +// Reader is a module's pictures of the owners' states it used to read (novox/hq ADR 0615 §1): it // asks each owner's read tool for the first view, subscribes the owner's views family, and answers the module's own // `Keys` and `Get` of a state from the picture, by the name the module's `reads` used to carry (`.`). // A module in a stdio tools bundle declares `invokes` for the tools and `consumes` for the events, and names no `reads` diff --git a/go/views/status.go b/go/views/status.go index d60b89b..e040a80 100644 --- a/go/views/status.go +++ b/go/views/status.go @@ -99,7 +99,7 @@ func HearAgain(ctx context.Context, kv jetstream.KeyValue, followers []*Follower func StatusTools(reader, read, again string, st stdio.KeptState, acts handacts.Recorder, now func() time.Time) []stdio.Tool { return []stdio.Tool{ {Name: read, - Description: fmt.Sprintf("%s's first views of the indexer's and the assigner's states (novox/hq ADR 0615): per owner and state, "+ + Description: fmt.Sprintf("%s's first views of the owners' states it follows (novox/hq ADR 0615): per owner and state, "+ "the tool it asks, the revision its picture held when last answered, when it asked and was answered, how many asks "+ "(by hand among them) and whether its condition (unanswered past two minutes) is raised.", reader), Input: map[string]any{"type": "object", "properties": map[string]any{}}, @@ -128,7 +128,7 @@ func StatusTools(reader, read, again string, st stdio.KeptState, acts handacts.R Description: fmt.Sprintf("Ask %s's first view of an owner's state again now, by hand (a hand act, its caller and why kept in "+ "the status %s reads): for a picture a person doubts. The part asks once and follows the owner's events after the answer.", reader, read), Input: map[string]any{"type": "object", "required": []string{"owner", "state", "why"}, "properties": map[string]any{ - "owner": map[string]any{"type": "string", "description": "the owner: mesh-delivery-indexer or mesh-delivery-assigner"}, + "owner": map[string]any{"type": "string", "description": "the owner module, as the read tool names it"}, "state": map[string]any{"type": "string", "description": "the owner's state, as the read tool names it"}, "why": map[string]any{"type": "string", "description": "why it is asked again, in plain words"}}}, Run: func(args map[string]any) (any, error) { diff --git a/go/views/views.go b/go/views/views.go index 287f058..4e1a515 100644 --- a/go/views/views.go +++ b/go/views/views.go @@ -1,5 +1,5 @@ // Package views is the owners' side and the readers' side of a first view (novox/hq ADR 0615, ADR 0621, ADR 0622; -// design 25 § 12.2): the indexer and the assigner answer a reader's first view of one of their states as a tool of +// design 25 § 12.2): an owner answers a reader's first view of one of their states as a tool of // their own module (Answer, FirstView), say each change of that state as one event on their views family (Event, // Emitter), and a reader keeps its picture from the answer and the events, asking the tool again only on a gap in // revisions (Follower). The tools' read side (Read) answers the ordinary read tools of § 12.2 too. @@ -19,12 +19,10 @@ const Version = 1 // The owners' views families (design 25 § 12.2; ADR 0615 §5, ADR 0619 §2): one stream each on its owner's old `streams` // section, kept by limits 7 days and at most 256 MiB, dropping its oldest when full, so a reader that missed a dropped -// event sees the gap in revisions and asks once more. A stream's name is the mesh's, so each carries its owner's name. +// event sees the gap in revisions and asks once more. A stream's name is its owner's to declare; views names none. const ( - IndexerStream = "indexer-views" - AssignerStream = "assigner-views" - StreamDays = 7 - StreamMiB = 256 + StreamDays = 7 + StreamMiB = 256 // EventPrefix starts each event's local name: `views.`, one event per changed key of that state. EventPrefix = "views." // EventPattern is every event of a views family, as a stream's events and a reader's consumes name it. diff --git a/go/views/views_test.go b/go/views/views_test.go index 7c4b703..8a3227a 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -265,28 +265,37 @@ func TestViews(t *testing.T) { t.Errorf("asked again by hand: %v, %v", again, err) } }) - t.Run("a process's reader reads its owner's state as a read-only bucket, each key's written time handed on, and opens no other", func(t *testing.T) { + t.Run("a process's reader follows the read tool and views stream its caller names, reads the state as a read-only bucket, each key's written time handed on, and opens no other", func(t *testing.T) { r := &ProcessReader{Module: "reader", Bound: time.Nanosecond, Wait: time.Millisecond, Now: now, Call: func(tool string, body any) (json.RawMessage, error) { - if m, _ := body.(map[string]any); tool != "mesh-delivery-indexer.indexer_read_modules" || m["values"] != true { + if m, _ := body.(map[string]any); tool != "o.o_read_modules" || m["values"] != true { return nil, fmt.Errorf("asked %s with %v", tool, body) } a, err := FirstView("o", "modules", values, marks, "", now()) raw, _ := json.Marshal(map[string]any{"result": a}) return raw, err }} - p := r.Follow("mesh-delivery-indexer", "modules") + p, err := r.Follow("o", "modules", "o.o_read_modules", "o-views") + if err != nil { + t.Fatal(err) + } + for name, c := range map[string][2]string{"another owner's tool": {"x.read_more", "o-views"}, "no stream": {"o.read_more", ""}, + "a second stream for the owner": {"o.read_more", "o-other-views"}} { + if _, err := r.Follow("o", "more", c[0], c[1]); err == nil { + t.Fatalf("%s is followed", name) // a picture no owner answers would hold Ready + } + } if _, err := p.Get(context.Background(), "a"); err == nil { t.Errorf("a picture with no first view is read") } - if _, err := r.KeyValue(context.Background(), "mesh-delivery-indexer_other"); err == nil { + if _, err := r.KeyValue(context.Background(), "o_other"); err == nil { t.Errorf("a bucket not followed is opened") } ctx, cancel := context.WithCancel(context.Background()) defer cancel() r.Ask(ctx, nil) <-r.Ready() - kv, err := r.KeyValue(ctx, "mesh-delivery-indexer_modules") + kv, err := r.KeyValue(ctx, "o_modules") if err != nil { t.Fatal(err) } diff --git a/go/views/viewstest/owner.go b/go/views/viewstest/owner.go index eb5a1f8..9f11002 100644 --- a/go/views/viewstest/owner.go +++ b/go/views/viewstest/owner.go @@ -14,14 +14,17 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" ) // Serve is owner's state on the bus with the owner's side of it: the bucket `_` (made when absent, history // 1), the owner's marks, its views stream with a durable consumer per reader (`_`), the emitter saying -// each change, and the read tool (views.OwnerReadTool) answering on nc. It returns the bucket the test writes. -func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state string, readers ...string) jetstream.KeyValue { +// each change, and the read tool answering on nc: tool is the owner's read tool as a reader's invokes names it +// (`.`), stream its views stream as its manifest's `streams` names it, both given as the reader's +// ProcessReader.Follow takes them. It returns the bucket the test writes. +func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state, tool, stream string, readers ...string) jetstream.KeyValue { t.Helper() bucket := owner + "_" + state kv, err := js.KeyValue(ctx, bucket) @@ -34,7 +37,7 @@ func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.C if err != nil { t.Fatal(err) } - stream := views.StreamOf(owner) + stream = catalogue.StreamBusName(stream) if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{process.ModuleStreamSubject(owner, views.EventPrefix+">")}}); err != nil { t.Fatal(err) } @@ -48,7 +51,7 @@ func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.C if err := em.Start(ctx, state, kv, nil); err != nil { t.Fatal(err) } - tool := strings.TrimPrefix(views.OwnerReadTool(owner, state), owner+".") + tool = strings.TrimPrefix(tool, owner+".") sub, err := nc.Subscribe("mesh.mod."+owner+".call."+tool+".>", func(m *nats.Msg) { var in struct { After string `json:"after"` @@ -71,9 +74,9 @@ func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.C // Grants are the subjects a reader's account publishes to follow owner's state (its `invokes` and `consumes`): the // read tool's call subjects, its durable consumer's info, next and acknowledgements on the owner's views stream. -func Grants(owner, state, durable string) []string { - tool := strings.TrimPrefix(views.OwnerReadTool(owner, state), owner+".") - stream := views.StreamOf(owner) +func Grants(owner, tool, stream, durable string) []string { + tool = strings.TrimPrefix(tool, owner+".") + stream = catalogue.StreamBusName(stream) return []string{"mesh.mod." + owner + ".call." + tool + ".>", "mesh.mod." + owner + ".call." + tool, "$JS.API.CONSUMER.INFO." + stream + "." + durable, "$JS.API.CONSUMER.MSG.NEXT." + stream + "." + durable, "$JS.ACK." + stream + "." + durable + ".>"} -- 2.54.0 From 712b21bd74745e9cf1b936d6c36c13c5b5dcd86b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:42:35 +0100 Subject: [PATCH 062/144] J3 (A3n finding 3, A2k finding 1): the builder's version.numbered is a contract, and a build's answer names its files out of step delivery.VersionNumbered with VersionNumberedEvent and VersionNumberedSubject is the body the builder says when it numbers a version, so a build module consumes it from mesh-sdk instead of the builder's internal package. BuildAnswer gains out-of-step, so the builder decodes a trunk build's compare by its field, not by name. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/published_packages_test.go | 17 +++++++- go/delivery/stepasks.go | 4 ++ go/delivery/version_numbered.go | 57 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 go/delivery/version_numbered.go diff --git a/go/delivery/published_packages_test.go b/go/delivery/published_packages_test.go index 773f022..a971dc9 100644 --- a/go/delivery/published_packages_test.go +++ b/go/delivery/published_packages_test.go @@ -105,8 +105,21 @@ func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) { } } var answer BuildAnswer - if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"]}`), &answer); err != nil || len(answer.Imports) != 1 { - t.Fatalf("build answer imports: %v, %v", err, answer.Imports) + if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"],"out-of-step":["go/seats/x/contract.go"]}`), &answer); err != nil || + len(answer.Imports) != 1 || !reflect.DeepEqual(answer.OutOfStep, []string{"go/seats/x/contract.go"}) { + t.Fatalf("build answer imports and files out of step: %v, %v, %v", err, answer.Imports, answer.OutOfStep) + } + // The builder's version.numbered, as a build module consumes it: the builder's own body whole, and its subject. + numbered := VersionNumbered{Name: "mesh-sdk/go", Kind: "package", Version: "0.1.7", Module: "mesh-sdk", Fingerprint: "src2:x", + Commit: commit, Digest: "sha256:d", Seat: "go-package-registry", Head: commit, Again: "2026-11-04T15:00:00Z"} + if raw, err := EncodeVersionNumbered(numbered); err != nil { + t.Errorf("numbered refused: %v", err) + } else if back, err := DecodeVersionNumbered(raw); err != nil || back != numbered || + !strings.Contains(string(raw), `"fingerprint":"src2:x"`) || VersionNumberedSubject != "mesh.mod.mesh-delivery-builder.event.version.numbered" { + t.Errorf("numbered round trip: %+v, %v, %s", back, err, raw) + } + if _, err := DecodeVersionNumbered([]byte(`{"name":"mesh-sdk/go","version":"0.1.7"}`)); err == nil { + t.Error("a numbered version naming no build is read") } if err := CheckImports([]string{"fmt"}); err == nil { t.Error("CheckImports takes a standard package") diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index 110cd21..a4ddb74 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -588,6 +588,10 @@ type BuildAnswer struct { // once (novox/hq ADR 0593 §2): a Go build module reports them, the builder carries them onto the build's end // (BuildLogEntry.Imports). Absent from a build module that reports none. Imports []string `json:"imports,omitempty"` + // OutOfStep are the generated files the build's compare found out of step with what their generator writes (the test + // step's `out-of-step`, answered by a trunk build's ask too): the builder carries them onto the build's end + // (BuildLogEntry.OutOfStep). Absent when none is, or from a build module that compares none. + OutOfStep []string `json:"out-of-step,omitempty"` } // AskBuilt is one artifact a build ask built. diff --git a/go/delivery/version_numbered.go b/go/delivery/version_numbered.go new file mode 100644 index 0000000..634cb84 --- /dev/null +++ b/go/delivery/version_numbered.go @@ -0,0 +1,57 @@ +package delivery + +import ( + "encoding/json" + "fmt" +) + +// The builder's event version.numbered (design 51 *Events of the builder*, *The floor*): said once per version the builder +// numbers, on its own family `version` (seven days), apart from its builds' ends, so a waiter on a build's end does not +// wake on it. A build module consumes it to publish a build's version with the held digest, which stores nothing. +const ( + // VersionNumberedEvent is the event's local name. + VersionNumberedEvent = "version.numbered" + // VersionNumberedSubject is its subject, as a consumer's filter names it. + VersionNumberedSubject = "mesh.mod." + BuilderModule + ".event." + VersionNumberedEvent +) + +// VersionNumbered is the body of VersionNumberedEvent: a numbered version of a name, the build it was numbered for, and +// what the build module needs to publish it. +type VersionNumbered struct { + // Name is the numbered name, as builder_versions lists it; Kind its artifact's kind. + Name string `json:"name"` + Kind string `json:"kind"` + Version string `json:"version"` + Tag string `json:"tag,omitempty"` + // Module and Fingerprint are the build the version was numbered for; Commit its source commit. + Module string `json:"module"` + Fingerprint string `json:"fingerprint"` + Commit string `json:"commit,omitempty"` + // Digest is the built artifact's, when the build held one; Seat the registry seat it is published through. + Digest string `json:"digest,omitempty"` + Seat string `json:"seat,omitempty"` + // Head is the head the version answers, when it answers a tag ask. + Head string `json:"head,omitempty"` + // Again is when builder_publish_again said it once more, so the bus's dedup does not drop it. + Again string `json:"again,omitempty"` +} + +// EncodeVersionNumbered writes the body, refusing one that names no name, version, module or fingerprint. +func EncodeVersionNumbered(n VersionNumbered) ([]byte, error) { + if n.Name == "" || n.Version == "" || n.Module == "" || n.Fingerprint == "" { + return nil, fmt.Errorf("delivery: %s names its name, version, module and fingerprint", VersionNumberedEvent) + } + return json.Marshal(n) +} + +// DecodeVersionNumbered reads the body, with the same refusal. +func DecodeVersionNumbered(data []byte) (VersionNumbered, error) { + var n VersionNumbered + if err := json.Unmarshal(data, &n); err != nil { + return VersionNumbered{}, fmt.Errorf("delivery: %s is not readable: %w", VersionNumberedEvent, err) + } + if _, err := EncodeVersionNumbered(n); err != nil { + return VersionNumbered{}, err + } + return n, nil +} -- 2.54.0 From fe83d66b62e6c0f73efb0ba554ab79a257e6887e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:48:21 +0100 Subject: [PATCH 063/144] J4 (A2j finding 1): an owner's read tool answers a key's kept history, so a reader sees what changed before its first view The view's module history, heard messages and a want's since asked the owners' read tools for a key's history and were refused: views.ReadTool takes key with history and answers {history: [{revision, at, op, value}]} from the state's kept values (KeptSource through mesh/state.history; viewstest.Source and Serve from the bucket). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/tools.go | 75 +++++++++++++++++++++++++++++++++---- go/views/views_test.go | 30 ++++++++++++++- go/views/viewstest/owner.go | 36 ++++++++++++++++-- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/go/views/tools.go b/go/views/tools.go index 20b164e..6951e13 100644 --- a/go/views/tools.go +++ b/go/views/tools.go @@ -25,6 +25,20 @@ func (k KeptSource) Get(key string) (*Entry, bool, error) { return &Entry{Value: e.Value, Revision: e.Revision, Written: e.At}, true, nil } +// History is every value the state still keeps for key, oldest first (stdio.KeptState.History): as many as the +// state declares it keeps, deletes included. +func (k KeptSource) History(key string) ([]Revision, error) { + kept, err := k.State.History(key) + if err != nil { + return nil, err + } + out := make([]Revision, 0, len(kept)) + for _, r := range kept { + out = append(out, Revision{Revision: r.Revision, At: r.At, Op: r.Op, Value: r.Value}) + } + return out, nil +} + // ParseCallers is a callers list as a manifest writes it into an env value: comma-separated, each a module (on any // machine), . or person. (stdio.Callers). func ParseCallers(list string) stdio.Callers { @@ -76,22 +90,28 @@ func FirstViewTool(owner, name, state, words string, callers stdio.Callers, valu } // ReadTool is an owner's ordinary read tool of one of its states (design 25 § 12.2): the keys, one key's value and -// revision, or a page of values in the first view's form, and the state's fill against its cap as its mark last said -// it. Granted, as any tool is, to every reader that invokes it, and callable by a session. +// revision, one key's kept history (values a Historian), or a page of values in the first view's form, and the state's +// fill against its cap as its mark last said it. Granted, as any tool is, to every reader that invokes it, and callable +// by a session. func ReadTool(owner, name, state, words string, values, marks Source, now func() time.Time) stdio.Tool { return stdio.Tool{ Name: name, Description: fmt.Sprintf("Read %s's state %s: %s With no argument, its keys and how full it is against its cap (as the "+ - "owner last marked it); with key, that key's value and revision; with values, a page of values whole by key, at the "+ - "revision the owner said its views family up to, continued with after (the page's next).", owner, state, words), + "owner last marked it); with key, that key's value and revision; with key and history, every value the state still "+ + "keeps for that key, oldest first, each with its revision, time and op; with values, a page of values whole by key, "+ + "at the revision the owner said its views family up to, continued with after (the page's next).", owner, state, words), Input: map[string]any{"type": "object", "properties": map[string]any{ - "key": map[string]any{"type": "string", "description": "one key: its value and revision"}, - "values": map[string]any{"type": "boolean", "description": "a page of values whole by key"}, - "after": map[string]any{"type": "string", "description": "with values: the key the page before ended at"}}}, + "key": map[string]any{"type": "string", "description": "one key: its value and revision"}, + "history": map[string]any{"type": "boolean", "description": "with key: every value the state keeps for it, oldest first"}, + "values": map[string]any{"type": "boolean", "description": "a page of values whole by key"}, + "after": map[string]any{"type": "string", "description": "with values: the key the page before ended at"}}}, Run: func(args map[string]any) (any, error) { key, _ := args["key"].(string) after, _ := args["after"].(string) all, _ := args["values"].(bool) + if history, _ := args["history"].(bool); history { + return ReadHistory(owner, state, values, strings.TrimSpace(key)) + } return Read(owner, state, values, marks, strings.TrimSpace(key), all, strings.TrimSpace(after), now()) }, } @@ -119,6 +139,47 @@ type KeyAnswer struct { Revision uint64 `json:"revision,omitempty"` } +// Revision is one value a state keeps for a key: its revision, when the bus wrote it, its op (put, or delete) and +// its value, null for a delete. +type Revision struct { + Revision uint64 `json:"revision"` + At time.Time `json:"at"` + Op string `json:"op"` + Value json.RawMessage `json:"value"` +} + +// Historian is a Source that also keeps each key's history (KeptSource: the state's own kept values). +type Historian interface { + History(key string) ([]Revision, error) +} + +// HistoryAnswer is a read tool's answer of one key's history: every value kept, oldest first; none kept is empty. +type HistoryAnswer struct { + Owner string `json:"owner"` + State string `json:"state"` + Key string `json:"key"` + History []Revision `json:"history"` +} + +// ReadHistory is a read tool's answer of key's history from values, refused when no key is named or values keeps none. +func ReadHistory(owner, state string, values Source, key string) (HistoryAnswer, error) { + if key == "" { + return HistoryAnswer{}, fmt.Errorf("a history of %s is of one key: name it", state) + } + h, ok := values.(Historian) + if !ok { + return HistoryAnswer{}, fmt.Errorf("%s's state %s keeps no history of its keys here", owner, state) + } + kept, err := h.History(key) + if err != nil { + return HistoryAnswer{}, fmt.Errorf("the history of %s.%s cannot be read: %w", state, key, err) + } + if kept == nil { + kept = []Revision{} + } + return HistoryAnswer{Owner: owner, State: state, Key: key, History: kept}, nil +} + // Read is a read tool's answer: one key, a page of values, or the keys and fill. func Read(owner, state string, values, marks Source, key string, all bool, after string, now time.Time) (any, error) { switch { diff --git a/go/views/views_test.go b/go/views/views_test.go index 8a3227a..e1a990f 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -32,6 +32,14 @@ func (m mapSource) Get(key string) (*Entry, bool, error) { return &e, true, nil } +// keptSource is a mapSource that keeps each key's history, as a Historian. +type keptSource struct { + mapSource + kept map[string][]Revision +} + +func (k keptSource) History(key string) ([]Revision, error) { return k.kept[key], nil } + func markOf(state string, rev uint64, made time.Time) Entry { raw, _ := json.Marshal(Mark{V: Version, State: state, Revision: rev, Created: made, Fill: Fill{Keys: 2, Bytes: 100, MaxBytes: 1000}}) return Entry{Value: raw, Revision: 1} @@ -97,7 +105,7 @@ func TestViews(t *testing.T) { t.Errorf("an unproven call naming a caller is answered") } }) - t.Run("a read tool says the keys and the fill, one key, or a page of values", func(t *testing.T) { + t.Run("a read tool says the keys and the fill, one key, one key's kept history, or a page of values", func(t *testing.T) { r, err := Read("o", "modules", values, marks, "", false, "", now()) ra, ok := r.(ReadAnswer) if err != nil || !ok || len(ra.Keys) != 2 || ra.Fill == nil || ra.Fill.MaxBytes != 1000 || ra.Revision != 9 { @@ -111,6 +119,26 @@ func TestViews(t *testing.T) { if ra := r.(ReadAnswer); ra.Fill != nil || ra.Words == "" { t.Errorf("an unmarked state says its fill as %+v", ra) } + kept := []Revision{{Revision: 3, At: written, Op: "put", Value: json.RawMessage(`{"n":0}`)}, + {Revision: 5, At: written.Add(time.Second), Op: "delete", Value: json.RawMessage(`null`)}, {Revision: 7, At: written, Op: "put", Value: json.RawMessage(`{"n":1}`)}} + tool := ReadTool("o", "o_read_modules", "modules", "w", keptSource{values, map[string][]Revision{"a": kept}}, marks, now) + r, err = tool.Run(map[string]any{"key": "a", "history": true}) + raw, _ := json.Marshal(r) + var h struct { + History []Revision `json:"history"` + } + if err != nil || json.Unmarshal(raw, &h) != nil || len(h.History) != 3 || h.History[1].Op != "delete" || h.History[2].Revision != 7 || !h.History[0].At.Equal(written) { + t.Errorf("a key's history = %s, %v", raw, err) + } + if r, err = tool.Run(map[string]any{"key": "none", "history": true}); err != nil || r.(HistoryAnswer).History == nil { + t.Errorf("a key with nothing kept answers %+v, %v, not an empty history", r, err) + } + if _, err := tool.Run(map[string]any{"history": true}); err == nil { + t.Error("a history naming no key is answered") + } + if _, err := ReadTool("o", "o_read_modules", "modules", "w", values, marks, now).Run(map[string]any{"key": "a", "history": true}); err == nil { + t.Error("a source keeping no history answers one") + } }) // A follower whose owner answers from values and marks. diff --git a/go/views/viewstest/owner.go b/go/views/viewstest/owner.go index 9f11002..4b62535 100644 --- a/go/views/viewstest/owner.go +++ b/go/views/viewstest/owner.go @@ -23,7 +23,8 @@ import ( // 1), the owner's marks, its views stream with a durable consumer per reader (`_`), the emitter saying // each change, and the read tool answering on nc: tool is the owner's read tool as a reader's invokes names it // (`.`), stream its views stream as its manifest's `streams` names it, both given as the reader's -// ProcessReader.Follow takes them. It returns the bucket the test writes. +// ProcessReader.Follow takes them. The tool answers a key's history too (key with history), from what the bucket keeps: +// a test that reads history makes the bucket first with the history it needs. It returns the bucket the test writes. func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state, tool, stream string, readers ...string) jetstream.KeyValue { t.Helper() bucket := owner + "_" + state @@ -54,10 +55,18 @@ func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.C tool = strings.TrimPrefix(tool, owner+".") sub, err := nc.Subscribe("mesh.mod."+owner+".call."+tool+".>", func(m *nats.Msg) { var in struct { - After string `json:"after"` + After string `json:"after"` + Key string `json:"key"` + History bool `json:"history"` } _ = json.Unmarshal(m.Data, &in) - a, err := views.FirstView(owner, state, Source{ctx, kv}, Source{ctx, marks}, in.After, time.Now()) + var a any + var err error + if in.History { + a, err = views.ReadHistory(owner, state, Source{ctx, kv}, in.Key) + } else { + a, err = views.FirstView(owner, state, Source{ctx, kv}, Source{ctx, marks}, in.After, time.Now()) + } out := map[string]any{"result": a, "node": "n0"} if err != nil { out = map[string]any{"error": err.Error()} @@ -108,3 +117,24 @@ func (s Source) Get(key string) (*views.Entry, bool, error) { } return &views.Entry{Value: e.Value(), Revision: e.Revision(), Written: e.Created()}, true, nil } + +// History is every value the bucket keeps for key, oldest first, as the tool runner's mesh/state.history says it +// (a put, or a delete with a null value); none kept is an empty history. +func (s Source) History(key string) ([]views.Revision, error) { + kept, err := s.KV.History(s.Ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return []views.Revision{}, nil + } + if err != nil { + return nil, err + } + out := make([]views.Revision, 0, len(kept)) + for _, e := range kept { + r := views.Revision{Revision: e.Revision(), At: e.Created().UTC(), Op: "delete", Value: json.RawMessage("null")} + if e.Operation() == jetstream.KeyValuePut { + r.Op, r.Value = "put", json.RawMessage(e.Value()) + } + out = append(out, r) + } + return out, nil +} -- 2.54.0 From 28ce325c2e843c74e813bed80ef1ce2c3760e4fe Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:53:01 +0100 Subject: [PATCH 064/144] J4 (A2j finding 2): a verdict names each series change the heads make, so none is lost when the status line is cut delivery.Verdict carries series, each {module, artifact (or the package's ecosystem name), from, to}, refused when a field is empty, it changes nothing, or one artifact is named twice. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/checker.go | 27 +++++++++++++++++++++++++++ go/delivery/checker_test.go | 16 +++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/go/delivery/checker.go b/go/delivery/checker.go index b5f5bad..d0a9f9d 100644 --- a/go/delivery/checker.go +++ b/go/delivery/checker.go @@ -179,6 +179,20 @@ type Verdict struct { // carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages // whose digest the moves changed. Absent from a verdict judged whole, and from one still running. CarriedFrom map[string]string `json:"carried-from,omitempty"` + // Series names each change of an artifact's series the checked heads make against the trunk's manifests (design 32; + // the checker's DLV-C4), each once, in the order the check found them; the pull request's mesh/check line says the + // same, and a reader that cuts that line still has every one here. Absent when the heads change no series, and in a + // value written before it. + Series []SeriesChange `json:"series,omitempty"` +} + +// SeriesChange is one change of a series: the module, its artifact (or the package's ecosystem name, as the module's +// packages name it), the series on trunk and the series the heads give it. +type SeriesChange struct { + Module string `json:"module"` + Artifact string `json:"artifact"` + From string `json:"from"` + To string `json:"to"` } func (c *Composition) check() error { @@ -243,6 +257,19 @@ func (v Verdict) check() error { return fmt.Errorf("delivery: the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried") } } + changed := map[string]bool{} + for _, c := range v.Series { + if c.Module == "" || c.Artifact == "" || c.From == "" || c.To == "" { + return fmt.Errorf("delivery: the verdict's series change %+v names no module, artifact, from or to", c) + } + if c.From == c.To { + return fmt.Errorf("delivery: the verdict's series change of %s/%s is from %s to the same series", c.Module, c.Artifact, c.From) + } + if changed[c.Module+"/"+c.Artifact] { + return fmt.Errorf("delivery: the verdict names the series change of %s/%s twice", c.Module, c.Artifact) + } + changed[c.Module+"/"+c.Artifact] = true + } for level, r := range v.Levels { if err := checkLevel(level); err != nil { return err diff --git a/go/delivery/checker_test.go b/go/delivery/checker_test.go index b20af2e..e923ccd 100644 --- a/go/delivery/checker_test.go +++ b/go/delivery/checker_test.go @@ -33,8 +33,11 @@ func verdictSamples() map[string]Verdict { carried.Beside = map[string]string{"novox/mesh-catalog": "cccc"} carried.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999", "novox/mesh-control": "bbbb", "novox/mesh-catalog": "cccc"} carried.Composition = &Composition{State: CompositionPass, SDK: carried.SDK, Machines: []string{"ace"}, FPSet: "fps1:00"} + series := passVerdict() + series.Series = []SeriesChange{{Module: "mesh-sdk", Artifact: "go", From: "0.1", To: "0.2"}, + {Module: "mesh-contracts", Artifact: "@novox/mesh-contracts", From: "0.1", To: "1.0"}} return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine, - "carried": carried} + "carried": carried, "naming its series changes": series} } func keptSample() Kept { @@ -93,6 +96,11 @@ func TestVerdictRoundTrip(t *testing.T) { } } data, _ := EncodeVerdict(passVerdict()) + data, _ = EncodeVerdict(verdictSamples()["naming its series changes"]) + if !strings.Contains(string(data), `"series":[{"module":"mesh-sdk","artifact":"go","from":"0.1","to":"0.2"},`) { + t.Errorf("the series changes are not each {module, artifact, from, to}: %s", data) + } + data, _ = EncodeVerdict(passVerdict()) for _, field := range []string{`"queue":1200`, `"clone":800`, `"unit":950`, `"integration-setup":2100`, `"integration-run":7400`, `"composition":3300`, `"run":12`, `"kept":301`, `"machines":["ace","g14","novox","shanks"]`, `"sdk":"v0.1.14`} { if !strings.Contains(string(data), field) { t.Errorf("the value lacks %s: %s", field, data) @@ -314,6 +322,12 @@ func TestVerdictNewFieldsChecked(t *testing.T) { "carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}}, "carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}}, "a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}}, + // Design 32: each series change whole, once, and a change. + "a series change with no from": {func(v *Verdict) { v.Series = []SeriesChange{{Module: "m", Artifact: "go", To: "0.2"}} }, []string{"series", "from"}}, + "a series change to itself": {func(v *Verdict) { v.Series = []SeriesChange{{Module: "m", Artifact: "go", From: "0.2", To: "0.2"}} }, []string{"m/go", "same series"}}, + "a series change named twice": {func(v *Verdict) { + v.Series = []SeriesChange{{Module: "m", Artifact: "go", From: "0.1", To: "0.2"}, {Module: "m", Artifact: "go", From: "0.2", To: "0.3"}} + }, []string{"m/go", "twice"}}, } for name, c := range cases { v := passVerdict() -- 2.54.0 From 09cf1ea6fc7b706b35dbe7740db9e7218a64a0f1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:54:33 +0100 Subject: [PATCH 065/144] J4 (lead's item): the packages state's series value is a contract its readers take, not the indexer's own type delivery.SeriesPackage and SeriesVersion, field for field as the indexer writes them (name, the ecosystem name; package, /), with SeriesPackageKey, IsSeriesPackageKey and a checked encode and decode. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/published_packages_test.go | 28 +++++++ go/delivery/series_package.go | 105 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 go/delivery/series_package.go diff --git a/go/delivery/published_packages_test.go b/go/delivery/published_packages_test.go index a971dc9..6ae85b6 100644 --- a/go/delivery/published_packages_test.go +++ b/go/delivery/published_packages_test.go @@ -56,6 +56,34 @@ func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) { } } + // The indexer's series value (design 51 *The versions*), as the indexer writes it: read back whole, and refused + // where a reader could not take it. + written := `{"v":1,"name":"` + sdk + `","package":"mesh-sdk/go","series":"0.1","seat":"go-package-registry","go-module":"` + sdk + + `","newest":{"version":"0.1.15","commit":"` + commit + `","digest":"sha256:0f","fingerprint":"fp1","contents":` + string(raw) + + `},"numbered":{"version":"0.1.16","commit":"` + commit + `"},"at":"2026-11-04T14:00:00Z"}` + series, err := DecodeSeriesPackage([]byte(written)) + if err != nil || series.Name != sdk || series.Package != "mesh-sdk/go" || series.Newest.Contents == nil || series.Numbered.Version != "0.1.16" { + t.Fatalf("a series value as the indexer writes it reads %+v, %v", series, err) + } + if again, err := EncodeSeriesPackage(series); err != nil || string(again) != written { + t.Fatalf("a series value writes back as\n%s, %v, not\n%s", again, err, written) + } + if key := SeriesPackageKey(sdk, "0.1"); key != "git=2enovox=2ebe/novox/mesh-sdk/go.0.1" || !IsSeriesPackageKey(key) || IsSeriesPackageKey(EscapeName(sdk)) { + t.Fatalf("the series key %q is not told apart from a per-module key", key) + } + for name, mutate := range map[string]func(s *SeriesPackage){ + "a series with no name": func(s *SeriesPackage) { s.Name = "" }, + "a series that is no series": func(s *SeriesPackage) { s.Series = "0.1.15" }, + "built from no artifact": func(s *SeriesPackage) { s.Package = "mesh-sdk" }, + "a numbered version of nothing": func(s *SeriesPackage) { s.Numbered = &SeriesVersion{} }, + } { + s := series + mutate(&s) + if _, err := EncodeSeriesPackage(s); err == nil { + t.Errorf("%s: encoded", name) + } + } + imports := []string{sdk, sdk + "/announce"} outOfStep := []string{"go/seats/meshbroker/contract.go", "ts/seats/meshbroker/src/index.ts"} end := BuildLogEntry{Event: BuildEndedEvent, Module: "netcheck", FP: "src2:x", Repository: "novox/mesh-catalog", Commit: commit, diff --git a/go/delivery/series_package.go b/go/delivery/series_package.go new file mode 100644 index 0000000..98ad84a --- /dev/null +++ b/go/delivery/series_package.go @@ -0,0 +1,105 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "time" +) + +// The packages state per series (design 51 *The versions*, design 32; ADR 0606 §1, ADR 0609 §1): beside the per-module +// values (PublishedPackages), the indexer keeps one key per package and series, `.` (SeriesPackageKey), +// holding the newest trunk version of that series the builder numbered and the newest a registry seat published. The +// indexer alone writes it; its readers (the builder, the checker, the build modules) take this value's form from here. +// A series key always holds a dot, which EscapeName never leaves in a per-module key (IsSeriesPackageKey). + +// SeriesPackage is the value of a series key. Name is the package's name in its own ecosystem (a Go package's module +// path in its go.mod at the commit, an npm package's package.json name): the key is named by it, and a user's +// `packages` names it. Package is the module and artifact it is built from, `/`, by which the builder +// finds the name to number under; Series its `.`. Seat is the registry seat it is published through; +// GoModule the Go module path a Go package is fetched under, empty for any other. Newest is the newest version of the +// series a seat published (what a user accepting the series builds against), Numbered the newest the builder numbered, +// published or not yet. +type SeriesPackage struct { + V int `json:"v"` + Name string `json:"name"` + Package string `json:"package,omitempty"` + Series string `json:"series"` + Seat string `json:"seat,omitempty"` + GoModule string `json:"go-module,omitempty"` + Newest *SeriesVersion `json:"newest,omitempty"` + Numbered *SeriesVersion `json:"numbered,omitempty"` + At time.Time `json:"at"` +} + +// SeriesVersion is one version of a series: its number, the source commit it was built from, the digest and +// fingerprint. Contents are, for a series' newest of a Go package, its Go module's requirements' and packages' digests +// at Commit: what a user accepting the series is fingerprinted against; nil when the forge could not list it, and on a +// numbered version. +type SeriesVersion struct { + Version string `json:"version"` + Commit string `json:"commit,omitempty"` + Digest string `json:"digest,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Contents *PublishedPackages `json:"contents,omitempty"` +} + +// SeriesPackageKey is the key of a package's series in the packages state: its ecosystem name as a key part and the +// series. +func SeriesPackageKey(name, series string) string { return EscapeName(name) + "." + series } + +// IsSeriesPackageKey says whether a key of the packages state is a series key, not a per-module one. +func IsSeriesPackageKey(key string) bool { return strings.Contains(key, ".") } + +// builtFrom says whether pkg is `/`: a module name (no slash) and an artifact named after it. +func builtFrom(pkg string) bool { + module, artifact, ok := strings.Cut(pkg, "/") + return ok && module != "" && artifact != "" +} + +var seriesForm = regexp.MustCompile(`^[0-9]+\.[0-9]+$`) + +func (s SeriesPackage) check() error { + switch { + case s.Name == "": + return fmt.Errorf("delivery: a series of %q names no package in its ecosystem", s.Package) + case !seriesForm.MatchString(s.Series): + return fmt.Errorf("delivery: the series %q of %s is no .", s.Series, s.Name) + case s.Package != "" && !builtFrom(s.Package): + return fmt.Errorf("delivery: the series %s of %s is built from %q, which is no /", s.Series, s.Name, s.Package) + } + for which, v := range map[string]*SeriesVersion{"newest": s.Newest, "numbered": s.Numbered} { + if v != nil && v.Version == "" { + return fmt.Errorf("delivery: the %s version of %s %s names no version", which, s.Name, s.Series) + } + if v != nil && v.Contents != nil { + if err := v.Contents.check(); err != nil { + return err + } + } + } + return nil +} + +// EncodeSeriesPackage is s as JSON at Version; it refuses what a reader could not take. +func EncodeSeriesPackage(s SeriesPackage) ([]byte, error) { + if err := s.check(); err != nil { + return nil, err + } + s.V = Version + return json.Marshal(s) +} + +// DecodeSeriesPackage reads a series key's value; it refuses an unknown version (ErrUnknownVersion) and what +// EncodeSeriesPackage refuses. +func DecodeSeriesPackage(data []byte) (SeriesPackage, error) { + var s SeriesPackage + if err := decode(data, &s); err != nil { + return SeriesPackage{}, err + } + if err := s.check(); err != nil { + return SeriesPackage{}, err + } + return s, nil +} -- 2.54.0 From c974a11c9795b20f45f22ad1a43381e3516f8801 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:58:14 +0100 Subject: [PATCH 066/144] J3 pick for A3p: delivery.VersionNumbered and BuildAnswer.OutOfStep as join-mesh-sdk 712b21b has them Only version_numbered.go and stepasks.go of 712b21b, so mesh-build-go builds against them before the join; identical to the join's, dropped there. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/stepasks.go | 4 +++ go/delivery/version_numbered.go | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 go/delivery/version_numbered.go diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index 327e87d..753f50d 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -583,6 +583,10 @@ type BuildAnswer struct { // once (novox/hq ADR 0593 §2): a Go build module reports them, the builder carries them onto the build's end // (BuildLogEntry.Imports). Absent from a build module that reports none. Imports []string `json:"imports,omitempty"` + // OutOfStep are the generated files the build's compare found out of step with what their generator writes (the test + // step's `out-of-step`, answered by a trunk build's ask too): the builder carries them onto the build's end + // (BuildLogEntry.OutOfStep). Absent when none is, or from a build module that compares none. + OutOfStep []string `json:"out-of-step,omitempty"` } // AskBuilt is one artifact a build ask built. diff --git a/go/delivery/version_numbered.go b/go/delivery/version_numbered.go new file mode 100644 index 0000000..634cb84 --- /dev/null +++ b/go/delivery/version_numbered.go @@ -0,0 +1,57 @@ +package delivery + +import ( + "encoding/json" + "fmt" +) + +// The builder's event version.numbered (design 51 *Events of the builder*, *The floor*): said once per version the builder +// numbers, on its own family `version` (seven days), apart from its builds' ends, so a waiter on a build's end does not +// wake on it. A build module consumes it to publish a build's version with the held digest, which stores nothing. +const ( + // VersionNumberedEvent is the event's local name. + VersionNumberedEvent = "version.numbered" + // VersionNumberedSubject is its subject, as a consumer's filter names it. + VersionNumberedSubject = "mesh.mod." + BuilderModule + ".event." + VersionNumberedEvent +) + +// VersionNumbered is the body of VersionNumberedEvent: a numbered version of a name, the build it was numbered for, and +// what the build module needs to publish it. +type VersionNumbered struct { + // Name is the numbered name, as builder_versions lists it; Kind its artifact's kind. + Name string `json:"name"` + Kind string `json:"kind"` + Version string `json:"version"` + Tag string `json:"tag,omitempty"` + // Module and Fingerprint are the build the version was numbered for; Commit its source commit. + Module string `json:"module"` + Fingerprint string `json:"fingerprint"` + Commit string `json:"commit,omitempty"` + // Digest is the built artifact's, when the build held one; Seat the registry seat it is published through. + Digest string `json:"digest,omitempty"` + Seat string `json:"seat,omitempty"` + // Head is the head the version answers, when it answers a tag ask. + Head string `json:"head,omitempty"` + // Again is when builder_publish_again said it once more, so the bus's dedup does not drop it. + Again string `json:"again,omitempty"` +} + +// EncodeVersionNumbered writes the body, refusing one that names no name, version, module or fingerprint. +func EncodeVersionNumbered(n VersionNumbered) ([]byte, error) { + if n.Name == "" || n.Version == "" || n.Module == "" || n.Fingerprint == "" { + return nil, fmt.Errorf("delivery: %s names its name, version, module and fingerprint", VersionNumberedEvent) + } + return json.Marshal(n) +} + +// DecodeVersionNumbered reads the body, with the same refusal. +func DecodeVersionNumbered(data []byte) (VersionNumbered, error) { + var n VersionNumbered + if err := json.Unmarshal(data, &n); err != nil { + return VersionNumbered{}, fmt.Errorf("delivery: %s is not readable: %w", VersionNumberedEvent, err) + } + if _, err := EncodeVersionNumbered(n); err != nil { + return VersionNumbered{}, err + } + return n, nil +} -- 2.54.0 From 7eba326d3d5fea59687df08a001559d8eac63bc8 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 15:40:49 +0100 Subject: [PATCH 067/144] J7 (2): a judge request carries the known packages in a field of its own The names a manifest's packages may name travelled in the request's Input, whose meaning is the composition's input; Packages says them plainly, nil being no list and an empty list a list. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/composer/composer.go | 5 +++++ go/composer/composer_test.go | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/go/composer/composer.go b/go/composer/composer.go index dd224cf..8a33b1a 100644 --- a/go/composer/composer.go +++ b/go/composer/composer.go @@ -59,6 +59,11 @@ type Request struct { // longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts // come in each case's Verdicts. Kept so an asker that still sends it is read. Predefined catalogue.PredefinedList `json:"predefined,omitempty"` + // Packages are the known packages a manifest's `packages` may name, each by its name in its own ecosystem (design 32: + // a Go package's go.mod module path, a TypeScript package's package.json name), for the indexer's judge (novox/hq ADR + // 0578 §5, ADR 0606 §4); the composer does not read them. Nil is no list at hand, and the judge judges no name; an + // empty list is a list, and every name is unknown to it. + Packages []string `json:"packages,omitzero"` } // Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs. diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go index 108e3c7..9ee1c40 100644 --- a/go/composer/composer_test.go +++ b/go/composer/composer_test.go @@ -39,6 +39,18 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo { t.Fatalf("filled %d times: %+v", asked, back.Cases[0]) } + // The known packages: none is no list (not on the wire), an empty list is a list, and a list reads back whole. + for _, known := range [][]string{nil, {}, {"git.novox.be/novox/mesh-sdk", "@novox/mesh-sdk"}} { + wire, err := json.Marshal(Request{V: Version, Packages: known}) + if err != nil { + t.Fatal(err) + } + back, err := Decode(wire) + if err != nil || (back.Packages == nil) != (known == nil) || strings.Join(back.Packages, ",") != strings.Join(known, ",") || + known == nil && strings.Contains(string(wire), "packages") { + t.Errorf("the known packages %#v went as %s and read back %#v (%v)", known, wire, back.Packages, err) + } + } bad := Request{V: Version, Cases: []Case{{Name: "c", Manifests: map[string]Manifest{"x": {Digest: "nope"}}}}} if err := Fill(&bad, func(string) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") { t.Fatalf("a digest the indexer does not hold: %v", err) -- 2.54.0 From 64e83c7ee2dc89f2727846b0cf58127c059bb18d Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 15:58:59 +0100 Subject: [PATCH 068/144] J8b: catalogue reads the holder's upstream naming, and a done key may name an upstream copy by it An upstream copy kept by the artifact store is named by the holder, upstream//@, in the seat form (container-image-registry/upstream/...) and the older one (artifact-store://upstream/...). InRegistrySeat reads it, InUpstream reads its host and path, and referenceProblem accepts it for an upstream artifact, so readers stop reading it on their own. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/artifacts.go | 32 ++++++++++++++++++++++++++- go/catalogue/artifacts_test.go | 18 +++++++++++++++ go/delivery/builder.go | 10 +++++++++ go/delivery/builder_reference_test.go | 7 ++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/go/catalogue/artifacts.go b/go/catalogue/artifacts.go index 6bea900..42d0b99 100644 --- a/go/catalogue/artifacts.go +++ b/go/catalogue/artifacts.go @@ -89,9 +89,18 @@ func SeatReference(seat, module, artifact, digest string) string { // InRegistrySeat reads a reference in the form a registry seat answers, `//@sha256:`: // the seat it came through and the rest. A seat's name holds no dot or port, so no image of an outside registry reads // as one, and a kept `artifact-store://` reference is not one. +// +// It also reads the holder's own naming of an upstream copy through container-image-registry, +// `container-image-registry/upstream//@sha256:`, whose rest InUpstream reads. func InRegistrySeat(reference string) (seat, path string, ok bool) { first, rest, found := strings.Cut(reference, "/") - if !found || !strings.Contains(rest, "@sha256:") || strings.Count(rest, "/") != 1 { + if !found || !strings.Contains(rest, "@sha256:") { + return "", "", false + } + if _, _, upstream := InUpstream(rest); upstream && first == ContainerImageRegistrySeat { + return first, rest, true + } + if strings.Count(rest, "/") != 1 { return "", "", false } for _, s := range RegistrySeats { @@ -102,6 +111,27 @@ func InRegistrySeat(reference string) (seat, path string, ok bool) { return "", "", false } +// UpstreamPrefix opens the holder's own name for an upstream copy it keeps: `upstream//@`, after +// `container-image-registry/` in the seat's form and after `artifact-store://` in the older one. +const UpstreamPrefix = "upstream/" + +// InUpstream reads a path in the holder's naming of an upstream copy, `upstream//@sha256:` (the path +// InRegistrySeat or InArtifactStore gives back): the upstream registry host and the image's path there, with its digest. +// A host is read as the runtime reads one (a dot, a port, or localhost). +func InUpstream(path string) (host, image string, ok bool) { + rest, found := strings.CutPrefix(path, UpstreamPrefix) + if !found { + return "", "", false + } + host, image, found = strings.Cut(rest, "/") + name, digest, pinned := strings.Cut(image, "@") + if !found || !isRegistryHost(host) || name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || + strings.Contains(name, "//") || !pinned || !strings.HasPrefix(digest, "sha256:") || strings.Contains(digest, "/") { + return "", "", false + } + return host, image, true +} + // Routed is a kept reference as a machine fetches it, through the artifact store at `address` // (host:port). A reference that is not a kept one is what it was. func Routed(reference, address string) string { diff --git a/go/catalogue/artifacts_test.go b/go/catalogue/artifacts_test.go index 9ff124e..252ca22 100644 --- a/go/catalogue/artifacts_test.go +++ b/go/catalogue/artifacts_test.go @@ -37,6 +37,9 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) { "oci-artifact-registry/gitea/config@" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest, "registry.example/gitea/server@" + digest: "registry.example/gitea/server@" + digest, "mesh-artifact-store/gitea/server@" + digest: "mesh-artifact-store/gitea/server@" + digest, + // An upstream copy under the holder's own naming, in both forms. + "container-image-registry/upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest, + ArtifactStoreScheme + "upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest, } { if got := Routed(reference, "10.0.0.1:5000"); got != want { t.Errorf("Routed(%q) = %q, want %q", reference, got, want) @@ -49,4 +52,19 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) { t.Errorf("SeatReference(%s) = %q, read back as %q %q %v", seat, ref, got, path, ok) } } + // The holder's naming of an upstream copy reads through the image seat only, and InUpstream reads its host and path. + for reference, want := range map[string]bool{ + "container-image-registry/upstream/quay.io:443/org/app@" + digest: true, + "oci-artifact-registry/upstream/quay.io:443/org/app@" + digest: false, + "container-image-registry/upstream/org/app@" + digest: false, + "container-image-registry/upstream/quay.io/app": false, + } { + _, path, ok := InRegistrySeat(reference) + if ok != want { + t.Errorf("InRegistrySeat(%q) read %v, want %v", reference, ok, want) + } + if host, image, up := InUpstream(path); ok && (!up || host != "quay.io:443" || image != "org/app@"+digest) { + t.Errorf("InUpstream(%q) = %q %q %v", path, host, image, up) + } + } } diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 32bf4c1..2c1439d 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -103,6 +103,16 @@ func (a BuiltArtifact) referenceProblem() string { if r == "" || a.Kind == "" { return "" } + // An upstream copy may be named by the holder's own naming, upstream//@, in either form: the + // holder names it, not the module that asked for it (catalogue.InUpstream). + if path, kept := catalogue.InArtifactStore(r); kept && a.Kind == "upstream" { + if _, image, ok := catalogue.InUpstream(path); ok { + if _, digest, _ := strings.Cut(image, "@"); digest != a.Digest { + return "does not name its digest " + a.Digest + } + return "" + } + } if seat, path, ok := catalogue.InRegistrySeat(r); ok { want := catalogue.ContainerImageRegistrySeat if a.Kind == "bundle" || a.Kind == "archive" { diff --git a/go/delivery/builder_reference_test.go b/go/delivery/builder_reference_test.go index d810b16..db1ef2a 100644 --- a/go/delivery/builder_reference_test.go +++ b/go/delivery/builder_reference_test.go @@ -29,6 +29,9 @@ func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) { artifactOf("files", "archive", d, "oci-artifact-registry/mail/files@"+d), artifactOf("server", "image", d, "container-image-registry/mail/server@"+d), artifactOf("nats", "upstream", d, "container-image-registry/nats/nats@"+d), + // An upstream copy under the holder's own naming, upstream//, in either form (ADR 0624 §1). + artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+d), + artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+d), } for _, a := range good { b := doneBuild() @@ -53,6 +56,10 @@ func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) { artifactOf("server", "image", d, "container-image-registry/mail/server@"+other), artifactOf("server", "image", d, "container-image-registry/mail/other@"+d), artifactOf("lib", "package", d, "container-image-registry/mail/lib@"+d), + artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+other), + artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+other), + artifactOf("app", "upstream", d, "oci-artifact-registry/upstream/quay.io:443/org/app@"+d), + artifactOf("server", "image", d, "container-image-registry/upstream/quay.io:443/org/server@"+d), } for _, a := range bad { b := doneBuild() -- 2.54.0 From c4de50e91e1125f5823f6a6acb6fa34c3851ac6e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:21:05 +0100 Subject: [PATCH 069/144] R1-F-sdk 1, 3: no test imports a seat's contract module, and the guard reads test imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import guard skipped _test.go files, so holdings_test.go's import of the mesh-broker seat would have reached mesh-sdk's go.mod at the pin (ADR 0625 §1, §3). The vault entries' comment says they stay to the next milestone (issue 1966 note 28). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/holdings_test.go | 12 ++++++++---- go/packages_list_test.go | 21 ++++++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/go/catalogue/holdings_test.go b/go/catalogue/holdings_test.go index 790c290..8e79d0a 100644 --- a/go/catalogue/holdings_test.go +++ b/go/catalogue/holdings_test.go @@ -3,8 +3,6 @@ package catalogue import ( "strings" "testing" - - "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" ) // **A seat's holder on record settles who holds it, and lets the next holder stand beside the @@ -20,9 +18,15 @@ func busSeatDelivering(t *testing.T, delivers string) { UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}}) } +// brokerVerbs are the bus seat's verbs as a literal: mesh-sdk imports no seat's contract module, a test neither +// (ADR 0625 §3). +func brokerVerbs() []string { + return []string{"list", "show", "check", "orphans", "create", "apply", "recreate", "drop"} +} + func newBroker() Manifest { return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}, - Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: meshbroker.Verbs()}}} + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: brokerVerbs()}}} } // CanHold is the one judgement registration and the handover share, against the store's row. @@ -43,7 +47,7 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) { t.Fatalf("a claim at the wrong scope was allowed: %v", err) } cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}}, - Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: meshbroker.Verbs()}}} + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: brokerVerbs()}}} if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) { t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err) } diff --git a/go/packages_list_test.go b/go/packages_list_test.go index e69d9b2..cb2ed4d 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -44,8 +44,8 @@ var moduleWord = regexp.MustCompile(`\bmesh-(?:delivery|grants|vault|host|config var notInSDK = []string{"asks", "asker", "publish"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below -// would refuse; it only shrinks, and is empty when the group is pinned: the vault module's name in delivery and -// provisions goes when the vault's answers are its contract's. +// would refuse; it only shrinks. The vault module's name in delivery and provisions stays past this milestone's pin +// (issue 1966 note 28) and goes in the next milestone, when the vault's answers are its contract's. var untilTheJoin = []string{ "delivery/signers.go: mesh-vault", "provisions/provisions.go: mesh-vault", @@ -96,7 +96,22 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } return nil } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + if !strings.HasSuffix(path, ".go") { + return nil + } + // A test's import is a requirement of mesh-sdk's go.mod too (ADR 0625 §1, §3): a test may import the seat set, + // which catalogue already requires, and no other contracts module. + if strings.HasSuffix(path, "_test.go") { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range file.Imports { + p, _ := strconv.Unquote(imp.Path.Value) + if strings.HasPrefix(p, "git.novox.be/novox/mesh-contracts/") && p != contractsSeatSet { + pending = append(pending, filepath.ToSlash(path)+": imports "+p) + } + } return nil } dir := filepath.ToSlash(filepath.Dir(path)) -- 2.54.0 From aef9957450defff22c2acd7d85038406a7f7f21b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:22:18 +0100 Subject: [PATCH 070/144] R1-F-sdk 2: a view event not said is said again before the mark moves past it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed say was logged and passed over, and the next change's mark moved past it, so a reader missed the change for good (design 25 §12.2). The emitter now says the same change again on a backoff until it is said or its context ends. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/emit.go | 107 ++++++++++++++++++++++++++++------------- go/views/views_test.go | 52 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 33 deletions(-) diff --git a/go/views/emit.go b/go/views/emit.go index 23a251a..6077f3f 100644 --- a/go/views/emit.go +++ b/go/views/emit.go @@ -27,8 +27,11 @@ type Emitter struct { // TooLarge is told of a value too large for an event (said as a gap): the owner's finding naming the key. Nil // only logs it. TooLarge func(state, key string, size int) - Logf func(string, ...any) - Now func() time.Time + // Wait pauses before a change not said is said again (its attempt so far); it answers false when ctx ended. Nil is + // a backoff from 250 ms doubling to 30 s. + Wait func(ctx context.Context, attempt int) bool + Logf func(string, ...any) + Now func() time.Time } // JetStreamPublish is Emitter.Publish through js, with the bus's dedup id. @@ -87,43 +90,81 @@ func (e Emitter) Start(ctx context.Context, state string, kv jetstream.KeyValue, if err != nil { return fmt.Errorf("the state %s cannot be watched for its views: %w", state, err) } + fillNow := func() (Fill, error) { + _, _, fill, err := created(ctx, kv) + return fill, err + } go func() { defer w.Stop() - said := uint64(0) - for { - select { - case <-ctx.Done(): - return - case u, ok := <-w.Updates(): - if !ok { - if errs != nil { - errs <- fmt.Errorf("the watch of %s for its views ended", state) - } - return - } - if u != nil { - if err := e.say(ctx, state, made, u); err != nil { - // Not marked: the next start says it again from the mark, and the bus keeps it once by its id. - e.logf("views: %s.%s at %d is not said: %v", state, u.Key(), u.Revision(), err) - continue - } - said = u.Revision() - } - // Marked once every change handed so far is said, so a busy state writes one mark per burst. - if said > 0 && len(w.Updates()) == 0 { - _, _, fill, err := created(ctx, kv) - if err != nil { - e.logf("views: the fill of %s is not read: %v", state, err) - } - e.writeMark(ctx, state, Mark{State: state, Revision: said, Created: made, Fill: fill}) - said = 0 - } - } - } + e.follow(ctx, state, made, w.Updates(), fillNow, errs) }() return nil } +// follow says each change handed on updates in order, and marks once every change handed so far is said. A change +// that fails to be said is said again, the same change on a backoff (Wait), until it is said or ctx ends: the mark +// never moves past a change not said, so no reader misses one (design 25 §12.2). +func (e Emitter) follow(ctx context.Context, state string, made time.Time, updates <-chan jetstream.KeyValueEntry, fillNow func() (Fill, error), errs chan<- error) { + said := uint64(0) + for { + select { + case <-ctx.Done(): + return + case u, ok := <-updates: + if !ok { + if errs != nil { + errs <- fmt.Errorf("the watch of %s for its views ended", state) + } + return + } + if u != nil { + for attempt := 1; ; attempt++ { + err := e.say(ctx, state, made, u) + if err == nil { + break + } + e.logf("views: %s.%s at %d is not said (try %d): %v; said again before the next change", state, u.Key(), u.Revision(), attempt, err) + if !e.wait(ctx, attempt) { + // Not marked: the next start says it again from the mark, and the bus keeps it once by its id. + return + } + } + said = u.Revision() + } + // Marked once every change handed so far is said, so a busy state writes one mark per burst. + if said > 0 && len(updates) == 0 { + fill, err := fillNow() + if err != nil { + e.logf("views: the fill of %s is not read: %v", state, err) + } + e.writeMark(ctx, state, Mark{State: state, Revision: said, Created: made, Fill: fill}) + said = 0 + } + } + } +} + +// wait is the pause before the attempt+1'th try of a change not said: Wait when given, else a backoff from 250 ms +// doubling to 30 s. It answers false once ctx ends. +func (e Emitter) wait(ctx context.Context, attempt int) bool { + if e.Wait != nil { + return e.Wait(ctx, attempt) + } + d := 250 * time.Millisecond + for i := 1; i < attempt && d < 30*time.Second; i++ { + d *= 2 + } + d = min(d, 30*time.Second) + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} + // Event is the event saying u, a change of state made at made. func (e Emitter) Event(state string, made time.Time, u jetstream.KeyValueEntry) Event { ev := Event{V: Version, Owner: e.Owner, State: state, Key: u.Key(), Revision: u.Revision(), Created: made, At: u.Created().UTC()} diff --git a/go/views/views_test.go b/go/views/views_test.go index e1a990f..3cd0ace 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -9,6 +9,9 @@ import ( "testing" "time" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/handacts" ) @@ -396,6 +399,40 @@ func TestViews(t *testing.T) { } } }) + t.Run("a change not said is said again on a backoff before the next, and the mark never passes it", func(t *testing.T) { + var said []string + fails := 2 + em := Emitter{Owner: "o", Subject: func(e string) string { return "s." + e }, + Publish: func(_ context.Context, msg *nats.Msg, id string) error { + if fails > 0 && strings.HasSuffix(id, "-1") { + fails-- + return errors.New("bus away") + } + said = append(said, id) + return nil + }, + Wait: func(_ context.Context, attempt int) bool { return true }} + updates := make(chan jetstream.KeyValueEntry, 2) + updates <- kvEntry{key: "a", rev: 1, at: made} + updates <- kvEntry{key: "b", rev: 2, at: made} + close(updates) + errs := make(chan error, 1) + em.follow(context.Background(), "s", made, updates, func() (Fill, error) { return Fill{}, nil }, errs) + if len(said) != 2 || !strings.HasSuffix(said[0], "-1") || !strings.HasSuffix(said[1], "-2") || fails != 0 { + t.Fatalf("said %v (fails left %d): the change at 1 must be said, again, before the one at 2", said, fails) + } + // A context that ends while a change waits to be said again stops there: nothing after it is said. + said, fails = nil, 1 + ctx, cancel := context.WithCancel(context.Background()) + em.Wait = func(context.Context, int) bool { cancel(); return false } + updates = make(chan jetstream.KeyValueEntry, 2) + updates <- kvEntry{key: "a", rev: 1, at: made} + updates <- kvEntry{key: "b", rev: 2, at: made} + em.follow(ctx, "s", made, updates, func() (Fill, error) { return Fill{}, nil }, errs) + if len(said) != 0 { + t.Fatalf("said %v after the context ended on a change not said", said) + } + }) t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) { if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { t.Errorf("state of subject = %q", s) @@ -427,3 +464,18 @@ func (f *findingsMap) Put(k string, v []byte) error { } func (f *findingsMap) Delete(k string) error { delete(f.m, k); f.deletes++; return nil } + +// kvEntry is one change of a bucket as a watch hands it. +type kvEntry struct { + key string + rev uint64 + at time.Time +} + +func (k kvEntry) Bucket() string { return "b" } +func (k kvEntry) Key() string { return k.key } +func (k kvEntry) Value() []byte { return []byte(`{"n":1}`) } +func (k kvEntry) Revision() uint64 { return k.rev } +func (k kvEntry) Created() time.Time { return k.at } +func (k kvEntry) Delta() uint64 { return 0 } +func (k kvEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut } -- 2.54.0 From 5e67b548a0cd5d8e18880f8bf7d2420ba30adc2b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:24:02 +0100 Subject: [PATCH 071/144] R1-F-sdk 4: one rule every window keeper steps by, and a read naming no node closes none distribution's two windows and the builder's old-form window each closed for good on a read that named no node (an empty node_haves, an unread placement): the second of a kind (security-upgrade review F3). delivery.StepWindow over delivery.WindowHeld is the one rule both call; a read naming no node judges nothing, as a first view's empty answer at revision 0 (ADR 0615). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/delivery/window.go | 64 ++++++++++++++++++++++++++++++++++++++ go/delivery/window_test.go | 41 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 go/delivery/window.go create mode 100644 go/delivery/window_test.go diff --git a/go/delivery/window.go b/go/delivery/window.go new file mode 100644 index 0000000..d5c7a00 --- /dev/null +++ b/go/delivery/window.go @@ -0,0 +1,64 @@ +package delivery + +import ( + "slices" + "sort" + "time" +) + +// WindowHeld is the part of a window every window keeper shares (design 51: distribution's old seat name and direct +// writes, the builder's old reference form): whether it is open, the nodes holding it and since when, and when it last +// closed and who held it then. A keeper embeds it in its own record, so its JSON is the record's. +type WindowHeld struct { + Open bool `json:"open"` + Holding []string `json:"holding,omitempty"` + Since map[string]time.Time `json:"since,omitempty"` + Closed time.Time `json:"closed,omitzero"` + // LastHolding are the nodes that held it last, said when it closes. + LastHolding []string `json:"last_holding,omitempty"` +} + +// StepWindow is the one rule every window keeper steps its window by (security-upgrade review F3, the second of a +// kind): named is every node the read named, dropped ones included, and holding those of them that hold the window at +// now. A read that names no node judges nothing, as a first view's empty answer at revision 0 is no empty state (ADR +// 0615): an empty node_haves or a placement not yet read never closes a window, and the window stays as it was. A read +// naming nodes, none holding, closes an open window (closed true, once); one naming a holder opens it, each holder's +// since kept from w. overdue are the holders whose since is at least findingAfter before now (the keeper's finding, +// cleared for every other node); a read naming no node keeps w's holders' overdue as they stand at now. +func StepWindow(w WindowHeld, named, holding []string, now time.Time, findingAfter time.Duration) (next WindowHeld, closed bool, overdue []string) { + now = now.UTC() + if len(named) == 0 { + next = w + for _, n := range w.Holding { + if since, ok := w.Since[n]; ok && now.Sub(since) >= findingAfter { + overdue = append(overdue, n) + } + } + return next, false, overdue + } + if len(holding) == 0 { + next = WindowHeld{Closed: w.Closed, LastHolding: w.LastHolding} + if w.Open { + next.Closed, next.LastHolding = now, slices.Clone(w.Holding) + if next.LastHolding == nil { + next.LastHolding = []string{} + } + return next, true, nil + } + return next, false, nil + } + held := slices.Clone(holding) + sort.Strings(held) + next = WindowHeld{Open: true, Holding: held, Since: map[string]time.Time{}, Closed: w.Closed, LastHolding: w.LastHolding} + for _, n := range held { + since, had := w.Since[n] + if !had { + since = now + } + next.Since[n] = since + if now.Sub(since) >= findingAfter { + overdue = append(overdue, n) + } + } + return next, false, overdue +} diff --git a/go/delivery/window_test.go b/go/delivery/window_test.go new file mode 100644 index 0000000..8fd1ade --- /dev/null +++ b/go/delivery/window_test.go @@ -0,0 +1,41 @@ +package delivery + +import ( + "slices" + "testing" + "time" +) + +// The one rule every window keeper steps by (design 51; security-upgrade review F3): a read naming no node judges +// nothing, so an empty node_haves or an unread placement never closes a window. +func TestStepWindow(t *testing.T) { + now := time.Date(2026, 11, 4, 17, 0, 0, 0, time.UTC) + week := 7 * 24 * time.Hour + long := now.Add(-8 * 24 * time.Hour) + open := WindowHeld{Open: true, Holding: []string{"n1"}, Since: map[string]time.Time{"n1": long}} + for _, c := range []struct { + name string + w WindowHeld + named, holding []string + open, closed bool + holds, overdue []string + }{ + {"a read naming no node leaves an open window open, its holder still overdue", open, nil, nil, true, false, []string{"n1"}, []string{"n1"}}, + {"a read naming no node leaves a closed window closed", WindowHeld{Closed: long}, nil, nil, false, false, nil, nil}, + {"a read naming nodes, none holding, closes an open window once", open, []string{"n1", "n2"}, nil, false, true, nil, nil}, + {"a closed window a read finds unheld stays closed, said no more", WindowHeld{Closed: long, LastHolding: []string{"n1"}}, []string{"n1"}, nil, false, false, nil, nil}, + {"a new holder opens it, since now, not overdue", WindowHeld{}, []string{"n1", "n2"}, []string{"n2"}, true, false, []string{"n2"}, nil}, + {"a holder keeps its since and is overdue past the bound", open, []string{"n1", "n2"}, []string{"n2", "n1"}, true, false, []string{"n1", "n2"}, []string{"n1"}}, + } { + next, closed, overdue := StepWindow(c.w, c.named, c.holding, now, week) + if next.Open != c.open || closed != c.closed || !slices.Equal(next.Holding, c.holds) || !slices.Equal(overdue, c.overdue) { + t.Errorf("%s: open %v closed %v holding %v overdue %v", c.name, next.Open, closed, next.Holding, overdue) + } + if closed && (!next.Closed.Equal(now) || !slices.Equal(next.LastHolding, c.w.Holding)) { + t.Errorf("%s: closed at %v, last held by %v", c.name, next.Closed, next.LastHolding) + } + if !c.open && !closed && !next.Closed.Equal(c.w.Closed) { + t.Errorf("%s: its last close moved to %v", c.name, next.Closed) + } + } +} -- 2.54.0 From eb2978b3ce360fed4824aefe84a2553fd6a0b259 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:58:09 +0100 Subject: [PATCH 072/144] R1-F-sdk 5: catalogue.Decode refuses a manifest giving one field twice, naming its path A decoder keeps the last of two equal keys without a word, so the indexer's judge read a manifest a merge of two branches had given one field twice as if one side never was. mesh-catalog's checks/manifests refuses it already; Decode now refuses it the same way, at any depth. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/decode.go | 54 +++++++++++++++++++++- go/catalogue/optional_requirements_test.go | 17 +++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/go/catalogue/decode.go b/go/catalogue/decode.go index b362814..78bc229 100644 --- a/go/catalogue/decode.go +++ b/go/catalogue/decode.go @@ -1,7 +1,9 @@ package catalogue import ( + "bytes" "encoding/json" + "fmt" "strings" ) @@ -9,15 +11,65 @@ import ( // contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It // judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its // verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it -// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape. +// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, a field of the wrong shape, or one field +// given twice in one object at any depth, named by its path: a decoder keeps the last of two equal keys without a word, +// so a merge of two branches that each added the field would lose one side silently (as mesh-catalog's checks/manifests +// refuses it). func Decode(raw []byte) (Manifest, error) { var m Manifest if err := json.Unmarshal(raw, &m); err != nil { return Manifest{}, err } + if twice := givenTwice(raw); twice != "" { + return Manifest{}, fmt.Errorf("the field %s is given twice; a manifest gives each field once", twice) + } return m, nil } +// givenTwice is the first field an object of raw gives twice, as its path (`invokes`, `build.artifacts[0].env`), or empty. +func givenTwice(raw []byte) string { + d := json.NewDecoder(bytes.NewReader(raw)) + var walk func(path string) string + walk = func(path string) string { + t, err := d.Token() + if err != nil { + return "" + } + switch t { + case json.Delim('{'): + seen := map[string]bool{} + for d.More() { + k, err := d.Token() + if err != nil { + return "" + } + key, _ := k.(string) + at := key + if path != "" { + at = path + "." + key + } + if seen[key] { + return at + } + seen[key] = true + if twice := walk(at); twice != "" { + return twice + } + } + _, _ = d.Token() + case json.Delim('['): + for i := 0; d.More(); i++ { + if twice := walk(fmt.Sprintf("%s[%d]", path, i)); twice != "" { + return twice + } + } + _, _ = d.Token() + } + return "" + } + return walk("") +} + // SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first // and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words // (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6). diff --git a/go/catalogue/optional_requirements_test.go b/go/catalogue/optional_requirements_test.go index d6d9cb3..2f6bcb8 100644 --- a/go/catalogue/optional_requirements_test.go +++ b/go/catalogue/optional_requirements_test.go @@ -64,21 +64,32 @@ func TestARequirementsLongForm(t *testing.T) { names string busState []BusStateEntry aside []string + refused string }{ // 1966/AC1: a plain name and a bus-state entry: Requires names the plain one only, since it names provisions, // and BusState holds the entry whole. {"a plain name and a bus-state entry", `["postgres-database", {"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}}]`, - "postgres-database", []BusStateEntry{records}, nil}, + "postgres-database", []BusStateEntry{records}, nil, ""}, // 1966/AC2: an unknown key is set aside and named, never refused; the entry decodes. {"a bus-state entry with a key it does not know", `[ {"name":"records","storage":"ledger","template":"ledger","colour":"red","options":{"max-mib":1024}}]`, - "", []BusStateEntry{records}, []string{"requires[0].colour"}}, + "", []BusStateEntry{records}, []string{"requires[0].colour"}, ""}, // 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged. {"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}, - "types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil}, + "types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""}, + // A field given twice at any depth is refused, named by its path: a decoder would keep the last without a word. + {"a field given twice", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"}, + {"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`, + "", nil, nil, "the field requires[0].name is given twice"}, } { got, err := decodeForTest([]byte(`{"module":"x","version":"1","requires":` + c.requires + `}`)) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("%s: read as %+v, %v; want refused: %s", c.name, got, err, c.refused) + } + continue + } if err != nil { t.Errorf("%s: refused: %v", c.name, err) continue -- 2.54.0 From 719956b7451a1f7e472f5e5d36be2358b79bb955 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 17:24:55 +0100 Subject: [PATCH 073/144] R1-F-parts: the receiving side keeps a transfer's idle key and reads its cap from the binding distribution and gitea each carried the same idle bound (one expiring key per open transfer, its end dropping a transfer idle for 24 hours or arming it again for the rest) and the same read of incoming's cap from its binding: the second instance of the kind, so it moves to the Receiver every holder shares. Arm is called on each open that leaves a transfer open, a key not written failing it loudly; Expired handles a key's end; Bound reads the binding's cap over Cap. BusArm, BusCap and FollowIdle bind them to the holder's own state and objects. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/parts/bus.go | 53 +++++++++++++++++++++++++++ go/parts/receive.go | 77 ++++++++++++++++++++++++++++++++++------ go/parts/receive_test.go | 36 +++++++++++++++++++ 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/go/parts/bus.go b/go/parts/bus.go index a9327fc..13f67cf 100644 --- a/go/parts/bus.go +++ b/go/parts/bus.go @@ -3,6 +3,9 @@ package parts import ( "encoding/json" "errors" + "fmt" + "sync/atomic" + "time" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/busstate" @@ -63,3 +66,53 @@ func (b busTransfers[M]) List() ([]Transfer[M], error) { } return out, nil } + +// BusCap is a Receiver's Bound over the holder's own object store of that name: the cap its binding carries (the bus's +// own bound, the store's max bytes), read through the runtime once it answers one, since a binding's cap does not +// change while the bundle runs. Make it once per bundle, not per receiver. +func BusCap(local string) func() (uint64, error) { + var known atomic.Uint64 + return func() (uint64, error) { + if c := known.Load(); c > 0 { + return c, nil + } + f, err := busstate.ObjectsOf(local).Fill() + if err != nil { + return 0, fmt.Errorf("%s's cap cannot be read from its binding: %w", local, err) + } + if f.MaxBytes == 0 { + return 0, fmt.Errorf("%s's binding carries no cap", local) + } + known.Store(f.MaxBytes) + return f.MaxBytes, nil + } +} + +// BusArm is a Receiver's Arm over the holder's own key-value state of that name, declared key-ttl: one key per open +// transfer with its own time to live; a key already there is the same bound, kept. +func BusArm(state string) func(id string, ttl time.Duration) error { + return func(id string, ttl time.Duration) error { + if _, err := stdio.State(state).CreateTTL(id, map[string]any{"transfer": id}, ttl); err != nil && !errors.Is(err, stdio.ErrChanged) { + return err + } + return nil + } +} + +// FollowIdle follows the holder's idle state (the one BusArm writes) once the bundle serves, and hands the end of each +// key (the bus's marker, OpExpire) to expired, a Receiver's Expired: the event drops an idle transfer, no timer runs. +// said is told each outcome (dropped, or the error that stopped it) for the holder's log. It blocks; run it in its own +// goroutine. +func FollowIdle(state string, expired func(id string) (bool, error), said func(id string, dropped bool, err error)) { + <-stdio.Served() + stdio.State(state).Follow("", func(ch stdio.StateChange) error { + if ch.Op != stdio.OpExpire { + return nil + } + dropped, err := expired(ch.Key) + if said != nil && (dropped || err != nil) { + said(ch.Key, dropped, err) + } + return err + }) +} diff --git a/go/parts/receive.go b/go/parts/receive.go index cdb6b6e..c77bcfe 100644 --- a/go/parts/receive.go +++ b/go/parts/receive.go @@ -2,7 +2,7 @@ package parts // The receiving side of a file in parts (novox/hq ADR 0611, ADR 0627 §2, design 51 *Publishing*): a holder opens a // transfer naming every part, takes the chunks of the parts it lacks into its own objects, checks each whole part's -// hash, drops a transfer left idle and says when its objects fill. Like the sender it names no seat, no verb and no +// hash, drops a transfer left idle when its idle bound ends and says when its objects fill. Like the sender it names no seat, no verb and no // seat's type: the caller's own shape of an opening rides along as M, its objects and its state are its own, and the // checks before storing, the storing and the saying of a refusal are the caller's functions. Each holder binds it to // the seat it holds. @@ -106,8 +106,12 @@ type Receiver[M any] struct { Transfers Transfers[M] // Prefix starts the name of every chunk object, so two receivers can share one object store. Prefix string - // Cap is the bytes the objects may hold; an opening that would pass it is refused. Required. + // Cap is the bytes the objects may hold; an opening that would pass it is refused. Required: it is said where + // Bound answers no cap. Cap uint64 + // Bound reads the cap the objects' binding carries (BusCap): the bus's own bound, which then counts over Cap. + // Optional; an error or a zero falls back to Cap. + Bound func() (uint64, error) // MaxChunk, Idle and Full override MaxReceived, IdleAfter and FullPercent when set. MaxChunk int Idle time.Duration @@ -125,7 +129,11 @@ type Receiver[M any] struct { Refuse func(t Transfer[M], reason string) error // Filled is told the objects' fill after every change: used, the cap and whether it is past Full percent. Optional. Filled func(used, cap uint64, full bool) - Now func() time.Time + // Arm writes a transfer's idle key with its own time to live, once (BusArm over a state declared key-ttl); one + // already there is no fault. The key's end is the event that calls Expired, so an open transfer left with no + // chunk is dropped without a timer. Optional: nil arms nothing, and only DropIdle drops. + Arm func(id string, ttl time.Duration) error + Now func() time.Time } func (r *Receiver[M]) now() time.Time { @@ -149,6 +157,16 @@ func (r *Receiver[M]) idle() time.Duration { return IdleAfter } +// CapNow is the cap the receiver counts against: the binding's (Bound) when it answers one, else Cap. +func (r *Receiver[M]) CapNow() uint64 { + if r.Bound != nil { + if c, err := r.Bound(); err == nil && c > 0 { + return c + } + } + return r.Cap +} + func (r *Receiver[M]) fullPct() uint64 { if r.Full > 0 { return r.Full @@ -177,7 +195,7 @@ func (r *Receiver[M]) Open(id, opener string, parts []Want, shape M) (Received, if t.Opener != opener { return Received{}, fmt.Errorf("refused for %s: transfer %s was opened by another caller", opener, id) } - return Received{ID: id, Lacks: t.Lacks()}, nil + return Received{ID: id, Lacks: t.Lacks()}, r.arm(id) } t := Transfer[M]{ID: id, Opener: opener, Parts: parts, Chunks: map[string]int{}, Opened: r.now(), Last: r.now(), Shape: shape} used, err := r.Objects.Used() @@ -188,8 +206,8 @@ func (r *Receiver[M]) Open(id, opener string, parts []Want, shape M) (Received, for _, p := range parts { size += uint64(max(p.Size, 0)) } - if used+size > r.Cap { - return Received{ID: id, Ended: true}, r.refuse(t, fmt.Sprintf(AtCap+" of %d bytes (%d held, %d asked)", r.Cap, used, size)) + if cap := r.CapNow(); used+size > cap { + return Received{ID: id, Ended: true}, r.refuse(t, fmt.Sprintf(AtCap+" of %d bytes (%d held, %d asked)", cap, used, size)) } if r.Holds != nil { for _, p := range parts { @@ -205,7 +223,19 @@ func (r *Receiver[M]) Open(id, opener string, parts []Want, shape M) (Received, if len(t.Lacks()) == 0 { return Received{ID: id, Ended: true}, r.finish(t) } - return Received{ID: id, Lacks: t.Lacks()}, nil + return Received{ID: id, Lacks: t.Lacks()}, r.arm(id) +} + +// arm bounds an open transfer by its idle key. A key that cannot be written fails the open loudly (the transfer is +// kept, so the opener's next open of it answers what is held and arms it again). +func (r *Receiver[M]) arm(id string) error { + if r.Arm == nil { + return nil + } + if err := r.Arm(id, r.idle()); err != nil { + return fmt.Errorf("transfer %s is open but its idle bound is not kept, so it would never be dropped: %w", id, err) + } + return nil } // Take takes one chunk of a part from the transfer's opener: chunks of a part arrive in sequence from 0, the last @@ -332,14 +362,15 @@ func (r *Receiver[M]) chunkName(id, digest string, seq int) string { // fill tells Filled the objects' fill. func (r *Receiver[M]) fill() { - if r.Filled == nil || r.Cap == 0 { + cap := r.CapNow() + if r.Filled == nil || cap == 0 { return } used, err := r.Objects.Used() if err != nil { return } - r.Filled(used, r.Cap, used*100 >= r.Cap*r.fullPct()) + r.Filled(used, cap, used*100 >= cap*r.fullPct()) } // Drop drops a transfer, by hand or because it was idle: its chunks and its key. @@ -361,8 +392,32 @@ func (r *Receiver[M]) Drop(id string) (Transfer[M], error) { return t, nil } -// DropIdle drops every transfer with no chunk for Idle and answers their ids. It runs when its caller calls it (on an -// open, on a listing), never on a timer. +// Expired is told that a transfer's idle key ended (the bus's marker, FollowIdle): a transfer with no chunk for Idle +// is dropped with its chunks; one that took a chunk since is armed again for the rest of its bound; one already gone +// is nothing to do. It answers whether it dropped the transfer. +func (r *Receiver[M]) Expired(id string) (bool, error) { + t, found, err := r.Transfers.Get(id) + if err != nil { + return false, fmt.Errorf("transfer %s's idle bound ended, but it cannot be read: %w", id, err) + } + if !found { + return false, nil + } + idle := r.now().Sub(t.Last) + if idle >= r.idle() { + if _, err := r.Drop(id); err != nil { + return false, fmt.Errorf("transfer %s had no chunk for %s and is not dropped: %w", id, r.idle(), err) + } + return true, nil + } + if r.Arm == nil { + return false, nil + } + return false, r.Arm(id, max(r.idle()-idle, time.Second)) +} + +// DropIdle drops every transfer with no chunk for Idle and answers their ids: a look by hand (a listing tool). The +// drop itself follows each transfer's idle key (Arm, Expired), never a timer. func (r *Receiver[M]) DropIdle() ([]string, error) { all, err := r.Transfers.List() if err != nil { diff --git a/go/parts/receive_test.go b/go/parts/receive_test.go index 7eaa978..760c35a 100644 --- a/go/parts/receive_test.go +++ b/go/parts/receive_test.go @@ -238,4 +238,40 @@ func aHolderTakesAFileInParts(t *testing.T) { t.Errorf("a chunk after the drop = %v", err) } }) + + // An open transfer is bounded by its idle key (Arm); the key's end (Expired) drops it idle, arms it again for the + // rest when it took a chunk since, and leaves a gone one; a key not written fails the open loudly. The cap the + // binding carries (Bound) counts over Cap. + t.Run("the idle key and the binding's cap", func(t *testing.T) { + armed := map[string]time.Duration{} + pkg.Arm = func(id string, ttl time.Duration) error { armed[id] = ttl; return nil } + defer func() { pkg.Arm, pkg.Bound = nil, nil }() + if _, err := pkg.Open("t6", "builder", []Want{part}, packageShape{"m", "1"}); err != nil || armed["t6"] != IdleAfter { + t.Fatalf("open = %v, armed %v; want t6's idle key for %s", err, armed, IdleAfter) + } + now = now.Add(time.Hour) + _, _ = pkg.Take("t6", "builder", part.Digest, 0, false, blob[:1000], nil) + now = now.Add(IdleAfter - time.Hour) + if dropped, err := pkg.Expired("t6"); err != nil || dropped || armed["t6"] != time.Hour { + t.Errorf("expired after a chunk = %v, %v, armed %v; want armed again for the hour left", dropped, err, armed["t6"]) + } + now = now.Add(time.Hour) + if dropped, err := pkg.Expired("t6"); err != nil || !dropped || len(objects) != 0 { + t.Errorf("expired idle = %v, %v, %d objects; want dropped with its chunks", dropped, err, len(objects)) + } + if dropped, err := pkg.Expired("t6"); err != nil || dropped { + t.Errorf("expired once gone = %v, %v; want nothing to do", dropped, err) + } + pkg.Arm = func(string, time.Duration) error { return errors.New("bus down") } + if _, err := pkg.Open("t7", "builder", []Want{part}, packageShape{"m", "1"}); err == nil || !strings.Contains(err.Error(), "never be dropped") { + t.Errorf("open with no idle key = %v, want a loud failure", err) + } + pkg.Bound = func() (uint64, error) { return 100, nil } + if _, err := pkg.Open("t8", "builder", []Want{part}, packageShape{}); err == nil || !strings.Contains(err.Error(), "cap of 100 bytes") { + t.Errorf("open past the binding's cap = %v, want refused at 100 bytes", err) + } + if pkg.Bound = func() (uint64, error) { return 0, errors.New("no binding") }; pkg.CapNow() != pkg.Cap { + t.Errorf("CapNow with no binding read = %d, want Cap %d", pkg.CapNow(), pkg.Cap) + } + }) } -- 2.54.0 From c54648e74346e384afa2b539fc8a612d327bf1a3 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 17:40:16 +0100 Subject: [PATCH 074/144] Require mesh-contracts/go at ec9529022c44, the group's contracts head, whose seat list the catalogue reads (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/go.mod | 1 + go/go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go/go.mod b/go/go.mod index 71251ca..71937db 100644 --- a/go/go.mod +++ b/go/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect diff --git a/go/go.sum b/go/go.sum index 8ca93df..193d331 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,3 +1,5 @@ +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 h1:VH8ENC8vAMSJOLx0zalAJk0kwIw6TIZTvBR39ujUkR8= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From 232f38de99cf496ea473e113aab52a00790d5b24 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:09:37 +0100 Subject: [PATCH 075/144] R2-G-catalog-B 5: the idle watch looks at every open transfer once it is taken A transfer whose idle key ended while the holder's bundle was down had its marker seen by no watch, so it stayed open for good. FollowIdle now hands every open transfer (IDs) to Expired once the watch is taken: one past its bound is dropped, one within it armed again for the rest. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/parts/bus.go | 42 +++++++++++++++++++++++++++++++++++++--- go/parts/receive_test.go | 28 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/go/parts/bus.go b/go/parts/bus.go index 13f67cf..6dc9d4d 100644 --- a/go/parts/bus.go +++ b/go/parts/bus.go @@ -101,9 +101,11 @@ func BusArm(state string) func(id string, ttl time.Duration) error { // FollowIdle follows the holder's idle state (the one BusArm writes) once the bundle serves, and hands the end of each // key (the bus's marker, OpExpire) to expired, a Receiver's Expired: the event drops an idle transfer, no timer runs. -// said is told each outcome (dropped, or the error that stopped it) for the holder's log. It blocks; run it in its own -// goroutine. -func FollowIdle(state string, expired func(id string) (bool, error), said func(id string, dropped bool, err error)) { +// Once the watch is taken it hands every transfer open (open, such as IDs of the holder's Transfers) to expired too, so +// a key that ended while the bundle was down, whose marker no watch saw, still drops its transfer, and a key lost is +// armed again. said is told each outcome (dropped, or the error that stopped it) for the holder's log. It returns +// once the start's look is done; run it in its own goroutine. +func FollowIdle(state string, open func() ([]string, error), expired func(id string) (bool, error), said func(id string, dropped bool, err error)) { <-stdio.Served() stdio.State(state).Follow("", func(ch stdio.StateChange) error { if ch.Op != stdio.OpExpire { @@ -115,4 +117,38 @@ func FollowIdle(state string, expired func(id string) (bool, error), said func(i } return err }) + expireOpen(state, open, expired, said) +} + +// expireOpen is FollowIdle's look at its start: every open transfer handed to expired, as its key's end would. +func expireOpen(state string, open func() ([]string, error), expired func(id string) (bool, error), said func(id string, dropped bool, err error)) { + say := func(id string, dropped bool, err error) { + if said != nil && (dropped || err != nil) { + said(id, dropped, err) + } + } + ids, err := open() + if err != nil { + say("", false, fmt.Errorf("the open transfers cannot be read at the start of %s's watch, so one whose idle key ended while the bundle was down waits for a look by hand: %w", state, err)) + return + } + for _, id := range ids { + dropped, err := expired(id) + say(id, dropped, err) + } +} + +// IDs answers the ids of the transfers open in t: FollowIdle's open. +func IDs[M any](t Transfers[M]) func() ([]string, error) { + return func() ([]string, error) { + all, err := t.List() + if err != nil { + return nil, err + } + out := make([]string, 0, len(all)) + for _, x := range all { + out = append(out, x.ID) + } + return out, nil + } } diff --git a/go/parts/receive_test.go b/go/parts/receive_test.go index 760c35a..d4ee5df 100644 --- a/go/parts/receive_test.go +++ b/go/parts/receive_test.go @@ -273,5 +273,33 @@ func aHolderTakesAFileInParts(t *testing.T) { if pkg.Bound = func() (uint64, error) { return 0, errors.New("no binding") }; pkg.CapNow() != pkg.Cap { t.Errorf("CapNow with no binding read = %d, want Cap %d", pkg.CapNow(), pkg.Cap) } + pkg.Bound = nil + // At the start of the idle watch every open transfer is looked at (expireOpen): one whose key ended while the + // bundle was down is dropped, one still within its bound is armed again for the rest. + pkg.Arm = func(id string, ttl time.Duration) error { armed[id] = ttl; return nil } + for id := range pkg.Transfers.(memTransfers[packageShape]) { + _, _ = pkg.Drop(id) + } + _, _ = pkg.Open("t9", "builder", []Want{part}, packageShape{"m", "1"}) + now = now.Add(IdleAfter + time.Hour) + _, _ = pkg.Open("t10", "builder", []Want{part}, packageShape{"m", "1"}) + now = now.Add(time.Hour) + var dropped []string + expireOpen("idle", IDs(pkg.Transfers), pkg.Expired, func(id string, d bool, err error) { + if err != nil { + t.Errorf("start's look at %s: %v", id, err) + } + if d { + dropped = append(dropped, id) + } + }) + if !reflect.DeepEqual(dropped, []string{"t9"}) || armed["t10"] != IdleAfter-time.Hour { + t.Errorf("at the start: dropped %v, t10 armed for %s; want t9 dropped and t10 armed for %s", dropped, armed["t10"], IdleAfter-time.Hour) + } + var said error + expireOpen("idle", func() ([]string, error) { return nil, errors.New("bus down") }, pkg.Expired, func(_ string, _ bool, err error) { said = err }) + if said == nil || !strings.Contains(said.Error(), "bus down") { + t.Errorf("start's look with no list said %v, want the failure", said) + } }) } -- 2.54.0 From d420491ca110bebf9815234fa7011b73228a47ee Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:13:41 +0100 Subject: [PATCH 076/144] R2-G-sdk-contracts 4: a state may be declared read-only Only the processes whose own bus.state names such a state write it; the module's tools read it and never write it. nats declares its warrant spend record so, and the judge's rule and the grants build on the field. Decoded and written back; given twice it is refused as every field is. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/state.go | 10 +++++++--- go/catalogue/state_test.go | 13 +++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/go/catalogue/state.go b/go/catalogue/state.go index 85a747e..19f07eb 100644 --- a/go/catalogue/state.go +++ b/go/catalogue/state.go @@ -31,9 +31,13 @@ type StateDeclaration struct { // KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker // when it ends (novox/hq ADR 0433 §1): the bucket is made with the server's time to live per message. KeyTTL bool `json:"key-ttl,omitempty"` + // ReadOnly says only the module's processes whose own bus.state names the state write it; the module's tools, + // the tool runner's account, read it and never write it (the lead's ruling, 2026-11-04: nats' warrant spend record + // `spent`). The judge's rule and the grants follow from it. + ReadOnly bool `json:"read-only,omitempty"` } -// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}. +// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only}. func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { trimmed := bytes.TrimSpace(raw) if len(trimmed) > 0 && trimmed[0] == '"' { @@ -44,7 +48,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { dec := json.NewDecoder(bytes.NewReader(trimmed)) dec.DisallowUnknownFields() if err := dec.Decode(&full); err != nil { - return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: %w", typedUnknown(err)) + return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl, read-only}: %w", typedUnknown(err)) } *s = StateDeclaration(full) return nil @@ -52,7 +56,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { // MarshalJSON writes back the short form when there is nothing else to say. func (s StateDeclaration) MarshalJSON() ([]byte, error) { - if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL { + if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL && !s.ReadOnly { return json.Marshal(s.Name) } type plain StateDeclaration diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go index cf6cb7e..0da094f 100644 --- a/go/catalogue/state_test.go +++ b/go/catalogue/state_test.go @@ -2,6 +2,7 @@ package catalogue import ( "encoding/json" + "strings" "testing" ) @@ -28,17 +29,25 @@ func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { } // 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before. +// G-sdk-contracts 4: read-only likewise, and given twice it is refused. func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) { m, err := decodeForTest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + - `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`)) + `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]}`)) if err != nil { t.Fatal(err) } if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL { t.Fatalf("key-ttl not read as said: %+v", m.State) } + if !m.State[3].ReadOnly || m.State[0].ReadOnly || m.State[1].ReadOnly { + t.Fatalf("read-only not read as said: %+v", m.State) + } out, _ := json.Marshal(m.State) - if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` { + if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]` { t.Fatalf("written back as %s", out) } + if _, err := decodeForTest([]byte(`{"module":"nats","version":"1","state":[{"name":"spent","read-only":true,"read-only":false}]}`)); err == nil || + !strings.Contains(err.Error(), "the field state[0].read-only is given twice") { + t.Fatalf("read-only given twice: %v", err) + } } -- 2.54.0 From be1ffdfedb114e8c5abbf6e0a31fb32321876dad Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:16:54 +0100 Subject: [PATCH 077/144] R2-G-sdk-contracts 1: a view change not said past its bound is the owner's finding A change said again and again without the bus taking it left every reader standing still with only a log line. Past UnsaidAfter (Bound by default) the emitter writes the owner's finding view-unsaid naming the state, as it does for a value too large, and clears it once the change is said. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/emit.go | 56 ++++++++++++++++++++++++++++++++++++++++++ go/views/findings.go | 17 +++++++++++-- go/views/views_test.go | 24 ++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/go/views/emit.go b/go/views/emit.go index 6077f3f..5f009b5 100644 --- a/go/views/emit.go +++ b/go/views/emit.go @@ -27,6 +27,14 @@ type Emitter struct { // TooLarge is told of a value too large for an event (said as a gap): the owner's finding naming the key. Nil // only logs it. TooLarge func(state, key string, size int) + // Findings writes and clears the owner's finding of a change not said past UnsaidAfter (UnsaidKind, its own state + // `findings`); nil says it in the log only. + Findings interface { + Put(key string, value []byte) error + Delete(key string) error + } + // UnsaidAfter is how long a change may stay not said before it is the owner's finding; zero is Bound. + UnsaidAfter time.Duration // Wait pauses before a change not said is said again (its attempt so far); it answers false when ctx ended. Nil is // a backoff from 250 ms doubling to 30 s. Wait func(ctx context.Context, attempt int) bool @@ -106,6 +114,7 @@ func (e Emitter) Start(ctx context.Context, state string, kv jetstream.KeyValue, // never moves past a change not said, so no reader misses one (design 25 §12.2). func (e Emitter) follow(ctx context.Context, state string, made time.Time, updates <-chan jetstream.KeyValueEntry, fillNow func() (Fill, error), errs chan<- error) { said := uint64(0) + raised := false for { select { case <-ctx.Done(): @@ -118,17 +127,27 @@ func (e Emitter) follow(ctx context.Context, state string, made time.Time, updat return } if u != nil { + var first time.Time for attempt := 1; ; attempt++ { err := e.say(ctx, state, made, u) if err == nil { break } e.logf("views: %s.%s at %d is not said (try %d): %v; said again before the next change", state, u.Key(), u.Revision(), attempt, err) + if first.IsZero() { + first = e.now() + } + if !raised && e.now().Sub(first) >= e.unsaidAfter() { + raised = e.raiseUnsaid(state, u, first, err) + } if !e.wait(ctx, attempt) { // Not marked: the next start says it again from the mark, and the bus keeps it once by its id. return } } + if raised { + raised = !e.clearUnsaid(state) + } said = u.Revision() } // Marked once every change handed so far is said, so a busy state writes one mark per burst. @@ -144,6 +163,43 @@ func (e Emitter) follow(ctx context.Context, state string, made time.Time, updat } } +func (e Emitter) unsaidAfter() time.Duration { + if e.UnsaidAfter > 0 { + return e.UnsaidAfter + } + return Bound +} + +// raiseUnsaid writes the owner's finding of u not said since first; it answers whether the finding stands. +func (e Emitter) raiseUnsaid(state string, u jetstream.KeyValueEntry, first time.Time, cause error) bool { + e.logf("views: %s.%s at %d is not said since %s: the owner's finding %s", state, u.Key(), u.Revision(), first.Format(time.RFC3339), UnsaidKind) + if e.Findings == nil { + return false + } + key, value, err := UnsaidFinding(e.Owner, state, u.Key(), u.Revision(), first, cause.Error(), e.now()) + if err == nil { + err = e.Findings.Put(key, value) + } + if err != nil { + e.logf("views: the finding of %s's change not said could not be written: %v", state, err) + return false + } + return true +} + +// clearUnsaid clears the finding once the change is said; it answers whether it is cleared. +func (e Emitter) clearUnsaid(state string) bool { + key, _, err := UnsaidFinding(e.Owner, state, "", 0, time.Time{}, "", e.now()) + if err == nil { + err = e.Findings.Delete(key) + } + if err != nil { + e.logf("views: the finding of %s's change not said could not be cleared: %v", state, err) + return false + } + return true +} + // wait is the pause before the attempt+1'th try of a change not said: Wait when given, else a backoff from 250 ms // doubling to 30 s. It answers false once ctx ends. func (e Emitter) wait(ctx context.Context, attempt int) bool { diff --git a/go/views/findings.go b/go/views/findings.go index 9795fe1..781186d 100644 --- a/go/views/findings.go +++ b/go/views/findings.go @@ -9,13 +9,26 @@ import ( ) // The findings a views part writes in its own state `findings`, which the condition keeper raises and clears -// (novox/hq ADR 0564 §2): an owner's value too large for an event (TooLargeKind), and a reader's first view unanswered -// past its bound (UnansweredKind). +// (novox/hq ADR 0564 §2): an owner's value too large for an event (TooLargeKind), an owner's change not said past its +// bound (UnsaidKind), and a reader's first view unanswered past its bound (UnansweredKind). const ( TooLargeKind = "view-too-large" + UnsaidKind = "view-unsaid" UnansweredKind = "first-view-unanswered" ) +// UnsaidFinding is the owner's finding of a change of state it has not said on its views family since since: every +// reader of the state stands still at the change before it until it is said, which clears this. +func UnsaidFinding(owner, state, key string, revision uint64, since time.Time, cause string, at time.Time) (string, []byte, error) { + id := owner + "." + state + summary := fmt.Sprintf("%s has not said the change of %s.%s at %d on its views family since %s (%s): its readers stand still before it", + owner, state, key, revision, since.UTC().Format(time.RFC3339), strings.TrimSpace(cause)) + return finding(owner, UnsaidKind, id, summary, + fmt.Sprintf("%s cannot say a change of its %s", owner, state), + "The owner says each change of its state as an event on its own stream, and says a change again until the bus takes it; "+ + "its readers hear nothing newer meanwhile. Look at the owner's stream on the bus: is it full, or is the bus away?", at) +} + // TooLargeFinding is the owner's finding of state's key too large to say whole on its views family. func TooLargeFinding(owner, state, key string, size int, at time.Time) (string, []byte, error) { id := owner + "." + state + "." + key diff --git a/go/views/views_test.go b/go/views/views_test.go index 3cd0ace..8fed70b 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -432,6 +432,30 @@ func TestViews(t *testing.T) { if len(said) != 0 { t.Fatalf("said %v after the context ended on a change not said", said) } + // A change not said past its bound (Bound) is the owner's finding naming the state, cleared once it is said; + // one said within the bound raises none. + for _, c := range []struct { + fails, puts int + }{{1, 0}, {3, 1}} { + clock := made + fs := &findingsMap{} + said, fails = nil, c.fails + em.Now, em.Findings = func() time.Time { return clock }, fs + em.Wait = func(context.Context, int) bool { clock = clock.Add(time.Minute); return true } + updates = make(chan jetstream.KeyValueEntry, 1) + updates <- kvEntry{key: "a", rev: 1, at: made} + close(updates) + em.follow(context.Background(), "s", made, updates, func() (Fill, error) { return Fill{}, nil }, nil) + if fs.puts != c.puts || fs.deletes != c.puts || len(fs.m) != 0 || len(said) != 1 { + t.Errorf("%d failures: %d findings written, %d cleared, %d left, said %v", c.fails, fs.puts, fs.deletes, len(fs.m), said) + } + if c.puts > 0 { + key, value, _ := UnsaidFinding("o", "s", "a", 1, made, "bus away", clock) + if !strings.Contains(key, UnsaidKind) || !strings.Contains(key, "o.s") || !strings.Contains(string(value), "o has not said the change of s.a at 1") { + t.Errorf("the finding is %s: %s", key, value) + } + } + } }) t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) { if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { -- 2.54.0 From 3526e9ef2a42b4081064ba424f142c54144fd699 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:17:12 +0100 Subject: [PATCH 078/144] R2-G-sdk-contracts 2: mesh-contracts/go is a direct requirement catalogue imports the seat set, so the requirement belongs in the direct block; its version is unchanged (the group's re-pin follows). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/go.mod b/go/go.mod index 71937db..13039f3 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,12 +3,12 @@ module git.novox.be/novox/mesh-sdk/go go 1.26.0 require ( + git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) require ( - git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect -- 2.54.0 From e5d89c5eedb59a700f43a0ee6768db78379e10b5 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:22:36 +0100 Subject: [PATCH 079/144] Require mesh-contracts/go at 347a3053580a, the group's contracts head after review round 2 (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/go.mod | 2 +- go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go/go.mod b/go/go.mod index 13039f3..3fd5e25 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,7 +3,7 @@ module git.novox.be/novox/mesh-sdk/go go 1.26.0 require ( - git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 + git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/go/go.sum b/go/go.sum index 193d331..74dce23 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44 h1:VH8ENC8vAMSJOLx0zalAJk0kwIw6TIZTvBR39ujUkR8= -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104164000-ec9529022c44/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a h1:E9ERPqR4uZXypudHGxv2/wNIOYcder9torVqJwJq/DI= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From f94abb95aaef0487eb282519354cd77b33c1e42a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:41:18 +0100 Subject: [PATCH 080/144] R3-F-sdk: the first change said after a restart clears a view-unsaid finding left standing raised started false on every run of follow, so a finding a run before a restart raised was never cleared. A flag of its own clears it once at the first change said; the raise keeps its own flag, so a change not said in the new run is still raised past its bound. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/emit.go | 6 +++++- go/views/views_test.go | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/go/views/emit.go b/go/views/emit.go index 5f009b5..2e16fa7 100644 --- a/go/views/emit.go +++ b/go/views/emit.go @@ -115,6 +115,9 @@ func (e Emitter) Start(ctx context.Context, state string, kv jetstream.KeyValue, func (e Emitter) follow(ctx context.Context, state string, made time.Time, updates <-chan jetstream.KeyValueEntry, fillNow func() (Fill, error), errs chan<- error) { said := uint64(0) raised := false + // A finding a run before this one raised is not known to this one: the first change said clears it once, so a + // restart never leaves it standing (the finding is about the state, its key the same). + leftover := e.Findings != nil for { select { case <-ctx.Done(): @@ -145,8 +148,9 @@ func (e Emitter) follow(ctx context.Context, state string, made time.Time, updat return } } - if raised { + if raised || leftover { raised = !e.clearUnsaid(state) + leftover = false } said = u.Revision() } diff --git a/go/views/views_test.go b/go/views/views_test.go index 8fed70b..880cea2 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -446,7 +446,7 @@ func TestViews(t *testing.T) { updates <- kvEntry{key: "a", rev: 1, at: made} close(updates) em.follow(context.Background(), "s", made, updates, func() (Fill, error) { return Fill{}, nil }, nil) - if fs.puts != c.puts || fs.deletes != c.puts || len(fs.m) != 0 || len(said) != 1 { + if fs.puts != c.puts || fs.deletes != 1 || len(fs.m) != 0 || len(said) != 1 { t.Errorf("%d failures: %d findings written, %d cleared, %d left, said %v", c.fails, fs.puts, fs.deletes, len(fs.m), said) } if c.puts > 0 { @@ -456,6 +456,18 @@ func TestViews(t *testing.T) { } } } + // A finding a run before a restart raised is cleared by the first change said after it. + key, value, _ := UnsaidFinding("o", "s", "a", 1, made, "bus away", made) + fs := &findingsMap{m: map[string][]byte{key: value}} + said, fails = nil, 0 + em.Findings = fs + updates = make(chan jetstream.KeyValueEntry, 1) + updates <- kvEntry{key: "b", rev: 2, at: made} + close(updates) + em.follow(context.Background(), "s", made, updates, func() (Fill, error) { return Fill{}, nil }, nil) + if _, left := fs.m[key]; left || len(said) != 1 { + t.Errorf("after a restart the leftover finding stands (%v), said %v", left, said) + } }) t.Run("an event names its state on its subject, and decodes as it was encoded", func(t *testing.T) { if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { -- 2.54.0 From 05b3e2e1b94f51179952a7bc58e3c99f7cb67472 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:43:59 +0100 Subject: [PATCH 081/144] Require mesh-contracts/go at ae5266e77639, the group's final contracts head (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/go.mod | 2 +- go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go/go.mod b/go/go.mod index 3fd5e25..abadd96 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,7 +3,7 @@ module git.novox.be/novox/mesh-sdk/go go 1.26.0 require ( - git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a + git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/go/go.sum b/go/go.sum index 74dce23..93a20b2 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a h1:E9ERPqR4uZXypudHGxv2/wNIOYcder9torVqJwJq/DI= -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104172218-347a3053580a/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM= +git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From bbf11664360853b0f0d55d8b2dfc17973405c3da Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 19:46:58 +0100 Subject: [PATCH 082/144] Put back the indirect requirements SDK-8 raised for no change of its own: antithesis-sdk-go v0.8.0 needs modules the running node-engine's composer cannot find offline (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/go.mod | 8 ++++---- go/go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go/go.mod b/go/go.mod index abadd96..e25d8c9 100644 --- a/go/go.mod +++ b/go/go.mod @@ -9,14 +9,14 @@ require ( ) require ( - github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/klauspost/compress v1.20.1 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/minio/highwayhash v1.0.4 // indirect - github.com/nats-io/jwt/v2 v2.8.2 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/time v0.16.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 93a20b2..83ee555 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,15 +1,15 @@ git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM= git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= -github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= -github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= -github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc= -github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= @@ -23,5 +23,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= -golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -- 2.54.0 From 8e7001509a1a2593d97628830cfefa4a74d1da40 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 20:53:50 +0100 Subject: [PATCH 083/144] =?UTF-8?q?Catalogue:=20the=20store's=20seats=20de?= =?UTF-8?q?liver=20nothing,=20and=20a=20seat's=20reach=20is=20the=20reader?= =?UTF-8?q?=20capability=20form:seat-reach=20(ADR=200632=20=C2=A71,=20?= =?UTF-8?q?=C2=A72;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CanHold asks the store's holder no provision once the seats stop delivering artifact-store; a manifest naming ${seat::reach} needs an engine that fills it from the seat holder's listen, so the delivery holds it from one that does not. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/broker_seat_test.go | 6 +++++- go/catalogue/manifest.go | 10 ++++------ go/catalogue/manifest_reads.go | 15 +++++++++++++++ go/catalogue/own_manifest_test.go | 5 ++++- go/catalogue/seats_test.go | 5 +++++ 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/go/catalogue/broker_seat_test.go b/go/catalogue/broker_seat_test.go index c2b5af4..11f5569 100644 --- a/go/catalogue/broker_seat_test.go +++ b/go/catalogue/broker_seat_test.go @@ -17,7 +17,11 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) { for _, pair := range []struct{ seat, delivers string }{ {"git", "git"}, {"npm-package-registry", "npm-package-registry"}, - {"mesh-artifact-store", "artifact-store"}, + // The store's seats deliver nothing since the provision artifact-store retired (novox/hq ADR 0632 §2): the + // store's address is its holder's listen, read through the seat. + {"mesh-artifact-store", ""}, + {"container-image-registry", ""}, + {"oci-artifact-registry", ""}, {"mesh-store", "postgres-database"}, {"mesh-broker", "mesh-bus"}, } { diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 94046c1..765bdcf 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -1331,12 +1331,10 @@ const ( ArtifactPackage = "package" ) -// ArtifactStoreProvision is the name a module offers when it is the mesh's store for what modules -// ship — images, and archives, which are directories from a repository packed as blobs. -// -// Named here because a rule depends on it: what provides this cannot be delivered through it -// (novox/hq 04-ISSUES/029). A string compared in one place is a convention; a string a rule turns -// on is a fact, and it should be written once. +// ArtifactStoreProvision is the name the mesh's store for what modules ship (images, and archives, +// which are directories from a repository packed as blobs) was offered under until the provision +// retired (novox/hq ADR 0632 §2): no seat delivers it and no module provides it. It stays as the +// scheme of the older reference form, artifact-store://, which done keys keep (ArtifactStoreScheme). const ArtifactStoreProvision = "artifact-store" // Listening is one endpoint a module serves: a port it accepts connections on, and what may be said diff --git a/go/catalogue/manifest_reads.go b/go/catalogue/manifest_reads.go index 8da9384..ed39e75 100644 --- a/go/catalogue/manifest_reads.go +++ b/go/catalogue/manifest_reads.go @@ -5,6 +5,7 @@ import ( _ "embed" "encoding/json" "reflect" + "regexp" "slices" "sort" "strings" @@ -47,6 +48,16 @@ const ReferenceFormSeat = "seat-reference" // artifactReferenceForms are the reference forms of a build's artifacts this contract reads beyond the baseline's. var artifactReferenceForms = []string{ReferenceFormSeat} +// ReferenceFormSeatReach names how a node-engine built with this contract fills `${seat::reach}`, as the reader +// capability `form:seat-reach` (novox/hq ADR 0632 §1): from the seat holder placement names and the listen its manifest +// opens to the mesh, the store's three seats among them, never from a module serving the retired provision +// artifact-store. An engine before it filled the store's reach only from that provision, so a manifest naming a seat's +// reach needs it and is held from a node whose engine does not state it (ManifestNeeds). +const ReferenceFormSeatReach = "seat-reach" + +// ofSeatReach is a manifest's value naming a seat's reach, `${seat::reach}`. +var ofSeatReach = regexp.MustCompile(`\$\{seat:[a-z0-9][a-z0-9-]*:reach\}`) + // manifestTopWords are the top-level keys Manifest.UnmarshalJSON reads apart from the fields of manifestFields: each // read whole, its inside a map of the module's own names (requirements, local names) and values, no words of the // contract's beneath it. `requires` and `config` are read as their fields are (manifestNeeds). @@ -73,6 +84,7 @@ func ManifestReads() []string { for _, f := range artifactReferenceForms { set[ReadForm+f] = true } + set[ReadForm+ReferenceFormSeatReach] = true for _, s := range DefaultSeats() { set[ReadSeat+s.Name] = true } @@ -139,6 +151,9 @@ func ManifestNeeds(raw []byte) ([]string, error) { used[ReadForm+f] = true } } + if ofSeatReach.Match(raw) { + used[ReadForm+ReferenceFormSeatReach] = true + } if _, has := top[ConfigField]; has || takesConfigKey.Match(raw) { used[ReadForm+"config"] = true } diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index 2096a9f..ebfd61b 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -51,7 +51,7 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { } for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage", // The reference form a registry seat answers (ADR 0605 §9), and the seats the registry milestone adds. - ReadForm + ReferenceFormSeat, "seat:go-package-registry", "seat:container-image-registry", "seat:oci-artifact-registry"} { + ReadForm + ReferenceFormSeat, ReadForm + ReferenceFormSeatReach, "seat:go-package-registry", "seat:container-image-registry", "seat:oci-artifact-registry"} { if !slices.Contains(added, want) { t.Errorf("%s is in the baseline, or not read: added since it are %v", want, added) } @@ -79,6 +79,9 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { "word:provides.identity.max", "word:resources"}, nil}, {"a bus-state entry of requires", `{"module":"b","version":"1","requires":["mesh-bus",{"name":"jobs","storage":"kv"}]}`, []string{"word:requires", "word:bus-state.storage"}, []string{"word:bus-state", "word:bus-state.name", "word:bus-state.storage"}}, + // A seat's reach, filled from the seat holder's listen (ADR 0632 §1): an engine before it lacks the reading. + {"a seat's reach", `{"module":"s","version":"1","resources":[{"id":"f","type":"file","content":"${seat:container-image-registry:reach}"}]}`, + []string{"form:seat", "form:seat-reach"}, []string{"form:seat-reach"}}, {"a word this contract does not know", `{"module":"u","version":"1","newer-word":{"inside":1}}`, []string{"word:module"}, nil}, } { needs, err := ManifestNeeds([]byte(row.raw)) diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 71c02ad..bd69cd5 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -61,6 +61,8 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { // A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the // checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it). retyped := map[string]bool{"node-engine.compose-candidate": true} + // A seat whose provision retired delivers nothing since: the store's, artifact-store (ADR 0632 §2). + retired := map[string]bool{"mesh-artifact-store": true} norm := func(v any) any { b, _ := json.Marshal(v) var out any @@ -75,6 +77,9 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { t.Errorf("%s left the generated set", want.Name) continue } + if retired[want.Name] { + want.Delivers = "" + } g := golden{Seat: got} for _, v := range got.Serves { if v.Optional { -- 2.54.0 From ecdbf1a668acd95d0b429ea44f50e33ae275f16e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 21:03:33 +0100 Subject: [PATCH 084/144] =?UTF-8?q?Catalogue:=20the=20store=20holder's=20m?= =?UTF-8?q?anifest=20needs=20form:seat-reach=20(ADR=200632=20=C2=A72;=20is?= =?UTF-8?q?sue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is read on every node and leaves an engine before the reading without the old way to find the store. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/manifest_reads.go | 21 +++++++++++++++++++-- go/catalogue/own_manifest_test.go | 5 +++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/go/catalogue/manifest_reads.go b/go/catalogue/manifest_reads.go index ed39e75..ac69427 100644 --- a/go/catalogue/manifest_reads.go +++ b/go/catalogue/manifest_reads.go @@ -52,9 +52,26 @@ var artifactReferenceForms = []string{ReferenceFormSeat} // capability `form:seat-reach` (novox/hq ADR 0632 §1): from the seat holder placement names and the listen its manifest // opens to the mesh, the store's three seats among them, never from a module serving the retired provision // artifact-store. An engine before it filled the store's reach only from that provision, so a manifest naming a seat's -// reach needs it and is held from a node whose engine does not state it (ManifestNeeds). +// reach, and the store holder's own manifest, need it and are held from a node whose engine does not state it +// (ManifestNeeds). const ReferenceFormSeatReach = "seat-reach" +// claimsAtMesh says whether a manifest claims seat at the mesh's scope (a claim with no scope is the seat's own). The +// store holder's own manifest, a claim of ContainerImageRegistrySeat, needs form:seat-reach this way (ADR 0632 §2): read +// on every node, it leaves an engine before the reading without the old way to find the store, so it is held from every +// node whose engine does not state it. +func claimsAtMesh(top map[string]any, seat string) bool { + for _, c := range asList(top["claims"]) { + obj, _ := c.(map[string]any) + name, _ := obj["name"].(string) + scope, _ := obj["scope"].(string) + if hs, ok := SeatNamed(name); ok && hs.Name == seat && (scope == "" || scope == ScopeMesh) { + return true + } + } + return false +} + // ofSeatReach is a manifest's value naming a seat's reach, `${seat::reach}`. var ofSeatReach = regexp.MustCompile(`\$\{seat:[a-z0-9][a-z0-9-]*:reach\}`) @@ -151,7 +168,7 @@ func ManifestNeeds(raw []byte) ([]string, error) { used[ReadForm+f] = true } } - if ofSeatReach.Match(raw) { + if ofSeatReach.Match(raw) || claimsAtMesh(top, ContainerImageRegistrySeat) { used[ReadForm+ReferenceFormSeatReach] = true } if _, has := top[ConfigField]; has || takesConfigKey.Match(raw) { diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index ebfd61b..ce279b0 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -82,6 +82,11 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { // A seat's reach, filled from the seat holder's listen (ADR 0632 §1): an engine before it lacks the reading. {"a seat's reach", `{"module":"s","version":"1","resources":[{"id":"f","type":"file","content":"${seat:container-image-registry:reach}"}]}`, []string{"form:seat", "form:seat-reach"}, []string{"form:seat-reach"}}, + // The store holder's manifest, distribution's as ADR 0632 leaves it: no reach written, no provision served; an engine + // stating only the baseline lacks the reading that finds the store by it (ADR 0632 §2). + {"the store's holder", `{"module":"distribution","version":"1","claims":[{"name":"container-image-registry",` + + `"scope":"mesh","serves":["list"]}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`, + []string{"form:seat-reach", "seat:container-image-registry"}, []string{"form:seat-reach", "seat:container-image-registry"}}, {"a word this contract does not know", `{"module":"u","version":"1","newer-word":{"inside":1}}`, []string{"word:module"}, nil}, } { needs, err := ManifestNeeds([]byte(row.raw)) -- 2.54.0 From 2e4eb0be90a577b4ea2191b92a8ecafe5f0c1477 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 21:32:04 +0100 Subject: [PATCH 085/144] =?UTF-8?q?Catalogue:=20a=20module's=20needs=20inc?= =?UTF-8?q?lude=20its=20templates'=20text=20(ADR=200632=20=C2=A72;=20issue?= =?UTF-8?q?=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker's daemon file and artifact-store-tools' store address move to the image seat's reach in their templates while their manifests stay the same, so needs read from the manifest alone let an engine before the reading lose the file. ModuleNeeds reads the templates TemplatePaths names; the merge message carries the result per directory for the indexer. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/catalogue/manifest_reads.go | 41 ++++++++++++++++++++++++++----- go/catalogue/own_manifest_test.go | 17 ++++++++++++- go/catalogue/template.go | 20 +++++++++++++++ go/delivery/source.go | 4 +++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/go/catalogue/manifest_reads.go b/go/catalogue/manifest_reads.go index ac69427..98503a1 100644 --- a/go/catalogue/manifest_reads.go +++ b/go/catalogue/manifest_reads.go @@ -163,12 +163,8 @@ func ManifestNeeds(raw []byte) ([]string, error) { return nil, err } used := map[string]bool{} - for _, f := range referenceForms { - if bytes.Contains(raw, []byte("${"+f+":")) { - used[ReadForm+f] = true - } - } - if ofSeatReach.Match(raw) || claimsAtMesh(top, ContainerImageRegistrySeat) { + textNeeds(raw, used) + if claimsAtMesh(top, ContainerImageRegistrySeat) { used[ReadForm+ReferenceFormSeatReach] = true } if _, has := top[ConfigField]; has || takesConfigKey.Match(raw) { @@ -429,6 +425,39 @@ func asList(v any) []any { return list } +// textNeeds adds the reference forms a manifest's or a template's text writes to used: a form the engine fills in a +// file's text is read there whether the text is inline or in a template file. +func textNeeds(text []byte, used map[string]bool) { + for _, f := range referenceForms { + if bytes.Contains(text, []byte("${"+f+":")) { + used[ReadForm+f] = true + } + } + if ofSeatReach.Match(text) { + used[ReadForm+ReferenceFormSeatReach] = true + } +} + +// ModuleNeeds is ManifestNeeds of a module whose files take their text from templates (template.go): what the manifest +// needs and what each template's text writes, by template path (TemplatePaths), sorted. A module's needs are both: an +// engine renders the template's text as it renders inline content, so a template naming ${seat:x:reach} needs the +// reading as the same content inline does, though the manifest itself is unchanged (novox/hq ADR 0632 §2). A template +// named and not given is no need; the reader of the files says it could not read one. +func ModuleNeeds(raw []byte, templates map[string]string) ([]string, error) { + needs, err := ManifestNeeds(raw) + if err != nil || len(templates) == 0 { + return needs, err + } + used := map[string]bool{} + for _, n := range needs { + used[n] = true + } + for _, text := range templates { + textNeeds([]byte(text), used) + } + return sortedSet(used), nil +} + func sortedSet(set map[string]bool) []string { out := make([]string, 0, len(set)) for k := range set { diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index ce279b0..21ae5f8 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -1,6 +1,7 @@ package catalogue import ( + "maps" "os" "slices" "testing" @@ -87,12 +88,26 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { {"the store's holder", `{"module":"distribution","version":"1","claims":[{"name":"container-image-registry",` + `"scope":"mesh","serves":["list"]}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`, []string{"form:seat-reach", "seat:container-image-registry"}, []string{"form:seat-reach", "seat:container-image-registry"}}, + // docker's daemon file as ADR 0632 leaves it: the manifest unchanged, its template naming the image seat's reach; + // TemplatePaths names the template a reader of the files reads. + {"a template naming a seat's reach", `{"module":"d","version":"1","resources":[{"id":"daemon","type":"file",` + + `"path":"/etc/d.json","template":"files/daemon.json"}]}`, []string{"form:seat", "form:seat-reach"}, + []string{"form:seat-reach"}}, {"a word this contract does not know", `{"module":"u","version":"1","newer-word":{"inside":1}}`, []string{"word:module"}, nil}, } { - needs, err := ManifestNeeds([]byte(row.raw)) + templates := map[string]map[string]string{ // the templates' text a row's module names + "a template naming a seat's reach": {"files/daemon.json": `{"x":"${seat:container-image-registry:reach}"}`}, + }[row.why] + needs, err := ModuleNeeds([]byte(row.raw), templates) if err != nil { t.Fatalf("%s: %v", row.why, err) } + if templates != nil { + m, err := Decode([]byte(row.raw)) + if paths := TemplatePaths(m); err != nil || !slices.Equal(paths, slices.Sorted(maps.Keys(templates))) { + t.Errorf("%s: TemplatePaths = %v (%v), want %v", row.why, paths, err, slices.Sorted(maps.Keys(templates))) + } + } for _, n := range row.needs { if !slices.Contains(needs, n) { t.Errorf("%s: needs %v, not %s", row.why, needs, n) diff --git a/go/catalogue/template.go b/go/catalogue/template.go index d5d8945..53d849f 100644 --- a/go/catalogue/template.go +++ b/go/catalogue/template.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "unicode/utf8" ) @@ -184,6 +185,25 @@ func fillItemTemplates(m Manifest, items []Item, text func(string) (string, bool return out, nil } +// TemplatePaths is every template the manifest's files and items name, by its path in the module's directory, sorted +// and once each: what a reader of the module's files reads for ModuleNeeds. A template whose name is wrong is left out; +// the judge refuses it by name. +func TemplatePaths(m Manifest) []string { + var out []string + for _, r := range m.Resources { + if name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r); has && wrong == "" { + out = append(out, name) + } + } + for _, it := range m.Items { + if it.Template != "" && itemTemplateProblem(m, it.Template) == "" { + out = append(out, it.Template) + } + } + slices.Sort(out) + return slices.Compact(out) +} + // ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository tree, // by template path. Refused by name: a template the directory lacks, and anything under its templates directory // (TemplatesDir) the build's packing refuses — that directory itself, or any directory or file under it, that is a diff --git a/go/delivery/source.go b/go/delivery/source.go index 6eebdfb..500dfd8 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -594,6 +594,10 @@ type Forge struct { // relative to it (novox/hq ADR 0465 clause 1). Older writers send neither. Manifests map[string]string `json:"manifests,omitempty"` Locks map[string]map[string]string `json:"locks,omitempty"` + // Needs are, of each such directory whose manifest names templates, the reader capabilities of the module whole: + // its manifest's and its templates' text's at the same commit (catalogue.ModuleNeeds; novox/hq ADR 0632 §2). The + // indexer takes them over the manifest's own; a directory absent, or an older writer, has the manifest's alone. + Needs map[string][]string `json:"needs,omitempty"` // Paths are, of each module directory whose manifest names paths on every artifact, each named path's git object // id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The // indexer names the paths; a message without them gives such a module none. Older writers send none. -- 2.54.0 From 863b9095041d03019647c5e481481d739a056095 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 12:47:26 +0100 Subject: [PATCH 086/144] =?UTF-8?q?Seat=20list=20reader:=20the=20seat=20de?= =?UTF-8?q?finitions=20read=20from=20the=20bus,=20followed=20on=20seat-def?= =?UTF-8?q?inition-changed=20(ADR=200635=20=C2=A71,=20=C2=A73;=20issue=201?= =?UTF-8?q?966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/catalogue/seat_from_definition.go | 57 +++++++ go/seatdef/refs.go | 40 +++++ go/seatdef/refs_test.go | 31 ++++ go/seatlist/seatlist.go | 227 +++++++++++++++++++++++++++ go/seatlist/seatlist_test.go | 129 +++++++++++++++ go/seatlist/stdiobus.go | 18 +++ 6 files changed, 502 insertions(+) create mode 100644 go/catalogue/seat_from_definition.go create mode 100644 go/seatdef/refs.go create mode 100644 go/seatdef/refs_test.go create mode 100644 go/seatlist/seatlist.go create mode 100644 go/seatlist/seatlist_test.go create mode 100644 go/seatlist/stdiobus.go diff --git a/go/catalogue/seat_from_definition.go b/go/catalogue/seat_from_definition.go new file mode 100644 index 0000000..f67eb57 --- /dev/null +++ b/go/catalogue/seat_from_definition.go @@ -0,0 +1,57 @@ +package catalogue + +import ( + "sort" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// SeatFromDefinition is a seat in this package's types from its definition (novox/hq ADR 0608 §1, ADR 0635 §1): what +// the seat list once held, now read from the definition the indexer keeps on the bus. The definition is resolved with +// its parts (seatdef.Resolve) as the indexer keeps it. A verb's input and answer have their named types in place, and a +// contribution kind given as data gets its checks (withShapeCode), which are code and never a definition's. +func SeatFromDefinition(d seatdef.Definition) Seat { + s := Seat{Name: d.Seat, Scope: d.Scope, Replicated: d.Replicated, Delivers: d.Delivers, + Accepts: seatdef.MessageNames(d.Accepts), Emits: seatdef.MessageNames(d.Events), PulledByKind: d.PulledByKind, + Yields: d.Yields, HeldByTheEngine: d.HeldByTheEngine, Decision: d.Decision} + for _, v := range d.Verbs { + s.Serves = append(s.Serves, Verb{Name: v.Name, Description: v.Description, + Input: seatdef.ResolveRefs(v.Input, d.Defs), Output: seatdef.ResolveRefs(v.Answer, d.Defs), + Optional: v.Optional, Replaces: v.Replaces}) + } + for _, r := range d.Receives { + rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered} + if r.Shape != nil { + sh := &Shape{Order: r.Shape.Order, Examples: r.Shape.Examples, Text: r.Shape.Text} + for _, f := range r.Shape.Places { + sh.Places = append(sh.Places, PlaceField{Field: f.Field, Values: f.Values}) + } + withShapeCode(d.Seat, r.Kind, sh) + rv.Shape = sh + } + s.Receives = append(s.Receives, rv) + } + for _, p := range d.Paths { + s.Paths = append(s.Paths, SeatPath{Path: p.Path, StepsBackFor: p.StepsBackFor}) + } + return s +} + +// SeatsFromDefinitions are the seats of a set of definitions, in reading order: the mesh's own, then a node's, each by +// name. A seat its holder declares itself (HolderDeclares) is not in the list, as the compiled list left it out. +func SeatsFromDefinitions(defs []seatdef.Definition) []Seat { + out := make([]Seat, 0, len(defs)) + for _, d := range defs { + if d.HolderDeclares { + continue + } + out = append(out, SeatFromDefinition(d)) + } + sort.SliceStable(out, func(i, j int) bool { + if (out[i].Scope == seatdef.ScopeMesh) != (out[j].Scope == seatdef.ScopeMesh) { + return out[i].Scope == seatdef.ScopeMesh + } + return out[i].Name < out[j].Name + }) + return out +} diff --git a/go/seatdef/refs.go b/go/seatdef/refs.go new file mode 100644 index 0000000..e3fc540 --- /dev/null +++ b/go/seatdef/refs.go @@ -0,0 +1,40 @@ +package seatdef + +import "strings" + +// ResolveRefs is a schema with every `{"$ref": "#/defs/"}` replaced by the definition's named type, as the seat +// list's readers need it (a verb's input and answer are one schema each, not a schema and its types). A reference to a +// name the definition does not define stays as it is; nesting deeper than 32 levels stops there. +func ResolveRefs(schema map[string]any, defs map[string]map[string]any) map[string]any { + if schema == nil { + return nil + } + m, _ := resolveValue(schema, defs, 0).(map[string]any) + return m +} + +func resolveValue(v any, defs map[string]map[string]any, depth int) any { + if depth > 32 { + return v + } + switch x := v.(type) { + case map[string]any: + if r, ok := x["$ref"].(string); ok { + if d, ok := defs[strings.TrimPrefix(r, "#/defs/")]; ok { + return resolveValue(d, defs, depth+1) + } + } + out := make(map[string]any, len(x)) + for k, e := range x { + out[k] = resolveValue(e, defs, depth+1) + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = resolveValue(e, defs, depth+1) + } + return out + } + return v +} diff --git a/go/seatdef/refs_test.go b/go/seatdef/refs_test.go new file mode 100644 index 0000000..59576a4 --- /dev/null +++ b/go/seatdef/refs_test.go @@ -0,0 +1,31 @@ +package seatdef + +import ( + "reflect" + "testing" +) + +// A verb's schema has its named types in place (the seat list's readers take one schema, not a schema and its types). +func TestResolveRefsPutsNamedTypesInPlace(t *testing.T) { + defs := map[string]map[string]any{ + "id": {"type": "string"}, + "row": {"type": "object", "properties": map[string]any{"id": map[string]any{"$ref": "#/defs/id"}}}, + } + cases := []struct { + name string + schema, want map[string]any + }{ + {"nothing", nil, nil}, + {"a type nested in a type", map[string]any{"type": "array", "items": map[string]any{"$ref": "#/defs/row"}}, + map[string]any{"type": "array", "items": map[string]any{"type": "object", + "properties": map[string]any{"id": map[string]any{"type": "string"}}}}}, + {"a name it does not define stays", map[string]any{"$ref": "#/defs/gone"}, map[string]any{"$ref": "#/defs/gone"}}, + {"a list of schemas", map[string]any{"oneOf": []any{map[string]any{"$ref": "#/defs/id"}}}, + map[string]any{"oneOf": []any{map[string]any{"type": "string"}}}}, + } + for _, c := range cases { + if got := ResolveRefs(c.schema, defs); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go new file mode 100644 index 0000000..68b8c50 --- /dev/null +++ b/go/seatlist/seatlist.go @@ -0,0 +1,227 @@ +// Package seatlist is the runtime's reader of the seat list (novox/hq ADR 0635 §1, §3): the seat definitions the +// indexer keeps on the bus, asked once through the owner's tool and followed on its event `seat-definition-changed`. +// The manifest validator, the node-engine, the tool runner, claude-code, the grants part and every other caller of +// `catalogue` get their seats here and give them to `catalogue` as an input; mesh-sdk compiles no seat list and imports +// no contracts module. +// +// **What it asks and hears.** The owner's tool Tool, with no seat for every trunk definition (the answer's shape is +// Listing) and with one seat for that seat's (the answer is the owner's own, read as Answer); the owner's event +// ChangedEvent, whose body names the seat and its source (Changed). Only trunk definitions are held: a pull request's are +// the indexer's to judge. Nothing polls and nothing waits on a timer: a reader that could not ask says so in its +// Status and reads again on the next event or on Reload, which the host module offers as a tool (steering it). +// +// **What it names.** The tool's and the event's names, which ADR 0635 §1 gives the owner. It names no module and no +// seat: the owner's module is the caller's (Config.Owner), the one its manifest's `invokes` lists. +package seatlist + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +const ( + // Tool is the owner's tool that answers a seat definition. + Tool = "indexer_seat_definition" + // ChangedEvent is the owner's event that says a definition moved. + ChangedEvent = "seat-definition-changed" + // SourceTrunk is the only source this reader holds. + SourceTrunk = "trunk" +) + +// Bus is what the reader needs of its host: a call of a tool by its key (`.`) and a subscription to an +// event pattern. StdioBus is the one a bundle has; the node-engine and the tool runner give theirs. +type Bus interface { + Ask(key string, body any) (json.RawMessage, error) + Subscribe(pattern string, handle func(key string, body json.RawMessage) error) error +} + +// Entry is one definition as the owner answers it: the seat, its source, the digest of the file, and the definition +// resolved with the parts it includes. It is the owner's SeatDefinitionEntry, read here by the fields it needs. +type Entry struct { + Seat string `json:"seat"` + Source string `json:"source"` + Digest string `json:"digest,omitempty"` + Definition json.RawMessage `json:"definition,omitempty"` + Unparsed string `json:"unparsed,omitempty"` +} + +// Listing is the owner's answer to the tool with no seat: every trunk definition and the revision of its state. +type Listing struct { + Revision uint64 `json:"revision"` + Definitions []Entry `json:"definitions"` +} + +// Answer is the owner's answer to the tool with one seat. +type Answer struct { + Seat string `json:"seat"` + Found bool `json:"found"` + Entry *Entry `json:"entry,omitempty"` + Revision uint64 `json:"revision"` +} + +// Changed is the event's body. +type Changed struct { + Seat string `json:"seat"` + Source string `json:"source"` + Digest string `json:"digest"` +} + +// Config is what a host gives Start. +type Config struct { + // Owner is the module that keeps the definitions, as the host's manifest `invokes` names it. + Owner string + // Bus is the host's way to the owner; StdioBus() for a bundle. + Bus Bus + // Bootstrap are the definitions known before the owner is heard (ADR 0635 §3); Bootstrapped() is the foundation's. + Bootstrap []seatdef.Definition + // OnChange, when set, is called after the held set changed, with the seats in reading order. It runs on the + // goroutine that heard the change: it returns quickly. + OnChange func([]catalogue.Seat) +} + +// Reader holds the seat list. +type Reader struct { + cfg Config + + mu sync.Mutex + defs map[string]seatdef.Definition // by seat + boot map[string]bool // seats held from the bootstrap, not yet from the owner +} + +// Start returns a reader holding the bootstrap, asks the owner for every definition and follows its event. A first +// ask the owner did not answer is no failure: the reader holds the bootstrap and reads again on the owner's next +// event or on Reload. Start returns the subscription's error alone, which the host may ask again by calling Start again +// on a new reader. +func Start(cfg Config) (*Reader, error) { + if cfg.Bus == nil { + return nil, errors.New("seatlist: no bus to read the seat list through") + } + if cfg.Owner == "" || strings.ContainsAny(cfg.Owner, ". \t") { + return nil, fmt.Errorf("seatlist: the owner %q is not a module name", cfg.Owner) + } + r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, boot: map[string]bool{}} + for _, d := range cfg.Bootstrap { + r.defs[d.Seat] = d + r.boot[d.Seat] = true + } + if err := cfg.Bus.Subscribe("*."+ChangedEvent, r.heard); err != nil { + return r, fmt.Errorf("seatlist: following %s: %w", ChangedEvent, err) + } + _ = r.Reload() + return r, nil +} + +// Seats are the held seats in reading order, as catalogue takes them. +func (r *Reader) Seats() []catalogue.Seat { + r.mu.Lock() + defer r.mu.Unlock() + return r.seatsLocked() +} + +// Definitions are the held definitions, by seat name. +func (r *Reader) Definitions() map[string]seatdef.Definition { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]seatdef.Definition, len(r.defs)) + for k, v := range r.defs { + out[k] = v + } + return out +} + +func (r *Reader) seatsLocked() []catalogue.Seat { + ds := make([]seatdef.Definition, 0, len(r.defs)) + for _, d := range r.defs { + ds = append(ds, d) + } + return catalogue.SeatsFromDefinitions(ds) +} + +// Reload asks the owner for every trunk definition and holds what it answers, a seat the owner holds replacing the +// bootstrap's. An answer that cannot be read is returned and changes nothing. +func (r *Reader) Reload() error { + raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{}) + if err != nil { + return fmt.Errorf("seatlist: asking %s.%s: %w", r.cfg.Owner, Tool, err) + } + var l Listing + if err := json.Unmarshal(raw, &l); err != nil { + return fmt.Errorf("seatlist: %s.%s answered what is not a listing: %w", r.cfg.Owner, Tool, err) + } + parsed := map[string]seatdef.Definition{} + for _, e := range l.Definitions { + if e.Source != SourceTrunk || e.Unparsed != "" || len(e.Definition) == 0 { + continue + } + d, err := seatdef.Parse(e.Definition) + if err != nil { + return fmt.Errorf("seatlist: the definition of %s does not parse: %w", e.Seat, err) + } + parsed[d.Seat] = d + } + r.mu.Lock() + for seat := range r.defs { + if !r.boot[seat] { + delete(r.defs, seat) + } + } + for seat, d := range parsed { + r.defs[seat] = d + delete(r.boot, seat) + } + r.mu.Unlock() + r.changed() + return nil +} + +// heard takes the owner's event: a trunk definition moved, so that seat is asked again. An error is returned so the +// bus offers the event again. +func (r *Reader) heard(_ string, body json.RawMessage) error { + var c Changed + if err := json.Unmarshal(body, &c); err != nil || c.Seat == "" { + return nil + } + if c.Source != SourceTrunk { + return nil + } + raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{"seat": c.Seat}) + if err != nil { + return err + } + var a Answer + if err := json.Unmarshal(raw, &a); err != nil { + return err + } + r.mu.Lock() + switch { + case !a.Found || a.Entry == nil || a.Entry.Source != SourceTrunk: + if !r.boot[c.Seat] { + delete(r.defs, c.Seat) + } + case a.Entry.Unparsed != "" || len(a.Entry.Definition) == 0: + // the definition that stands stays: a trunk's that does not parse is never written over it + default: + d, err := seatdef.Parse(a.Entry.Definition) + if err != nil { + r.mu.Unlock() + return fmt.Errorf("seatlist: the definition of %s does not parse: %w", c.Seat, err) + } + r.defs[c.Seat] = d + delete(r.boot, c.Seat) + } + r.mu.Unlock() + r.changed() + return nil +} + +func (r *Reader) changed() { + if r.cfg.OnChange != nil { + r.cfg.OnChange(r.Seats()) + } +} diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go new file mode 100644 index 0000000..18750f8 --- /dev/null +++ b/go/seatlist/seatlist_test.go @@ -0,0 +1,129 @@ +package seatlist + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// fakeOwner is the definitions' owner: the tool answers from its table and the event is delivered by the test. +type fakeOwner struct { + table map[string]string // seat -> definition JSON, trunk + down bool + asked []string + handler func(string, json.RawMessage) error + pattern string +} + +func (f *fakeOwner) Ask(key string, body any) (json.RawMessage, error) { + f.asked = append(f.asked, key) + if f.down { + return nil, errors.New("no answer") + } + args, _ := body.(map[string]any) + if seat, _ := args["seat"].(string); seat != "" { + raw, ok := f.table[seat] + if !ok { + return json.Marshal(Answer{Seat: seat}) + } + return json.Marshal(Answer{Seat: seat, Found: true, Entry: &Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)}}) + } + l := Listing{Revision: 7} + for seat, raw := range f.table { + l.Definitions = append(l.Definitions, Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)}) + } + return json.Marshal(l) +} + +func (f *fakeOwner) Subscribe(pattern string, h func(string, json.RawMessage) error) error { + f.pattern, f.handler = pattern, h + return nil +} + +func (f *fakeOwner) say(seat, source string) error { + b, _ := json.Marshal(Changed{Seat: seat, Source: source, Digest: "d"}) + return f.handler("mesh-owner."+ChangedEvent, b) +} + +func def(seat, scope, verb string) string { + return `{"format":1,"seat":"` + seat + `","scope":"` + scope + `","grain":"seat-list","decision":"ADR 1",` + + `"verbs":[{"name":"` + verb + `","input":{"type":"object"}}]}` +} + +func names(r *Reader) string { + var out []string + for _, s := range r.Seats() { + out = append(out, s.Name) + } + return strings.Join(out, ",") +} + +// ADR 0635 §1, §3: the seat list comes from the owner on the bus, starting from the bootstrap, and follows its event. +func TestTheSeatListIsReadFromTheOwnerAndFollowsItsEvent(t *testing.T) { + boot, err := seatdef.Parse([]byte(def("boot-seat", "mesh", "go"))) + if err != nil { + t.Fatal(err) + } + owner := &fakeOwner{down: true, table: map[string]string{ + "node-b": def("node-b", "node", "put"), "mesh-a": def("mesh-a", "mesh", "get"), "boot-seat": def("boot-seat", "mesh", "other")}} + var told [][]catalogue.Seat + r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: []seatdef.Definition{boot}, + OnChange: func(s []catalogue.Seat) { told = append(told, s) }}) + if err != nil { + t.Fatal(err) + } + if owner.pattern != "*."+ChangedEvent { + t.Fatalf("subscribed to %q", owner.pattern) + } + steps := []struct { + name string + act func() error + want string + err string + }{ + {"the owner has not answered: the bootstrap stands", func() error { return nil }, "boot-seat", ""}, + {"an event while the owner is down is offered again", func() error { return owner.say("mesh-a", SourceTrunk) }, "boot-seat", "no answer"}, + {"the owner answers: its seats in reading order, mesh seats first, the owner's definition replacing the bootstrap's", + func() error { owner.down = false; return r.Reload() }, "boot-seat,mesh-a,node-b", ""}, + {"a moved definition is asked again", func() error { + owner.table["mesh-a"] = def("mesh-a", "mesh", "renamed") + return owner.say("mesh-a", SourceTrunk) + }, "boot-seat,mesh-a,node-b", ""}, + {"a seat the owner holds no more goes", func() error { delete(owner.table, "node-b"); return owner.say("node-b", SourceTrunk) }, "boot-seat,mesh-a", ""}, + {"a pull request's definition is not held", func() error { owner.asked = nil; return owner.say("mesh-z", "pull") }, "boot-seat,mesh-a", ""}, + } + for _, s := range steps { + err := s.act() + if (err == nil) != (s.err == "") || (err != nil && !strings.Contains(err.Error(), s.err)) { + t.Fatalf("%s: error %v, want %q", s.name, err, s.err) + } + if got := names(r); got != s.want { + t.Fatalf("%s: seats %q, want %q", s.name, got, s.want) + } + } + if len(owner.asked) != 0 { + t.Fatalf("a pull request's event asked the owner: %v", owner.asked) + } + if v := r.Seats()[1].Serves[0]; v.Name != "renamed" || v.Input["type"] != "object" { + t.Fatalf("the moved definition's verb is %+v, want renamed ", v) + } + if len(told) != 3 { + t.Fatalf("OnChange was told %d times", len(told)) + } +} + +func TestStartRefusesWhatCannotRead(t *testing.T) { + for name, cfg := range map[string]Config{ + "no bus": {Owner: "m"}, + "an owner with a dot": {Owner: "m.tool", Bus: &fakeOwner{}}, + "no owner": {Bus: &fakeOwner{}}, + } { + if _, err := Start(cfg); err == nil { + t.Fatalf("%s: started", name) + } + } +} diff --git a/go/seatlist/stdiobus.go b/go/seatlist/stdiobus.go new file mode 100644 index 0000000..5ba2c4f --- /dev/null +++ b/go/seatlist/stdiobus.go @@ -0,0 +1,18 @@ +package seatlist + +import ( + "encoding/json" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +type stdioBus struct{} + +// StdioBus is the bus a bundle has: the runtime's ask and subscribe over its pipe. Call Start once Serve runs. +func StdioBus() Bus { return stdioBus{} } + +func (stdioBus) Ask(key string, body any) (json.RawMessage, error) { return stdio.Ask(key, body) } + +func (stdioBus) Subscribe(pattern string, handle func(key string, body json.RawMessage) error) error { + return stdio.Subscribe(pattern, func(e stdio.Envelope) error { return handle(e.Key, e.Body) }) +} -- 2.54.0 From 651c28e70d4c28105ed8c7b9aacee33f3d626279 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 12:46:05 +0100 Subject: [PATCH 087/144] =?UTF-8?q?Seat=20definition=20format:=20states=20?= =?UTF-8?q?replace=20records=20(templates,=20writer,=20key=20forms=20with?= =?UTF-8?q?=20value=20schemas;=20ADR=200608=20=C2=A71,=20ADR=200634=20?= =?UTF-8?q?=C2=A76;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatdef/seatdef.go | 144 ++++++++++++++++++++++++++++++++----- go/seatdef/seatdef_test.go | 49 ++++++++++++- 2 files changed, 175 insertions(+), 18 deletions(-) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 6017b36..37626d4 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -28,7 +28,7 @@ const MaxSize = 256 << 10 const Format = 1 // The two grains a definition is written at (ADR 0613 §1, §2; ADR 0620 §1): whole, with every verb's messages, its -// events and their bodies, its records and its provisions' forms; or the seat list's, holding what mesh-sdk's +// events and their bodies, its states and its provisions' forms; or the seat list's, holding what mesh-sdk's // compiled seat list held, until its area's contract next changes. const ( Whole = "whole" @@ -74,8 +74,9 @@ type Definition struct { // HolderEvents are the holder's own events that belong with the seat, listed beside it and never moved onto // its subject (ADR 0620 §2): they keep the holder's subjects. HolderEvents []HolderEvent `json:"holder-events,omitempty"` - // Records are the states and records the holder keeps for the seat. - Records []Record `json:"records,omitempty"` + // States are the layouts of the state the seat's holder keeps on the bus, and the callers' writes into it (ADR + // 0608 §1, ADR 0634 §6). + States []State `json:"states,omitempty"` // Provisions are the forms of the provisions the seat's holder answers. Provisions []Provision `json:"provisions,omitempty"` // Receives and Paths are the seat list's contributions and owned paths. @@ -129,11 +130,48 @@ type Family struct { // EventsStream, whose test holds the two equal). const SharedEventsMaxAgeDays = 7 -// Record is one state the holder keeps for the seat: its key's form and its value's schema. -type Record struct { - Name string `json:"name"` - Key string `json:"key,omitempty"` - Value map[string]any `json:"value,omitempty"` +// The templates a state is kept in (ADR 0595 §2): the same six names mesh-sdk's catalogue gives a module's `requires` +// entry; a unit test holds the two lists equal. This package imports no catalogue. +const ( + TemplateState = "state" + TemplateEvents = "events" + TemplateWorkQueue = "work-queue" + TemplateLog = "log" + TemplateObjects = "objects" + TemplateLedger = "ledger" +) + +// Templates are the six, in the order ADR 0595 gives them. +var Templates = []string{TemplateState, TemplateEvents, TemplateWorkQueue, TemplateLog, TemplateObjects, TemplateLedger} + +// The writers of a state: the seat's holder (the default), or its callers, each writing its own key. +const ( + WriterHolder = "holder" + WriterCaller = "caller" +) + +// State is one state of the seat, by its local name: the holder's name is never in it, since a contract names no +// holder (ADR 0625 §4, §7); each reader takes the holder from its own manifest's `reads` entry. A state is kept in +// one of the templates, and its keys have one form each, with the schema of the value under it. +type State struct { + Name string `json:"name"` + Template string `json:"template"` + // Writer is WriterHolder when empty. + Writer string `json:"writer,omitempty"` + KeyForms []KeyForm `json:"key-forms"` + // History is how many past values a TemplateState's key keeps; MaxAgeDays is how long a stream template + // (events, work queue, log) keeps a message. Neither is given where the template has no such setting. + History int `json:"history,omitempty"` + MaxAgeDays int `json:"max-age-days,omitempty"` + Description string `json:"description,omitempty"` +} + +// KeyForm is one form a state's key takes: a pattern of named parts (`.`, a part between angle brackets, +// the rest literal) and the JSON Schema of the value kept under keys of that form. +type KeyForm struct { + Key string `json:"key"` + Value map[string]any `json:"value"` + Description string `json:"description,omitempty"` } // Provision is the form of a provision the seat's holder answers. @@ -271,7 +309,7 @@ func ParseFixtures(raw []byte) ([]Fixture, error) { } if _, _, ok := f.Instance(); !ok { ps = append(ps, Problem{where + ".of", fmt.Sprintf("%q is none of verb:.input, verb:.answer, "+ - "accept:, event:, holder-event:", f.Of)}) + "accept:, event:, holder-event:, state:/", f.Of)}) } if f.Given == nil { ps = append(ps, Problem{where + ".given", "missing"}) @@ -292,6 +330,11 @@ func (f Fixture) Instance() (kind, name string, ok bool) { switch k { case "accept", "event", "holder-event": return k, rest, true + case "state": + // state:/: one value of the state under that key form. + if n, form, ok := strings.Cut(rest, "/"); ok && n != "" && form != "" { + return "state", rest, true + } case "verb": if n, ok := strings.CutSuffix(rest, ".input"); ok && n != "" { return "verb-input", n, true @@ -356,10 +399,10 @@ func (d Definition) check() Problems { ps = append(ps, Problem{"decision", "missing"}) } whole := d.Grain == Whole - if !whole && (len(d.Includes) > 0 || d.Family != nil || len(d.HolderEvents) > 0 || len(d.Records) > 0 || + if !whole && (len(d.Includes) > 0 || d.Family != nil || len(d.HolderEvents) > 0 || len(d.States) > 0 || len(d.Provisions) > 0 || len(d.Defs) > 0) { ps = append(ps, Problem{"grain", "seat-list holds the seat list's fields only; includes, family, " + - "holder-events, records, provisions and defs are a whole definition's"}) + "holder-events, states, provisions and defs are a whole definition's"}) } if d.HolderDeclares && !whole { ps = append(ps, Problem{"holder-declares", "only a whole definition describes a seat its holder declares"}) @@ -403,11 +446,7 @@ func (d Definition) check() Problems { ps = append(ps, Problem{where + ".subject", fmt.Sprintf("not %s, nor %s for an event kept in the holder's own stream", own, stream)}) } } - for i, r := range d.Records { - if !wordName.MatchString(r.Name) { - ps = append(ps, Problem{fmt.Sprintf("records[%d].name", i), "missing, or not a lower-case name"}) - } - } + ps = append(ps, checkStates(d.States)...) for i, p := range d.Provisions { where := fmt.Sprintf("provisions[%d]", i) if p.Name == "" { @@ -430,6 +469,79 @@ func (d Definition) check() Problems { return ps } +var ( + keyPart = regexp.MustCompile(`<[^<>]*>`) + keyPartOK = regexp.MustCompile(`^<[a-z][a-z0-9-]*>$`) + keyLiteral = regexp.MustCompile(`^[A-Za-z0-9_.\-/=:]*$`) +) + +func checkStates(ss []State) Problems { + var ps Problems + seen := map[string]bool{} + for i, s := range ss { + where := fmt.Sprintf("states[%d]", i) + if !wordName.MatchString(s.Name) { + ps = append(ps, Problem{where + ".name", "missing, or not a lower-case name"}) + } else if seen[s.Name] { + ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", s.Name)}) + } + seen[s.Name] = true + known := false + for _, t := range Templates { + known = known || s.Template == t + } + switch { + case s.Template == "": + ps = append(ps, Problem{where + ".template", "missing"}) + case !known: + ps = append(ps, Problem{where + ".template", fmt.Sprintf("%q is none of %s", s.Template, strings.Join(Templates, ", "))}) + } + if s.Writer != "" && s.Writer != WriterHolder && s.Writer != WriterCaller { + ps = append(ps, Problem{where + ".writer", fmt.Sprintf("%q is neither holder nor caller", s.Writer)}) + } + if s.History < 0 || (s.History > 0 && s.Template != TemplateState) { + ps = append(ps, Problem{where + ".history", "a count of past values, of a state template only"}) + } + stream := s.Template == TemplateEvents || s.Template == TemplateWorkQueue || s.Template == TemplateLog + if s.MaxAgeDays < 0 || (s.MaxAgeDays > 0 && !stream) { + ps = append(ps, Problem{where + ".max-age-days", "days, of an events, work-queue or log template only"}) + } + if len(s.KeyForms) == 0 { + ps = append(ps, Problem{where + ".key-forms", "missing: a state gives at least one key form"}) + } + forms := map[string]bool{} + for j, f := range s.KeyForms { + at := fmt.Sprintf("%s.key-forms[%d]", where, j) + if f.Key == "" { + ps = append(ps, Problem{at + ".key", "missing"}) + } else if bad := badKey(f.Key); bad != "" { + ps = append(ps, Problem{at + ".key", bad}) + } else if forms[f.Key] { + ps = append(ps, Problem{at + ".key", fmt.Sprintf("%q twice", f.Key)}) + } + forms[f.Key] = true + if f.Value == nil { + ps = append(ps, Problem{at + ".value", fmt.Sprintf("missing: key form %q gives its value's schema", f.Key)}) + } + } + } + return ps +} + +// badKey says what is wrong with a key pattern: a part between angle brackets that is not a lower-case name, or a +// literal outside what a bus key may hold. +func badKey(key string) string { + for _, part := range keyPart.FindAllString(key, -1) { + if !keyPartOK.MatchString(part) { + return fmt.Sprintf("part %s is not a lower-case name between < and >", part) + } + } + if lit := keyPart.ReplaceAllString(key, ""); !keyLiteral.MatchString(lit) || strings.ContainsAny(lit, "<>") { + return fmt.Sprintf("%q holds a character a key may not", key) + } + return "" +} + func checkVerbs(vs []Verb, whole bool) Problems { var ps Problems seen := map[string]bool{} diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index e385e38..fccd43b 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -4,6 +4,8 @@ import ( "errors" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // TestADefinitionIsReadStrictlyNamingWhatIsWrong is the format's own table (design 26: a valid definition parses; @@ -50,8 +52,27 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { {"a holder's event on a subject of no module", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ `"subject":"mesh.event.verdict"}],"family":`, 1), "holder-events[0].subject: not mesh.mod..event.verdict"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, - {"records at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, - `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, + {"states at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, + `"decision":"ADR 1","states":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, + {"the old records field is gone", strings.Replace(whole, `"decision":"ADR 1",`, + `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "records: a field the format does not know"}, + {"a state with a key form and a value", withState(whole, `{"name":"asks","template":"state","writer":"caller", + "history":3,"key-forms":[{"key":".","value":{"type":"object"}}]}`), ""}, + {"a stream state with an age", withState(whole, `{"name":"placement","template":"events","max-age-days":7, + "key-forms":[{"key":"changed.","value":{"type":"object"}}]}`), ""}, + {"a state of an unknown template", withState(whole, `{"name":"asks","template":"bucket", + "key-forms":[{"key":"","value":{"type":"object"}}]}`), `states[0].template: "bucket" is none of state, events`}, + {"a key form with no value", withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":""}]}`), + `states[0].key-forms[0].value: missing: key form ""`}, + {"a state with no key form", withState(whole, `{"name":"asks","template":"state"}`), "states[0].key-forms: missing"}, + {"a key part that is no name", withState(whole, `{"name":"asks","template":"state", + "key-forms":[{"key":".","value":{}}]}`), "part is not a lower-case name"}, + {"a state written by neither", withState(whole, `{"name":"asks","template":"state","writer":"anyone", + "key-forms":[{"key":"","value":{}}]}`), "states[0].writer"}, + {"history of a stream", withState(whole, `{"name":"asks","template":"log","history":2, + "key-forms":[{"key":"","value":{}}]}`), "states[0].history"}, + {"a state twice", withState(withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":"","value":{}}]}`), + `{"name":"asks","template":"log","key-forms":[{"key":"","value":{}}]}`), `"asks" twice`}, {"over 256 KiB", listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) + `"}`, ErrTooLarge.Error()}, } @@ -106,6 +127,12 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { "subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil { t.Errorf("fixtures: %v", err) } + if _, err := ParseFixtures([]byte(`[{"name":"a","of":"state:asks/.","given":{}}]`)); err != nil { + t.Errorf("a state's fixture: %v", err) + } + if _, err := ParseFixtures([]byte(`[{"name":"a","of":"state:asks","given":{}}]`)); err == nil { + t.Error("a fixture of a state with no key form parsed") + } if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list","given":{}}]`)); err == nil || !strings.Contains(err.Error(), "fixtures[0].of") { t.Errorf("a fixture of nothing known: %v", err) @@ -114,3 +141,21 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { t.Error("an index naming a seat twice parsed") } } + +// withState adds one state to a whole definition's JSON. +func withState(def, state string) string { + if strings.Contains(def, `"states":[`) { + return strings.Replace(def, `"states":[`, `"states":[`+state+`,`, 1) + } + return strings.Replace(def, `"decision":"ADR 1",`, `"decision":"ADR 1","states":[`+state+`],`, 1) +} + +// TestTheTemplatesAreTheCataloguesSix holds this reader's list of templates equal to the one a module's `requires` +// entry takes (ADR 0595 §2), so a seventh template cannot be added to one and not the other. +func TestTheTemplatesAreTheCataloguesSix(t *testing.T) { + want := []string{catalogue.TemplateState, catalogue.TemplateEvents, catalogue.TemplateWorkQueue, + catalogue.TemplateLog, catalogue.TemplateObjects, catalogue.TemplateLedger} + if strings.Join(Templates, ",") != strings.Join(want, ",") { + t.Errorf("templates %v, the catalogue's %v", Templates, want) + } +} -- 2.54.0 From 50258215b40f3a252fccb83e1487b53b63556156 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 12:47:46 +0100 Subject: [PATCH 088/144] Seatdef: the templates test moves to an external test package, the catalogue now reads definitions (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatdef/seatdef_test.go | 12 ------------ go/seatdef/templates_test.go | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 go/seatdef/templates_test.go diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index fccd43b..43881f4 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -4,8 +4,6 @@ import ( "errors" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" ) // TestADefinitionIsReadStrictlyNamingWhatIsWrong is the format's own table (design 26: a valid definition parses; @@ -149,13 +147,3 @@ func withState(def, state string) string { } return strings.Replace(def, `"decision":"ADR 1",`, `"decision":"ADR 1","states":[`+state+`],`, 1) } - -// TestTheTemplatesAreTheCataloguesSix holds this reader's list of templates equal to the one a module's `requires` -// entry takes (ADR 0595 §2), so a seventh template cannot be added to one and not the other. -func TestTheTemplatesAreTheCataloguesSix(t *testing.T) { - want := []string{catalogue.TemplateState, catalogue.TemplateEvents, catalogue.TemplateWorkQueue, - catalogue.TemplateLog, catalogue.TemplateObjects, catalogue.TemplateLedger} - if strings.Join(Templates, ",") != strings.Join(want, ",") { - t.Errorf("templates %v, the catalogue's %v", Templates, want) - } -} diff --git a/go/seatdef/templates_test.go b/go/seatdef/templates_test.go new file mode 100644 index 0000000..88bac53 --- /dev/null +++ b/go/seatdef/templates_test.go @@ -0,0 +1,20 @@ +package seatdef_test + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// (An external test: the catalogue reads definitions, so seatdef's own tests cannot import it.) +// TestTheTemplatesAreTheCataloguesSix holds this reader's list of templates equal to the one a module's `requires` +// entry takes (ADR 0595 §2), so a seventh template cannot be added to one and not the other. +func TestTheTemplatesAreTheCataloguesSix(t *testing.T) { + want := []string{catalogue.TemplateState, catalogue.TemplateEvents, catalogue.TemplateWorkQueue, + catalogue.TemplateLog, catalogue.TemplateObjects, catalogue.TemplateLedger} + if strings.Join(seatdef.Templates, ",") != strings.Join(want, ",") { + t.Errorf("templates %v, the catalogue's %v", seatdef.Templates, want) + } +} -- 2.54.0 From 7ea960fe200c21ec671d767128380fa4485f8738 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 12:49:36 +0100 Subject: [PATCH 089/144] =?UTF-8?q?Seat=20list=20reader:=20its=20status=20?= =?UTF-8?q?for=20a=20module's=20health,=20and=20the=20foundation's=20boots?= =?UTF-8?q?trapped=20definitions=20(ADR=200635=20=C2=A73;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatlist/bootstrap.go | 42 + go/seatlist/bootstrap/mesh-broker.json | 569 +++++++++ go/seatlist/bootstrap/mesh-catalog.json | 69 ++ go/seatlist/bootstrap/mesh-config.json | 222 ++++ .../bootstrap/mesh-delivery-checker.json | 245 ++++ go/seatlist/bootstrap/mesh-hand-acts.json | 97 ++ go/seatlist/bootstrap/mesh-watchdogs.json | 93 ++ go/seatlist/bootstrap/node-backup.json | 80 ++ go/seatlist/bootstrap/node-engine.json | 1055 +++++++++++++++++ go/seatlist/seatlist.go | 63 +- go/seatlist/seatlist_test.go | 28 + 11 files changed, 2559 insertions(+), 4 deletions(-) create mode 100644 go/seatlist/bootstrap.go create mode 100644 go/seatlist/bootstrap/mesh-broker.json create mode 100644 go/seatlist/bootstrap/mesh-catalog.json create mode 100644 go/seatlist/bootstrap/mesh-config.json create mode 100644 go/seatlist/bootstrap/mesh-delivery-checker.json create mode 100644 go/seatlist/bootstrap/mesh-hand-acts.json create mode 100644 go/seatlist/bootstrap/mesh-watchdogs.json create mode 100644 go/seatlist/bootstrap/node-backup.json create mode 100644 go/seatlist/bootstrap/node-engine.json diff --git a/go/seatlist/bootstrap.go b/go/seatlist/bootstrap.go new file mode 100644 index 0000000..7559f8e --- /dev/null +++ b/go/seatlist/bootstrap.go @@ -0,0 +1,42 @@ +package seatlist + +import ( + "embed" + "fmt" + "sort" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// The foundation's bootstrapped definitions (novox/hq ADR 0608 §2, ADR 0635 §3): the seats the foundation's parts claim +// and invoke before the indexer has run, kept as the definitions' own files (`bootstrap/.json`, the format's own +// text, whole or at the seat list's grain) and embedded as data. A reader holds them until the owner answers, and says so +// (Status). **They are copies:** a seat's definition is the one in its contracts module, and a test there holds each of +// these files equal to it; where a seat moves or changes, its file here is replaced by the same change. Which seats are +// the foundation's, and how a fresh mesh gets them without a copy, is research 075's. +// +//go:embed bootstrap/*.json +var bootstrapFiles embed.FS + +// Bootstrapped are the foundation's definitions, by seat name. It panics on a file that does not parse, which the +// package's test refuses to let ship. +func Bootstrapped() []seatdef.Definition { + entries, err := bootstrapFiles.ReadDir("bootstrap") + if err != nil { + panic(err) + } + var out []seatdef.Definition + for _, e := range entries { + raw, err := bootstrapFiles.ReadFile("bootstrap/" + e.Name()) + if err != nil { + panic(err) + } + d, err := seatdef.Parse(raw) + if err != nil { + panic(fmt.Sprintf("seatlist: bootstrap/%s does not parse: %v", e.Name(), err)) + } + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i].Seat < out[j].Seat }) + return out +} diff --git a/go/seatlist/bootstrap/mesh-broker.json b/go/seatlist/bootstrap/mesh-broker.json new file mode 100644 index 0000000..f0ebf4a --- /dev/null +++ b/go/seatlist/bootstrap/mesh-broker.json @@ -0,0 +1,569 @@ +{ + "format": 1, + "seat": "mesh-broker", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0079", + "delivers": "mesh-bus", + "description": "The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone.", + "verbs": [ + { + "name": "list", + "description": "Every entry the bus module created, its template, its settings and its fill against its cap.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "answer": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/defs/Held" + }, + "description": "every entry" + } + }, + "required": [ + "entries" + ] + }, + "optional": true + }, + { + "name": "show", + "description": "One entry, its template, settings and fill.", + "callers": "any caller whose role grants it", + "input": { + "$ref": "#/defs/Named" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "check", + "description": "Compare what the bus holds with what was asked, entry by entry; a difference that only grows is said as such.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "answer": { + "type": "object", + "properties": { + "differences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry" + }, + "asked": { + "type": "object", + "description": "asked" + }, + "held": { + "type": "object", + "description": "held" + }, + "grows": { + "type": "boolean", + "description": "the difference only grows: apply may act on it" + } + }, + "required": [ + "module", + "local-name" + ] + }, + "description": "every difference" + } + }, + "required": [ + "differences" + ] + }, + "optional": true + }, + { + "name": "orphans", + "description": "Entries the bus holds that no manifest asks for; never one whose manifest the judge refused or outgrew, nor one of the foundation's old sections.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/defs/Held" + }, + "description": "every orphan" + } + }, + "required": [ + "entries" + ] + }, + "optional": true + }, + { + "name": "create", + "description": "Create one entry now.", + "callers": "the bus module's own process only", + "input": { + "$ref": "#/defs/Entry" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "apply", + "description": "Apply a difference that only grows: a larger cap, a longer age.", + "callers": "the bus module's own process only", + "input": { + "$ref": "#/defs/Named" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "recreate", + "description": "Rebuild an entry whose settings differ in a way that cannot grow, from its data where the storage type allows, after a stream snapshot.", + "callers": "the bus module's process, on the operator's warrant", + "input": { + "$ref": "#/defs/Warranted" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "drop", + "description": "Remove one orphan, after a stream snapshot.", + "callers": "the bus module's process, on the operator's warrant", + "input": { + "$ref": "#/defs/Warranted" + }, + "answer": { + "type": "object", + "properties": { + "dropped": { + "type": "boolean", + "description": "it is gone" + }, + "snapshot": { + "type": "string", + "description": "the snapshot taken first" + } + }, + "required": [ + "dropped" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "created", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "refused", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "drifted", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "drift-cleared", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "full", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "full-cleared", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "orphaned", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "recreated", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "dropped", + "body": { + "$ref": "#/defs/Said" + } + } + ], + "family": { + "name": "mesh-broker.events", + "max-age-days": 7 + }, + "provisions": [ + { + "name": "bus-state", + "form": { + "$ref": "#/defs/Binding" + } + } + ], + "defs": { + "Entry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + } + }, + "required": [ + "module", + "local-name", + "storage-type" + ], + "description": "one piece of bus state as a manifest asks it" + }, + "Fill": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "messages, keys or objects held" + }, + "bytes": { + "type": "integer", + "description": "bytes held" + }, + "cap": { + "type": "integer", + "description": "its cap in bytes" + } + }, + "required": [ + "count", + "bytes", + "cap" + ] + }, + "Held": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + }, + "bus-name": { + "type": "string", + "description": "its name on the bus, _" + }, + "settings": { + "type": "object", + "description": "the settings the bus holds" + }, + "fill": { + "$ref": "#/defs/Fill" + }, + "state": { + "type": "string", + "enum": [ + "held", + "refused", + "waiting", + "drifted", + "full", + "orphaned" + ], + "description": "the entry's state: held as asked, refused, waiting to be made, drifted from what is asked, full, or orphaned (no manifest asks it)" + }, + "why": { + "type": "string", + "description": "why it is in its state, in plain words; empty when held as asked" + } + }, + "required": [ + "module", + "local-name", + "storage-type", + "bus-name" + ], + "description": "one entry as the bus holds it" + }, + "Said": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + }, + "bus-name": { + "type": "string", + "description": "its name on the bus" + }, + "asked": { + "type": "object", + "description": "the settings asked" + }, + "held": { + "type": "object", + "description": "the settings held" + }, + "why": { + "type": "string", + "description": "why: a refusal's cause, a drift's difference, a drop's or recreate's reason" + }, + "fill": { + "$ref": "#/defs/Fill" + }, + "snapshot": { + "type": "string", + "description": "the stream snapshot taken before a recreate or drop" + }, + "binding": { + "$ref": "#/defs/Binding" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict the bus module acted on (ADR 0622 §2), so a reader orders what it hears" + } + }, + "required": [ + "module", + "local-name", + "bus-name" + ], + "description": "the body of every event of the seat" + }, + "Named": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry's local name" + } + }, + "required": [ + "module", + "local-name" + ] + }, + "Warranted": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry's local name" + }, + "warrant": { + "type": "string", + "description": "the operator's warrant, as the operator channel decided it (ADR 0259 §6)" + }, + "why": { + "type": "string", + "description": "why, kept in the record" + } + }, + "required": [ + "module", + "local-name", + "warrant", + "why" + ] + }, + "Binding": { + "type": "object", + "properties": { + "bus-name": { + "type": "string", + "description": "the entry's name on the bus" + }, + "storage-type": { + "type": "string", + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "description": "its template" + }, + "subjects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "its subjects, for a stream" + } + }, + "required": [ + "bus-name", + "storage-type" + ], + "description": "the binding a module receives in its membership for one entry (ADR 0595 §1, §7)" + } + } +} diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json new file mode 100644 index 0000000..40b6618 --- /dev/null +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -0,0 +1,69 @@ +{ + "format": 1, + "seat": "mesh-catalog", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0604", + "verbs": [ + { + "name": "modules", + "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "module", + "description": "One module whole: its repository, directory, commit and fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + }, + { + "name": "provides", + "description": "Which modules provide a provision (a module provides its own name too) and which require it, from the module graph's provision edges. Reads only.", + "optional": true, + "input": { + "properties": { + "provision": { + "description": "the provision, such as postgres-database", + "type": "string" + } + }, + "required": [ + "provision" + ], + "type": "object" + } + }, + { + "name": "dependents", + "description": "The modules whose build edges reach a module, directly or through others (a base, a mesh package it publishes, the build module that builds them): what a change of it builds again, from the module graph. Reads only.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/bootstrap/mesh-config.json b/go/seatlist/bootstrap/mesh-config.json new file mode 100644 index 0000000..d095825 --- /dev/null +++ b/go/seatlist/bootstrap/mesh-config.json @@ -0,0 +1,222 @@ +{ + "format": 1, + "seat": "mesh-config", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0348, ADR 0565", + "verbs": [ + { + "name": "config", + "description": "Set, clear, list or read the history of config values: mesh config or node config for a predefined key, or a module's own value on one node, each set with why, validated, and naming each module on each node whose value moves.", + "optional": true, + "input": { + "properties": { + "action": { + "description": "list (when not given), set, clear or history", + "type": "string" + }, + "by": { + "description": "who gave the value at the controller's terminal", + "type": "string" + }, + "key": { + "description": "the config key", + "type": "string" + }, + "limit": { + "description": "for history: at most this many, newest", + "type": "number" + }, + "module": { + "description": "a module: its own value, with node", + "type": "string" + }, + "node": { + "description": "a node", + "type": "string" + }, + "value": { + "description": "for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list" + }, + "why": { + "description": "for set and clear: why", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the values asked, or the one set or cleared, or its history" + } + }, + { + "name": "renames", + "optional": true, + "description": "The open renames of config values (design 32 *A config value renamed*): per rename the module, the old and new name, when it moved, and the nodes still holding the old name open.", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "callers": "any caller whose role grants it; the checker asks it", + "answer": { + "type": "object", + "properties": { + "renames": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "from": { + "type": "string", + "description": "the old name" + }, + "to": { + "type": "string", + "description": "the new name" + }, + "at": { + "type": "string", + "description": "when it moved, RFC 3339" + }, + "open": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the nodes still holding the old name open" + } + }, + "required": [ + "module", + "from", + "to" + ] + }, + "description": "every open rename" + } + }, + "required": [ + "renames" + ] + } + } + ], + "description": "The mesh's config values: read and set one, and the renames still open. mesh-config's own events stay under its own subjects (ADR 0620 §2).", + "holder-events": [ + { + "name": "config-changed", + "subject": "mesh.mod..event.config-changed" + }, + { + "name": "renamed", + "subject": "mesh.stream..event.renamed", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "from": { + "type": "string", + "description": "the old name" + }, + "to": { + "type": "string", + "description": "the new name" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + } + }, + "required": [ + "module", + "from", + "to", + "at" + ] + } + }, + { + "name": "retiring", + "subject": "mesh.stream..event.retiring", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "key": { + "type": "string", + "description": "the value's or secret's name" + }, + "kind": { + "type": "string", + "enum": [ + "value", + "secret" + ] + }, + "since": { + "type": "string", + "description": "since when, RFC 3339" + }, + "ask": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "module", + "key", + "kind", + "since" + ] + } + }, + { + "name": "retired", + "subject": "mesh.stream..event.retired", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "name": { + "type": "string", + "description": "the name" + }, + "node": { + "type": "string", + "description": "the node" + }, + "ask": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "module", + "name" + ] + } + } + ] +} diff --git a/go/seatlist/bootstrap/mesh-delivery-checker.json b/go/seatlist/bootstrap/mesh-delivery-checker.json new file mode 100644 index 0000000..7b56ab5 --- /dev/null +++ b/go/seatlist/bootstrap/mesh-delivery-checker.json @@ -0,0 +1,245 @@ +{ + "format": 1, + "seat": "mesh-delivery-checker", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0547", + "verbs": [ + { + "name": "try", + "description": "Try a pushed head before its pull request is opened: the checker judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most 4 wait or run at once, mesh-wide.", + "optional": true, + "replaces": [ + "sh merge-check.sh", + "docker run postgres" + ], + "input": { + "properties": { + "head": { + "description": "the full commit to try, 40 or 64 hex digits", + "type": "string" + }, + "level": { + "description": "unit or integration: only this level; both when absent", + "type": "string" + }, + "repository": { + "description": "/", + "type": "string" + }, + "tests": { + "description": "a go test -run pattern: only those tests run, and nothing is kept", + "type": "string" + }, + "trunk": { + "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "type": "string" + } + }, + "required": [ + "repository", + "head" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the try's id" + }, + "state": { + "type": "string", + "description": "its state" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "try-read", + "description": "Where a try stands, as stored: queued, running, or its end (pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit it was merged onto, the time it took and what failed first. Read-only.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the try's id, try-", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the try: its state, its steps and its verdict when it has one" + } + }, + { + "name": "try-end", + "description": "End a try that waits or runs, as error, by hand: say why (kept in what it says). Its place among the tries is free at once and the next try of its head asks anew with a new id. A try that ended already is refused.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the try's id, try-", + "type": "string" + }, + "why": { + "description": "why it is ended, in words", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the try" + }, + "ended": { + "type": "boolean", + "description": "it is ended" + } + }, + "required": [ + "id", + "ended" + ] + } + } + ], + "description": "The merge check's tries: start one, read one, end one. The checker's own events stay under its own subjects (ADR 0620 §2).", + "holder-events": [ + { + "name": "verdict", + "subject": "mesh.stream..event.verdict", + "family": "checker.asks", + "body": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "owner/repo" + }, + "pull": { + "type": "integer", + "description": "the pull request's number, for a pull request's check" + }, + "head": { + "type": "string", + "description": "the commit checked" + }, + "verdict": { + "type": "string", + "description": "pass or fail" + }, + "out-of-step": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the generated files out of step with their seat definitions, each path (design 26)" + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module whose artifact's series changes" + }, + "artifact": { + "type": "string", + "description": "its artifact, or the package's ecosystem name as the module's packages name it" + }, + "from": { + "type": "string", + "description": "the series on trunk" + }, + "to": { + "type": "string", + "description": "the series the checked head gives it" + } + }, + "required": [ + "module", + "artifact", + "from", + "to" + ] + }, + "description": "each change of an artifact's series the checked head makes against trunk's manifests, once each (design 32); absent when it changes none" + } + }, + "required": [ + "repository", + "head", + "verdict" + ] + } + }, + { + "name": "tag-wanted", + "subject": "mesh.stream..event.tag-wanted", + "family": "checker.asks", + "body": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "owner/repo" + }, + "pull": { + "type": "integer", + "description": "the pull request" + }, + "head": { + "type": "string", + "description": "its head" + }, + "branch": { + "type": "string", + "description": "the pull request's branch: its tag is removed when the branch ends" + }, + "module": { + "type": "string", + "description": "the group member" + }, + "package": { + "type": "string", + "description": "the package it declares" + }, + "tag": { + "type": "string", + "description": "the tag wanted" + } + }, + "required": [ + "repository", + "head", + "branch", + "module", + "package" + ] + } + }, + { + "name": "preview.written", + "subject": "mesh.stream..event.preview.written", + "family": "checks" + } + ] +} diff --git a/go/seatlist/bootstrap/mesh-hand-acts.json b/go/seatlist/bootstrap/mesh-hand-acts.json new file mode 100644 index 0000000..a789dbb --- /dev/null +++ b/go/seatlist/bootstrap/mesh-hand-acts.json @@ -0,0 +1,97 @@ +{ + "format": 1, + "seat": "mesh-hand-acts", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0565", + "verbs": [ + { + "name": "hand-act", + "description": "Record one act done by hand: what, why, its cause in a word or a condition's kind (the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the runtime proves, never said by the caller. Said as act-recorded.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word, or a condition's kind", + "type": "string" + }, + "condition": { + "description": "the key of the condition it addressed, if any (optional)", + "type": "string" + }, + "verb": { + "description": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + "type": "string" + }, + "what": { + "description": "what was done, in a line", + "type": "string" + }, + "why": { + "description": "why it had to be done by hand", + "type": "string" + } + }, + "required": [ + "what", + "why" + ], + "type": "object" + } + }, + { + "name": "hand-acts", + "description": "What was done by hand lately, over this log and the controller's: who, why and the cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + "input": { + "properties": { + "cause": { + "description": "only this cause", + "type": "string" + }, + "days": { + "description": "how many days back (default 14)", + "type": "string" + }, + "warrants": { + "description": "\"true\": every warrant recorded", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "who": { + "description": "only acts whose who contains this", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "warranted", + "description": "Record what a module did on the operator's warrant: who chose, through which channel, with which proofs and which answer read from the router's own record of that module's ask, never from the caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + "input": { + "properties": { + "ask": { + "description": "its ask's id", + "type": "string" + }, + "asker": { + "description": "the module that asked", + "type": "string" + } + }, + "required": [ + "asker", + "ask" + ], + "type": "object" + } + } + ], + "events": [ + { + "name": "act-recorded" + } + ] +} diff --git a/go/seatlist/bootstrap/mesh-watchdogs.json b/go/seatlist/bootstrap/mesh-watchdogs.json new file mode 100644 index 0000000..3ad9f3c --- /dev/null +++ b/go/seatlist/bootstrap/mesh-watchdogs.json @@ -0,0 +1,93 @@ +{ + "format": 1, + "seat": "mesh-watchdogs", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0564", + "verbs": [ + { + "name": "signals", + "description": "Every watchdog of the signals table the watchman keeps, each a key that expires unless its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its expiry wrote.", + "input": { + "properties": { + "row": { + "description": "one row, e.g. S1", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "stuck", + "description": "Every want, build or step the watchman says stopped moving past its bound, with since when and the words of its finding.", + "input": { + "properties": { + "machine": { + "description": "only those on this machine", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "presence", + "description": "Every machine and tool runner the watchman sees connected, with its last word, and those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", + "input": { + "properties": { + "machine": { + "description": "one machine", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "dead-letters", + "description": "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one whole. With deliver or drop, a hand act with why.", + "input": { + "properties": { + "cause": { + "description": "with deliver or drop: the cause in a word (dead-letter when absent)", + "type": "string" + }, + "consumer": { + "description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on", + "type": "string" + }, + "deliver": { + "description": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", + "type": "string" + }, + "drop": { + "description": "a dead letter's id: drop it for good (needs why)", + "type": "string" + }, + "id": { + "description": "a dead letter's id, as the list gives it: that one whole, with its message", + "type": "string" + }, + "limit": { + "description": "how many to list, newest first (default 50); only when listing", + "type": "string" + }, + "why": { + "description": "with deliver or drop: why, required and recorded", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "resync", + "description": "Rebuild presence from the bus's connections, and the pulses and findings from the keys the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", + "input": { + "properties": {}, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/bootstrap/node-backup.json b/go/seatlist/bootstrap/node-backup.json new file mode 100644 index 0000000..465f68f --- /dev/null +++ b/go/seatlist/bootstrap/node-backup.json @@ -0,0 +1,80 @@ +{ + "format": 1, + "seat": "node-backup", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0214", + "verbs": [ + { + "name": "backed-up", + "description": "What this machine backs up: each module, what it declared, its last good night, how many restore points are kept and the repository's size.", + "replaces": [ + "restic snapshots" + ], + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "now", + "description": "Take a backup now, of one module or of every module on this machine — before a migration, a retirement or anything else that could go wrong.", + "replaces": [ + "restic backup" + ], + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "restore", + "description": "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as \u003cpath\u003e.restored-\u003cdate\u003e. Swapping it in is a person's act. Lists the restore points when none is named.", + "replaces": [ + "restic restore" + ], + "input": { + "properties": { + "module": { + "description": "the module", + "type": "string" + }, + "path": { + "description": "one of the module's directories (all of them when omitted)", + "type": "string" + }, + "snapshot": { + "description": "the restore point (from `backed-up`; the newest when omitted)", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + } + ], + "receives": [ + { + "kind": "backup", + "comment": "#", + "dirs": true + }, + { + "kind": "data", + "comment": "#", + "dirs": true + } + ] +} diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json new file mode 100644 index 0000000..dfcaeb8 --- /dev/null +++ b/go/seatlist/bootstrap/node-engine.json @@ -0,0 +1,1055 @@ +{ + "format": 1, + "seat": "node-engine", + "scope": "node", + "grain": "whole", + "decision": "novox/hq ADR 0563", + "description": "The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.., and each event is published on mesh.seat.node-engine.event.., the node's name its last token.", + "held-by-the-engine": true, + "verbs": [ + { + "name": "hand-over", + "description": "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, §5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, reached through no link) and refuses in words, or asks the operator on their channel at the level approve, binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands it over. One ask is open per path; acts shows where it stands.", + "input": { + "properties": { + "path": { + "description": "the directory's absolute path, as the engine states it", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "answer": { + "$ref": "#/defs/Asked" + } + }, + { + "name": "access-act", + "description": "Ask the operator for the take act on a library whose root holds data outside its group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes sets the library's group, group write and setgid under its root. One ask is open per access.", + "input": { + "properties": { + "access": { + "description": "the access, library:", + "type": "string" + }, + "act": { + "description": "the act: take", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "act", + "access" + ], + "type": "object" + }, + "answer": { + "$ref": "#/defs/Asked" + } + }, + { + "name": "judge-directories", + "description": "Which of this machine's declared directories the engine would refuse to make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Said" + } + }, + { + "name": "setuid-search", + "description": "Throw away the engine's last search for setuid programs and start a full one (novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while one runs, and when one was started within the hour, naming when the next may start.", + "input": { + "properties": { + "why": { + "description": "why it is asked, in words", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Said" + } + }, + { + "name": "root-free", + "description": "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root without a person, and the login shell's execute not served here. Anything else is no pass, and says why. Answered by this machine's engine alone.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "machines": { + "type": "array", + "items": { + "$ref": "#/defs/RootVerdict" + }, + "description": "this machine's verdict" + } + }, + "required": [ + "machines" + ] + } + }, + { + "name": "acts", + "description": "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the operator's warrant. Read-only.", + "input": { + "properties": { + "id": { + "description": "one act, by its id", + "type": "string" + }, + "limit": { + "description": "at most this many, newest", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Acts" + } + }, + { + "name": "packages", + "description": "The packages this machine's compositions declare, present or absent, each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller hands its lease away, beside the controller's mirror for this machine and their difference. With write, the key written again from what the engine composed.", + "optional": true, + "input": { + "properties": { + "write": { + "description": "\"true\": write this machine's key again from what the engine composed", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "source": { + "type": "string", + "description": "where the key is read: the engine's state packages" + }, + "key": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string", + "description": "the package" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the modules that declare it" + }, + "absent": { + "type": "boolean", + "description": "declared absent" + } + }, + "required": [ + "package", + "modules", + "absent" + ] + }, + "description": "its packages sorted by name, absent after present" + } + }, + "required": [ + "node", + "packages" + ], + "description": "the node's key as the bus holds it; absent when the engine wrote none" + }, + "key_error": { + "type": "string", + "description": "why the key could not be read" + }, + "wrote": { + "type": "boolean", + "description": "with write: the key was written again (false: it already held what the engine composed)" + }, + "note": { + "type": "string", + "description": "a note, such as that no key is written yet" + } + }, + "required": [ + "node", + "source" + ] + } + }, + { + "name": "composition", + "description": "One module's composition on this machine beside what runs of it, read-only: the composition its want's build has in the bucket composed- (the latest revision, or the one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs (applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers that join or leave.", + "optional": true, + "input": { + "properties": { + "diff": { + "description": "\"true\": list the resources that differ from what runs", + "type": "string" + }, + "digest": { + "description": "a composition's digest (sha256:…), to read that revision rather than the latest", + "type": "string" + }, + "module": { + "description": "the module, as placed on this machine; node for the node's own", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "module": { + "type": "string", + "description": "the module" + }, + "want": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "its build's fingerprint" + }, + "composed": { + "type": "string", + "description": "the composition's digest it names" + }, + "yes": { + "type": "string", + "description": "the digest a person said yes to" + }, + "removed": { + "type": "boolean", + "description": "the module is removed" + }, + "self": { + "type": "boolean", + "description": "the node composes the module itself (ADR 0547 §2)" + }, + "backup": { + "type": "string", + "description": "the operator's answer to a failed backup, again or skip" + }, + "words": { + "type": "string", + "description": "its words" + }, + "revision": { + "type": "integer", + "description": "the revision of the want's key read last" + } + }, + "required": [ + "fp", + "revision" + ], + "description": "the module's want as the agent holds it; absent when it holds none" + }, + "attempt": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "the fingerprint tried" + }, + "composed": { + "type": "string", + "description": "the digest tried" + }, + "want_revision": { + "type": "integer", + "description": "the want's revision it was tried at" + }, + "composition_revision": { + "type": "integer", + "description": "the composition's revision it was tried at" + }, + "held": { + "type": "boolean", + "description": "held for a failed backup" + }, + "answered_revision": { + "type": "integer", + "description": "the want's revision the hold took" + }, + "transient": { + "type": "boolean", + "description": "it failed because the bus did not answer for a moment" + } + }, + "required": [ + "fp", + "want_revision", + "composition_revision" + ], + "description": "the module's last apply that did not end running; absent when none" + }, + "started": { + "type": "boolean", + "description": "the agent has looked at the module once since it started" + }, + "have": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "fingerprint" + }, + "composed": { + "type": "string", + "description": "digest" + }, + "phase": { + "type": "string", + "description": "running, failed, held or waiting-yes" + }, + "detail": { + "type": "string", + "description": "detail" + }, + "judgement": { + "type": "string", + "description": "judgement" + }, + "waits": { + "type": "string", + "description": "the digest that waits for a yes" + } + }, + "required": [ + "phase" + ], + "description": "what the module's have key says now; absent when it says nothing" + }, + "composition": { + "$ref": "#/defs/Revision" + }, + "revisions": { + "type": "array", + "items": { + "$ref": "#/defs/Revision" + }, + "description": "the revisions of its key the bucket keeps, oldest first" + }, + "runs": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "fingerprint" + }, + "composed": { + "type": "string", + "description": "digest" + }, + "phase": { + "type": "string", + "description": "phase" + }, + "from": { + "type": "string", + "description": "applied, have or kept" + }, + "resources": { + "type": "integer", + "description": "how many resources run" + } + }, + "required": [ + "from", + "resources" + ], + "description": "what runs of the module; absent when nothing runs" + }, + "judged": { + "type": "object", + "properties": { + "judgement": { + "type": "string", + "description": "the judgement" + }, + "words": { + "type": "string", + "description": "its words" + }, + "waits_for_yes": { + "type": "boolean", + "description": "the agent would wait for a person's yes before applying it" + } + }, + "required": [ + "judgement", + "words", + "waits_for_yes" + ], + "description": "the move from what runs to the composition read" + }, + "diff": { + "type": "array", + "items": { + "type": "object", + "description": "one resource added, removed or changed, as the engine's judge says it" + }, + "description": "every resource that differs from what runs, when asked" + }, + "own": { + "type": "object", + "properties": { + "provisioner_account": { + "type": "string", + "description": "the account the provisioner's client runs as, as the revision names it" + }, + "runs_provisioner_account": { + "type": "string", + "description": "as what runs names it" + }, + "adoption": { + "type": "object", + "description": "the revision's adoption; absent for a converged node" + }, + "identities": { + "type": "integer", + "description": "identities the revision names" + }, + "groups": { + "type": "integer", + "description": "groups" + }, + "libraries": { + "type": "integer", + "description": "libraries" + }, + "held": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "each module the node's composer held in its last pass, with why" + } + }, + "required": [ + "provisioner_account" + ], + "description": "of the node's own composition, its own fields beside what runs names" + } + }, + "required": [ + "node", + "module", + "started", + "composition", + "judged" + ] + } + }, + { + "name": "compose", + "description": "Compose this machine's modules now and say how it went, so a composition that failed, or a module held, recovers without a restart: what the pass refused or held, each with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried again. Changes only what a pass the engine makes on its own would change.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "a module placed on this machine whose apply is also tried again (optional)", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "retried": { + "type": "string", + "description": "the module whose apply was tried again" + }, + "pass": { + "type": "object", + "properties": { + "at": { + "type": "string", + "description": "when the pass ended, RFC 3339" + }, + "refused": { + "type": "string", + "description": "why the node composed none of its modules" + }, + "held": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "each module refused alone, with why" + }, + "handed": { + "type": "integer", + "description": "how many compositions the pass handed the agent that differ from before" + }, + "again": { + "type": "string", + "description": "when the node composes again on its own, RFC 3339" + }, + "earlier": { + "type": "boolean", + "description": "the answer is the last pass, made moments ago" + } + }, + "required": [ + "at", + "handed" + ], + "description": "how the pass went" + } + }, + "required": [ + "pass" + ] + } + }, + { + "name": "compose-candidate", + "description": "Compose a check's candidate on this machine in shadow, with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it composes (with its digest and whether the candidate changes it), is refused by the candidate or is held without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", + "optional": true, + "input": { + "properties": { + "candidate": { + "description": "the key of the indexer's candidate, .", + "type": "string" + }, + "input": { + "description": "also answer the composition input the candidate was composed from, for the sandbox path", + "type": "boolean" + } + }, + "required": [ + "candidate" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "candidate": { + "type": "string", + "description": "the candidate's key" + }, + "verdict": { + "type": "string", + "enum": [ + "composed", + "refused", + "unready", + "not-judged" + ], + "description": "the node's verdict (ADR 0593 §7, §9)" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/defs/Module" + }, + "description": "every module placed on the node, sorted by name" + }, + "own": { + "$ref": "#/defs/Module" + }, + "refused": { + "type": "string", + "description": "why the node could not compose with the candidate at all" + }, + "took": { + "type": "integer", + "description": "how long the engine took, in milliseconds" + }, + "input": { + "type": "object", + "description": "when asked: the composition input it composed from" + } + }, + "required": [ + "v", + "node", + "candidate", + "verdict", + "modules", + "own", + "took" + ] + } + }, + { + "name": "manifest-reads", + "optional": true, + "description": "The reader capabilities this machine's engine states (manifest words, forms and seat names it reads, novox/hq ADR 0618 §3) and when it last stated them. An engine built before it said them states none, which a reader takes as the baseline. Read-only. Answered by this machine's engine alone.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "the reader capabilities its engine states: the manifest words, forms and seat names it reads; empty for an engine built before it said them, read as the baseline" + }, + "stated-at": { + "type": "string", + "description": "when the engine last stated them, RFC 3339" + } + }, + "required": [ + "v", + "node", + "stated-at" + ], + "description": "what this machine's engine reads of a manifest (ADR 0618 §3)" + } + } + ], + "events": [ + { + "name": "asked", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "done", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "refused", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "manifest-reads-changed", + "by-caller": true, + "body": { + "$ref": "#/defs/ManifestReadsChanged" + } + } + ], + "family": { + "name": "node-engine.events", + "max-age-days": 7 + }, + "defs": { + "Act": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the act's id" + }, + "act": { + "type": "string", + "description": "the act: hand-over, access-act, judge-directories or setuid-search" + }, + "subject": { + "type": "string", + "description": "the path of a hand-over, or the access of an access act" + }, + "caller": { + "type": "string", + "description": "the caller as the runtime proved it" + }, + "why": { + "type": "string", + "description": "why it was asked, in words" + }, + "at": { + "type": "string", + "description": "when it was asked, RFC 3339" + }, + "state": { + "type": "string", + "enum": [ + "asked", + "done", + "refused", + "answered" + ], + "description": "where it stands" + }, + "said": { + "type": "string", + "description": "what the engine said of it: its outcome or why it was refused" + }, + "ended": { + "type": "string", + "description": "when it ended, RFC 3339" + }, + "ask": { + "type": "string", + "description": "the operator's ask of a hand-over or an access act" + }, + "sets": { + "type": "string", + "description": "the owner, group and mode it sets, in words" + }, + "disk": { + "type": "string", + "description": "what the engine read on disk when it judged" + }, + "path": { + "type": "string", + "description": "the directory the act changes" + }, + "expires": { + "type": "string", + "description": "when the ask ends unanswered, RFC 3339" + }, + "by": { + "type": "string", + "description": "who answered, as the operator's warrant names them" + }, + "via": { + "type": "string", + "description": "the channel the warrant came on" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the warrant's proofs" + }, + "auto": { + "type": "boolean", + "description": "the engine asked it itself, on finding a library's root holding data outside its group (ADR 0563 §4)" + } + }, + "required": [ + "id", + "act", + "caller", + "at", + "state" + ], + "description": "one act asked through the seat, as acts reads it (ADR 0563 §9)" + }, + "ActSaid": { + "type": "object", + "properties": { + "act": { + "type": "string", + "description": "the act" + }, + "subject": { + "type": "string", + "description": "the path or the access" + }, + "caller": { + "type": "string", + "description": "the caller as the runtime proved it" + }, + "ask": { + "type": "string", + "description": "the operator's ask" + }, + "state": { + "type": "string", + "enum": [ + "asked", + "done", + "refused" + ], + "description": "where the act stands" + }, + "said": { + "type": "string", + "description": "what the engine said of it" + }, + "node": { + "type": "string", + "description": "the node whose engine says it" + } + }, + "required": [ + "act", + "caller", + "state", + "node" + ], + "description": "what the seat says of an act: the act, the path or access, the caller as the runtime proved it and the ask (ADR 0563 §1)" + }, + "Acts": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "acts": { + "type": "array", + "items": { + "$ref": "#/defs/Act" + }, + "description": "the acts, newest first" + } + }, + "required": [ + "node", + "acts" + ], + "description": "the acts kept" + }, + "Asked": { + "type": "object", + "properties": { + "asked": { + "type": "string", + "description": "the ask's id: acts shows where it stands" + }, + "node": { + "type": "string", + "description": "the node" + }, + "says": { + "type": "string", + "description": "what happens next, in words" + } + }, + "required": [ + "asked", + "node", + "says" + ], + "description": "the answer of an act the operator is asked" + }, + "ManifestReadsChanged": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "what its engine states it reads now" + }, + "at": { + "type": "string", + "description": "when it says it, RFC 3339" + }, + "why": { + "type": "string", + "enum": [ + "started", + "returned" + ], + "description": "started: the engine started or what it states changed; returned: its node returned after being away" + } + }, + "required": [ + "v", + "node", + "at", + "why" + ], + "description": "what a node-engine says when what it reads changed or its node returned (ADR 0618 §4)" + }, + "Module": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module, node for the node's own composition" + }, + "fp": { + "type": "string", + "description": "the fingerprint it was composed at" + }, + "state": { + "type": "string", + "enum": [ + "composed", + "refused", + "held" + ], + "description": "composed (digest set), refused by the candidate, or held without it too (words set)" + }, + "digest": { + "type": "string", + "description": "the composition's digest when composed" + }, + "changed": { + "type": "boolean", + "description": "the candidate changes it from the one composed without it" + }, + "words": { + "type": "string", + "description": "why, for a refused or held one" + } + }, + "required": [ + "module", + "state" + ], + "description": "one module's composition with the candidate" + }, + "Revision": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "the composition's key" + }, + "revision": { + "type": "integer", + "description": "its revision" + }, + "digest": { + "type": "string", + "description": "its digest" + }, + "resources": { + "type": "integer", + "description": "how many resources it names" + }, + "latest": { + "type": "boolean", + "description": "the latest revision" + }, + "refused": { + "type": "string", + "description": "why it cannot be read or trusted" + } + }, + "required": [ + "key" + ], + "description": "one revision of a composition key" + }, + "RootVerdict": { + "type": "object", + "properties": { + "machine": { + "type": "string", + "description": "the machine" + }, + "free": { + "type": "boolean", + "description": "the machine is root-free" + }, + "why": { + "type": "string", + "description": "why, in words" + }, + "judged": { + "type": "string", + "description": "when the engine judged it, RFC 3339" + } + }, + "required": [ + "machine", + "free", + "why", + "judged" + ], + "description": "the engine's root-free verdict (ADR 0563 §6)" + }, + "Said": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "said": { + "type": "string", + "description": "the engine's judgement or search, one line per directory or program" + } + }, + "required": [ + "node", + "said" + ], + "description": "the answer of a read-only judgement" + } + } +} diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index 68b8c50..ea5d413 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" "sync" @@ -92,6 +93,52 @@ type Reader struct { mu sync.Mutex defs map[string]seatdef.Definition // by seat boot map[string]bool // seats held from the bootstrap, not yet from the owner + // what the last ask of the owner came to (Status) + spoken bool + revision uint64 + lastErr string +} + +// Status is what the reader holds and what it waits for (ADR 0635 §3), for the host's health and its tool: Heard says +// the owner has answered, Waiting are the seats still held from the bootstrap alone (the owner has not named them), +// LastError is why the last ask failed, and Revision is the owner's state revision of the last listing. +type Status struct { + Heard bool `json:"heard"` + Held int `json:"held"` + Waiting []string `json:"waiting,omitempty"` + LastError string `json:"last-error,omitempty"` + Revision uint64 `json:"revision,omitempty"` +} + +// Status is the reader's state of mind. +func (r *Reader) Status() Status { + r.mu.Lock() + defer r.mu.Unlock() + st := Status{Heard: r.spoken, Held: len(r.defs), LastError: r.lastErr, Revision: r.revision} + for seat := range r.boot { + st.Waiting = append(st.Waiting, seat) + } + sort.Strings(st.Waiting) + return st +} + +// Health is Status as the one line a module's health says: empty when the owner has been heard and nothing waits. +func (st Status) Health() string { + switch { + case !st.Heard: + return fmt.Sprintf("the seat list is held from the foundation's bootstrap (%d seats): the definitions' owner has not answered%s", + st.Held, withError(st.LastError)) + case len(st.Waiting) > 0: + return fmt.Sprintf("%d seats are held from the bootstrap alone, the owner names none of them: %s", len(st.Waiting), strings.Join(st.Waiting, ", ")) + } + return "" +} + +func withError(e string) string { + if e == "" { + return "" + } + return " (" + e + ")" } // Start returns a reader holding the bootstrap, asks the owner for every definition and follows its event. A first @@ -148,11 +195,11 @@ func (r *Reader) seatsLocked() []catalogue.Seat { func (r *Reader) Reload() error { raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{}) if err != nil { - return fmt.Errorf("seatlist: asking %s.%s: %w", r.cfg.Owner, Tool, err) + return r.failed(fmt.Errorf("seatlist: asking %s.%s: %w", r.cfg.Owner, Tool, err)) } var l Listing if err := json.Unmarshal(raw, &l); err != nil { - return fmt.Errorf("seatlist: %s.%s answered what is not a listing: %w", r.cfg.Owner, Tool, err) + return r.failed(fmt.Errorf("seatlist: %s.%s answered what is not a listing: %w", r.cfg.Owner, Tool, err)) } parsed := map[string]seatdef.Definition{} for _, e := range l.Definitions { @@ -161,11 +208,12 @@ func (r *Reader) Reload() error { } d, err := seatdef.Parse(e.Definition) if err != nil { - return fmt.Errorf("seatlist: the definition of %s does not parse: %w", e.Seat, err) + return r.failed(fmt.Errorf("seatlist: the definition of %s does not parse: %w", e.Seat, err)) } parsed[d.Seat] = d } r.mu.Lock() + r.spoken, r.revision, r.lastErr = true, l.Revision, "" for seat := range r.defs { if !r.boot[seat] { delete(r.defs, seat) @@ -192,7 +240,7 @@ func (r *Reader) heard(_ string, body json.RawMessage) error { } raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{"seat": c.Seat}) if err != nil { - return err + return r.failed(err) } var a Answer if err := json.Unmarshal(raw, &a); err != nil { @@ -220,6 +268,13 @@ func (r *Reader) heard(_ string, body json.RawMessage) error { return nil } +func (r *Reader) failed(err error) error { + r.mu.Lock() + r.lastErr = err.Error() + r.mu.Unlock() + return err +} + func (r *Reader) changed() { if r.cfg.OnChange != nil { r.cfg.OnChange(r.Seats()) diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index 18750f8..e22d060 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -127,3 +127,31 @@ func TestStartRefusesWhatCannotRead(t *testing.T) { } } } + +// ADR 0635 §3: before the owner is heard the reader holds the foundation's definitions and says it waits. +func TestTheBootstrapParsesAndIsHeldUntilTheOwnerAnswers(t *testing.T) { + boot := Bootstrapped() + if len(boot) == 0 { + t.Fatal("no bootstrapped definition") + } + owner := &fakeOwner{down: true} + r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: boot}) + if err != nil { + t.Fatal(err) + } + if got := len(r.Seats()); got != len(boot) { + t.Fatalf("the reader holds %d seats, the bootstrap has %d", got, len(boot)) + } + st := r.Status() + if st.Heard || len(st.Waiting) != len(boot) || st.LastError == "" { + t.Fatalf("status %+v: want the owner not heard, every bootstrapped seat waiting and the error said", st) + } + owner.down = false + owner.table = map[string]string{"node-b": def("node-b", "node", "put")} + if err := r.Reload(); err != nil { + t.Fatal(err) + } + if st := r.Status(); !st.Heard || len(st.Waiting) != len(boot) || st.LastError != "" || st.Revision != 7 { + t.Fatalf("status after the owner answered %+v: want heard, the bootstrap's seats the owner does not hold still waiting, no error, revision 7", st) + } +} -- 2.54.0 From 1aca4991d4e2b956d8d1c0292c8768c04ad2b22d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 12:54:23 +0100 Subject: [PATCH 090/144] =?UTF-8?q?mesh-sdk=20compiles=20no=20seat=20list?= =?UTF-8?q?=20and=20imports=20no=20contracts=20module:=20catalogue=20takes?= =?UTF-8?q?=20its=20seats=20from=20the=20host,=20whole=20seats'=20names=20?= =?UTF-8?q?leave=20it=20(ADR=200635=20=C2=A71,=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catalogue's working set starts empty and a host gives it the seats it read (UseSeats over SeatsFromDefinitions); the node-engine's verbs and events take the seat's name from the caller. Tests take their seats from seatlisttest. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/buscompose/users.go | 4 +- go/catalogue/artifact_sweep_seat.go | 4 +- go/catalogue/artifacts.go | 37 +- go/catalogue/catalog_seat.go | 3 - go/catalogue/checker_seat.go | 3 - go/catalogue/control_records_seats.go | 7 +- go/catalogue/delivery_seat.go | 3 - go/catalogue/engine_compose_candidate_test.go | 2 +- go/catalogue/engine_seat.go | 33 +- go/catalogue/enrolment_seat.go | 3 - go/catalogue/environment_into.go | 8 +- go/catalogue/files.go | 4 +- go/catalogue/graphical_session.go | 24 +- go/catalogue/health_seats.go | 9 +- go/catalogue/holdings_test.go | 19 +- go/catalogue/login_shell_execute_test.go | 7 - go/catalogue/manifest_reads.go | 8 +- go/catalogue/node_resolver.go | 4 +- go/catalogue/operators_machine_test.go | 23 - go/catalogue/placement_seat.go | 3 - go/catalogue/reads.go | 4 +- go/catalogue/replaces_test.go | 26 - go/catalogue/seat_contributions.go | 8 +- go/catalogue/seat_dependencies.go | 8 +- go/catalogue/seat_from_definition.go | 19 +- go/catalogue/seat_paths_test.go | 11 - go/catalogue/seats.go | 137 +-- go/catalogue/seats_test.go | 3 +- go/catalogue/shares.go | 6 +- go/catalogue/test_seats_test.go | 38 + go/catalogue/two_resolvers_test.go | 5 - go/catalogue/uplink_verbs_test.go | 13 - go/delivery/builder.go | 4 +- go/delivery/enrolment.go | 2 +- go/delivery/seats_for_tests_test.go | 9 + go/go.mod | 1 - go/go.sum | 2 - go/handacts/handacts.go | 4 +- go/health/findings.go | 6 +- go/overlay/seats_for_tests_test.go | 9 + go/packages_list_test.go | 54 +- go/provisions/provisions.go | 2 +- go/seatdef/refs.go | 36 +- go/seatlist/seatlisttest/parts/registry.json | 436 +++++++ go/seatlist/seatlisttest/seatlisttest.go | 50 + .../seats/container-image-registry.json | 344 ++++++ go/seatlist/seatlisttest/seats/git.json | 8 + .../seats/go-package-registry.json | 34 + .../seats/mesh-artifact-store.json | 7 + .../seats/mesh-artifact-sweep.json | 103 ++ .../seatlisttest/seats/mesh-broker.json | 569 +++++++++ .../seatlisttest/seats/mesh-catalog.json | 69 ++ .../seatlisttest/seats/mesh-conditions.json | 95 ++ .../seatlisttest/seats/mesh-config.json | 222 ++++ .../seats/mesh-delivery-checker.json | 245 ++++ .../seatlisttest/seats/mesh-delivery.json | 226 ++++ .../seatlisttest/seats/mesh-dns-resolver.json | 9 + .../seatlisttest/seats/mesh-enrolment.json | 170 +++ .../seatlisttest/seats/mesh-hand-acts.json | 97 ++ .../seatlisttest/seats/mesh-healers.json | 73 ++ .../seatlisttest/seats/mesh-placement.json | 137 +++ .../seatlisttest/seats/mesh-provisioner.json | 35 + .../seatlisttest/seats/mesh-self-check.json | 43 + .../seatlisttest/seats/mesh-store.json | 45 + .../seatlisttest/seats/mesh-vault.json | 761 ++++++++++++ .../seatlisttest/seats/mesh-wants.json | 51 + .../seatlisttest/seats/mesh-watchdogs.json | 93 ++ .../seatlisttest/seats/node-backup.json | 80 ++ go/seatlist/seatlisttest/seats/node-bar.json | 75 ++ .../seatlisttest/seats/node-build-go.json | 46 + .../seatlisttest/seats/node-build-image.json | 46 + .../seatlisttest/seats/node-build-node.json | 46 + .../seatlisttest/seats/node-clipboard.json | 38 + .../seatlisttest/seats/node-compositor.json | 7 + .../seats/node-container-runtime.json | 7 + .../seats/node-display-server.json | 42 + .../seats/node-display-session.json | 44 + .../seatlisttest/seats/node-engine.json | 1055 +++++++++++++++++ .../seatlisttest/seats/node-environment.json | 7 + .../seatlisttest/seats/node-files.json | 589 +++++++++ .../seatlisttest/seats/node-hostname.json | 73 ++ .../seatlisttest/seats/node-hotkeys.json | 13 + .../seats/node-intrusion-prevention.json | 89 ++ .../seatlisttest/seats/node-launcher.json | 68 ++ .../seatlisttest/seats/node-lock-screen.json | 17 + .../seats/node-login-manager.json | 17 + .../seatlisttest/seats/node-login-shell.json | 30 + .../seatlisttest/seats/node-message-bus.json | 7 + .../seatlisttest/seats/node-mounts.json | 111 ++ .../seatlisttest/seats/node-nfs-server.json | 99 ++ .../seatlisttest/seats/node-notifier.json | 51 + .../seats/node-package-manager.json | 7 + .../seats/node-packet-filter.json | 62 + .../seatlisttest/seats/node-power.json | 7 + .../seatlisttest/seats/node-provisioner.json | 7 + .../seatlisttest/seats/node-reads.json | 179 +++ .../seatlisttest/seats/node-resolver.json | 77 ++ .../seats/node-secret-service.json | 7 + .../seats/node-service-manager.json | 305 +++++ .../seats/node-terminal-emulator.json | 26 + .../seatlisttest/seats/node-uplink.json | 33 + .../seats/npm-package-registry.json | 34 + .../seats/oci-artifact-registry.json | 247 ++++ .../seatlisttest/seats/operator-channel.json | 567 +++++++++ .../seats/the-private-network.json | 7 + go/testdata/seat-names.txt | 79 +- 106 files changed, 8392 insertions(+), 446 deletions(-) create mode 100644 go/catalogue/test_seats_test.go create mode 100644 go/delivery/seats_for_tests_test.go create mode 100644 go/overlay/seats_for_tests_test.go create mode 100644 go/seatlist/seatlisttest/parts/registry.json create mode 100644 go/seatlist/seatlisttest/seatlisttest.go create mode 100644 go/seatlist/seatlisttest/seats/container-image-registry.json create mode 100644 go/seatlist/seatlisttest/seats/git.json create mode 100644 go/seatlist/seatlisttest/seats/go-package-registry.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-artifact-store.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-broker.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-catalog.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-conditions.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-config.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-delivery-checker.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-delivery.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-dns-resolver.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-enrolment.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-hand-acts.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-healers.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-placement.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-provisioner.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-self-check.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-store.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-vault.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-wants.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-watchdogs.json create mode 100644 go/seatlist/seatlisttest/seats/node-backup.json create mode 100644 go/seatlist/seatlisttest/seats/node-bar.json create mode 100644 go/seatlist/seatlisttest/seats/node-build-go.json create mode 100644 go/seatlist/seatlisttest/seats/node-build-image.json create mode 100644 go/seatlist/seatlisttest/seats/node-build-node.json create mode 100644 go/seatlist/seatlisttest/seats/node-clipboard.json create mode 100644 go/seatlist/seatlisttest/seats/node-compositor.json create mode 100644 go/seatlist/seatlisttest/seats/node-container-runtime.json create mode 100644 go/seatlist/seatlisttest/seats/node-display-server.json create mode 100644 go/seatlist/seatlisttest/seats/node-display-session.json create mode 100644 go/seatlist/seatlisttest/seats/node-engine.json create mode 100644 go/seatlist/seatlisttest/seats/node-environment.json create mode 100644 go/seatlist/seatlisttest/seats/node-files.json create mode 100644 go/seatlist/seatlisttest/seats/node-hostname.json create mode 100644 go/seatlist/seatlisttest/seats/node-hotkeys.json create mode 100644 go/seatlist/seatlisttest/seats/node-intrusion-prevention.json create mode 100644 go/seatlist/seatlisttest/seats/node-launcher.json create mode 100644 go/seatlist/seatlisttest/seats/node-lock-screen.json create mode 100644 go/seatlist/seatlisttest/seats/node-login-manager.json create mode 100644 go/seatlist/seatlisttest/seats/node-login-shell.json create mode 100644 go/seatlist/seatlisttest/seats/node-message-bus.json create mode 100644 go/seatlist/seatlisttest/seats/node-mounts.json create mode 100644 go/seatlist/seatlisttest/seats/node-nfs-server.json create mode 100644 go/seatlist/seatlisttest/seats/node-notifier.json create mode 100644 go/seatlist/seatlisttest/seats/node-package-manager.json create mode 100644 go/seatlist/seatlisttest/seats/node-packet-filter.json create mode 100644 go/seatlist/seatlisttest/seats/node-power.json create mode 100644 go/seatlist/seatlisttest/seats/node-provisioner.json create mode 100644 go/seatlist/seatlisttest/seats/node-reads.json create mode 100644 go/seatlist/seatlisttest/seats/node-resolver.json create mode 100644 go/seatlist/seatlisttest/seats/node-secret-service.json create mode 100644 go/seatlist/seatlisttest/seats/node-service-manager.json create mode 100644 go/seatlist/seatlisttest/seats/node-terminal-emulator.json create mode 100644 go/seatlist/seatlisttest/seats/node-uplink.json create mode 100644 go/seatlist/seatlisttest/seats/npm-package-registry.json create mode 100644 go/seatlist/seatlisttest/seats/oci-artifact-registry.json create mode 100644 go/seatlist/seatlisttest/seats/operator-channel.json create mode 100644 go/seatlist/seatlisttest/seats/the-private-network.json diff --git a/go/buscompose/users.go b/go/buscompose/users.go index af9e75b..0eba1c7 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -1,7 +1,5 @@ package buscompose -import "git.novox.be/novox/mesh-sdk/go/catalogue" - // Declared is one module on one node, as composing its authority needs it. type Declared struct { Module string @@ -104,4 +102,4 @@ type ProcessPrincipal struct { } // VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. -const VaultSeat = catalogue.VaultSeat +const VaultSeat = "mesh-vault" diff --git a/go/catalogue/artifact_sweep_seat.go b/go/catalogue/artifact_sweep_seat.go index 3412efa..752852c 100644 --- a/go/catalogue/artifact_sweep_seat.go +++ b/go/catalogue/artifact_sweep_seat.go @@ -1,7 +1,5 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by // distribution, and not the provision artifact-store it delivers. -const ArtifactSweepSeat = contractseats.MeshArtifactSweep +const ArtifactSweepSeat = "mesh-artifact-sweep" diff --git a/go/catalogue/artifacts.go b/go/catalogue/artifacts.go index 42d0b99..e1db225 100644 --- a/go/catalogue/artifacts.go +++ b/go/catalogue/artifacts.go @@ -2,8 +2,6 @@ package catalogue import ( "strings" - - contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without @@ -47,7 +45,7 @@ func isRegistryHost(component string) bool { // through container-image-registry or oci-artifact-registry, whose blob it names the way the older form did. func InArtifactStore(reference string) (path string, kept bool) { if seat, rest, ok := InRegistrySeat(reference); ok { - if seat == OciArtifactRegistrySeat { + if seat == ociArtifactRegistrySeat { name, digest, _ := strings.Cut(rest, "@") return name + "/blobs/" + digest, true } @@ -56,28 +54,23 @@ func InArtifactStore(reference string) (path string, kept bool) { return strings.CutPrefix(reference, ArtifactStoreScheme) } +// The registry seats' names, which this file's reference forms read and write. **They leave with these forms:** a whole +// seat's name is its contract's (ADR 0634 §4, ADR 0635 §4), and the reference of a registry seat is the registry +// clients' in mesh-delivery-contracts (prepare D-1); until those clients hold it, this package keeps the one copy and +// the package-list test names it as the one exception (S-8). +const ( + npmPackageRegistrySeat = "npm-package-registry" + goPackageRegistrySeat = "go-package-registry" + containerImageRegistrySeat = "container-image-registry" + ociArtifactRegistrySeat = "oci-artifact-registry" +) + // RegistrySeats are the seats a reference of the registry form names first, the two that front the artifact store // (novox/hq ADR 0605 §1, §9). -var RegistrySeats = []string{ContainerImageRegistrySeat, OciArtifactRegistrySeat} - -// The four registry seats (novox/hq ADR 0605 §1), named here so the rest of mesh-sdk takes them from the seat set -// through catalogue alone (ADR 0625 §3). -const ( - NpmPackageRegistrySeat = contractseats.NpmPackageRegistry - GoPackageRegistrySeat = contractseats.GoPackageRegistry - ContainerImageRegistrySeat = contractseats.ContainerImageRegistry - OciArtifactRegistrySeat = contractseats.OciArtifactRegistry -) +var RegistrySeats = []string{containerImageRegistrySeat, ociArtifactRegistrySeat} // PublishSeats are the seats an artifact is published through (ADR 0605 §1). -var PublishSeats = []string{NpmPackageRegistrySeat, GoPackageRegistrySeat, ContainerImageRegistrySeat, OciArtifactRegistrySeat} - -// The seats of the vault, the provisioner, the hand acts' record and the conditions' keeper, as the seat set names them, -// for the rest of mesh-sdk (ADR 0625 §3). -const ( - VaultSeat = contractseats.MeshVault - ProvisionerSeat = contractseats.MeshProvisioner -) +var PublishSeats = []string{npmPackageRegistrySeat, goPackageRegistrySeat, containerImageRegistrySeat, ociArtifactRegistrySeat} // SeatReference is the reference a build names an image, bundle, archive or upstream copy by once it published it // through a registry seat (novox/hq ADR 0605 §9): `//@sha256:`, as InRegistrySeat reads @@ -97,7 +90,7 @@ func InRegistrySeat(reference string) (seat, path string, ok bool) { if !found || !strings.Contains(rest, "@sha256:") { return "", "", false } - if _, _, upstream := InUpstream(rest); upstream && first == ContainerImageRegistrySeat { + if _, _, upstream := InUpstream(rest); upstream && first == containerImageRegistrySeat { return first, rest, true } if strings.Count(rest, "/") != 1 { diff --git a/go/catalogue/catalog_seat.go b/go/catalogue/catalog_seat.go index 2c711ee..d53809d 100644 --- a/go/catalogue/catalog_seat.go +++ b/go/catalogue/catalog_seat.go @@ -1,7 +1,5 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - import "encoding/json" // CatalogSeat is the seat of the catalog: what the mesh holds of every module (novox/hq ADR 0604 §1). Its states are @@ -10,7 +8,6 @@ import "encoding/json" // no verb. It names no event. What must be built again is no verb: a module whose fingerprint differs from its newest // build, which the delivery view derives. The verbs' answers are CatalogModules, CatalogOne, CatalogProvides and // CatalogDependents, whose field names are those of the catalog module's tools they replace. -const CatalogSeat = contractseats.MeshCatalog // CatalogEntry is one module of the catalog: its repository, its directory there ("." for the root), the merge commit it // was last changed at, and its new-form fingerprint or why it has none. diff --git a/go/catalogue/checker_seat.go b/go/catalogue/checker_seat.go index 8de1988..d13d3b8 100644 --- a/go/catalogue/checker_seat.go +++ b/go/catalogue/checker_seat.go @@ -1,9 +1,6 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // CheckerSeat is the seat mesh-delivery-checker holds. // // Deprecated: a whole seat's name is mesh-contracts' (seats.MeshDeliveryChecker); kept for its importers until their // next change takes it from there (testdata/seat-names.txt). -const CheckerSeat = contractseats.MeshDeliveryChecker diff --git a/go/catalogue/control_records_seats.go b/go/catalogue/control_records_seats.go index a61fbc8..8d75390 100644 --- a/go/catalogue/control_records_seats.go +++ b/go/catalogue/control_records_seats.go @@ -1,12 +1,7 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // The two seats. -const ( - WantsSeat = contractseats.MeshWants - HandActsSeat = contractseats.MeshHandActs -) +const () // controlRecordsDecision is the record that made the two seats. const controlRecordsDecision = "novox/hq ADR 0565" diff --git a/go/catalogue/delivery_seat.go b/go/catalogue/delivery_seat.go index 1e99f8d..6521ff7 100644 --- a/go/catalogue/delivery_seat.go +++ b/go/catalogue/delivery_seat.go @@ -1,9 +1,6 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // DeliverySeat is the seat mesh-delivery holds. -const DeliverySeat = contractseats.MeshDelivery // RemovedDeliveryVerbs are the delivery seat's verbs release 6 removed (novox/hq ADR 0539 clause 17): with no plan the // gate keeps the merge's states and derives nothing after the merge. Each stays in deliveryVerbs as an optional row diff --git a/go/catalogue/engine_compose_candidate_test.go b/go/catalogue/engine_compose_candidate_test.go index b2c3797..6abdf3d 100644 --- a/go/catalogue/engine_compose_candidate_test.go +++ b/go/catalogue/engine_compose_candidate_test.go @@ -4,7 +4,7 @@ import "testing" // The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a boolean, as the engine and the checker read it. func TestTheEngineServesComposeCandidate(t *testing.T) { - for _, v := range EngineVerbs() { + for _, v := range EngineVerbs(EngineSeat) { if v.Name != "compose-candidate" { continue } diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index 363e2fa..ec5889f 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -1,32 +1,15 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - -// EngineSeat is the node seat every node's engine holds, from the seat set. The seat is defined whole in mesh-contracts -// (its contract, go/seats/nodeengine, and its client, go/clients/nodeengineclient): its verbs' and events' names and -// message types are taken from there (ADR 0627 §4). -const EngineSeat = contractseats.NodeEngine - -// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the -// generated seat list (design 26), each with its description and its input's schema as discovery shows them. -func EngineVerbs() []Verb { - s, _ := seatIn(defaultSeats, EngineSeat) +// EngineVerbs are the node-engine seat's verbs as the seat list the host gave has them (ADR 0563 §1; design 26), each +// with its description and its input's schema as discovery shows them. The seat's name is its contract's +// (mesh-contracts' nodeengine), never this package's (ADR 0634 §4, ADR 0635 §4): the caller names it. +func EngineVerbs(seat string) []Verb { + s, _ := SeatNamed(seat) return append([]Verb(nil), s.Serves...) } -// EngineEvents are what the seat says: of an act, each naming the act, the path or access, the caller as the runtime -// proved it and the ask (ADR 0563 §1), and that what its engine reads of a manifest changed (ADR 0618 §4). -var EngineEvents = func() []string { - s, _ := seatIn(defaultSeats, EngineSeat) +// EngineEvents are what the node-engine seat says, as the seat list the host gave has them. +func EngineEvents(seat string) []string { + s, _ := SeatNamed(seat) return append([]string(nil), s.Emits...) -}() - -// seatIn is the seat of that name in a list. -func seatIn(list []Seat, name string) (Seat, bool) { - for _, s := range list { - if s.Name == name { - return s, true - } - } - return Seat{}, false } diff --git a/go/catalogue/enrolment_seat.go b/go/catalogue/enrolment_seat.go index e7381b8..227329c 100644 --- a/go/catalogue/enrolment_seat.go +++ b/go/catalogue/enrolment_seat.go @@ -1,9 +1,6 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // EnrolmentSeat is the seat mesh-enrolment. -const EnrolmentSeat = contractseats.MeshEnrolment // The seat's six verbs (ADR 0587 §1, §4-§8). const ( diff --git a/go/catalogue/environment_into.go b/go/catalogue/environment_into.go index a0c54e6..6df4fe2 100644 --- a/go/catalogue/environment_into.go +++ b/go/catalogue/environment_into.go @@ -1,14 +1,12 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules // contributed: the account's environment, and the login shell's code. const ( - EnvironmentSeat = contractseats.NodeEnvironment - LoginShellSeat = contractseats.NodeLoginShell + EnvironmentSeat = "node-environment" + LoginShellSeat = "node-login-shell" // PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211). - PowerSeat = contractseats.NodePower + PowerSeat = "node-power" ) // Where an environment entry on PATH goes: before the account's existing PATH, or after it. diff --git a/go/catalogue/files.go b/go/catalogue/files.go index 685c555..7590483 100644 --- a/go/catalogue/files.go +++ b/go/catalogue/files.go @@ -1,9 +1,7 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394). -const FilesSeat = contractseats.NodeFiles +const FilesSeat = "node-files" // FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from // each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5). diff --git a/go/catalogue/graphical_session.go b/go/catalogue/graphical_session.go index 75f05bf..446d1f8 100644 --- a/go/catalogue/graphical_session.go +++ b/go/catalogue/graphical_session.go @@ -3,8 +3,6 @@ package catalogue import ( "fmt" "strings" - - contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's @@ -12,17 +10,17 @@ import ( // a role rather than each inventing one — and a machine running two of one role is refused at // assignment instead of found by two bars on one screen. const ( - LoginManagerSeat = contractseats.NodeLoginManager - DisplayServerSeat = contractseats.NodeDisplayServer - DisplaySessionSeat = contractseats.NodeDisplaySession - TerminalEmulatorSeat = contractseats.NodeTerminalEmulator - LauncherSeat = contractseats.NodeLauncher - NotifierSeat = contractseats.NodeNotifier - LockScreenSeat = contractseats.NodeLockScreen - ClipboardSeat = contractseats.NodeClipboard - BarSeat = contractseats.NodeBar - CompositorSeat = contractseats.NodeCompositor - SecretServiceSeat = contractseats.NodeSecretService + LoginManagerSeat = "node-login-manager" + DisplayServerSeat = "node-display-server" + DisplaySessionSeat = "node-display-session" + TerminalEmulatorSeat = "node-terminal-emulator" + LauncherSeat = "node-launcher" + NotifierSeat = "node-notifier" + LockScreenSeat = "node-lock-screen" + ClipboardSeat = "node-clipboard" + BarSeat = "node-bar" + CompositorSeat = "node-compositor" + SecretServiceSeat = "node-secret-service" ) // BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255). diff --git a/go/catalogue/health_seats.go b/go/catalogue/health_seats.go index b8d0b7e..84dd98e 100644 --- a/go/catalogue/health_seats.go +++ b/go/catalogue/health_seats.go @@ -1,14 +1,7 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // The four health seats. -const ( - ConditionsSeat = contractseats.MeshConditions - WatchdogsSeat = contractseats.MeshWatchdogs - SelfCheckSeat = contractseats.MeshSelfCheck - HealersSeat = contractseats.MeshHealers -) +const () // healthDecision is the record that made the four seats. const healthDecision = "novox/hq ADR 0564" diff --git a/go/catalogue/holdings_test.go b/go/catalogue/holdings_test.go index 8e79d0a..d4cba50 100644 --- a/go/catalogue/holdings_test.go +++ b/go/catalogue/holdings_test.go @@ -15,7 +15,7 @@ func busSeatDelivering(t *testing.T, delivers string) { t.Helper() was := Seats() t.Cleanup(func() { UseSeats(was) }) - UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}}) + UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test", Serves: verbsNamed(brokerVerbs())}}) } // brokerVerbs are the bus seat's verbs as a literal: mesh-sdk imports no seat's contract module, a test neither @@ -80,17 +80,10 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) { } } -// The store's seat rows have no protocol columns yet; loading them must not drop the protocol the -// bus is derived from, or no role's work queue is ever raised (found live, 2026-09-28). -func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) { - was := Seats() - t.Cleanup(func() { UseSeats(was) }) - UseSeats([]Seat{{Name: "node-build-go", Scope: ScopeNode, Decision: "row"}}) - got, ok := SeatNamed("node-build-go") - if !ok || len(got.Accepts) == 0 || !got.PulledByKind || !got.Yields { - t.Fatalf("the Go build seat lost what it accepts when loaded from the store: %+v", got) - } - if got.Decision != "row" { - t.Fatalf("the store's own columns were not kept: %+v", got) +func verbsNamed(names []string) []Verb { + var out []Verb + for _, n := range names { + out = append(out, Verb{Name: n}) } + return out } diff --git a/go/catalogue/login_shell_execute_test.go b/go/catalogue/login_shell_execute_test.go index 51b16e4..822476a 100644 --- a/go/catalogue/login_shell_execute_test.go +++ b/go/catalogue/login_shell_execute_test.go @@ -31,11 +31,4 @@ func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) { } } t.Run("compiled", check) - t.Run("read back from the store", func(t *testing.T) { - before := seats - t.Cleanup(func() { seats = before }) - UseSeats([]Seat{{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204", - Serves: []Verb{{Name: "execute"}}}}) - check(t) - }) } diff --git a/go/catalogue/manifest_reads.go b/go/catalogue/manifest_reads.go index 98503a1..0bbd68b 100644 --- a/go/catalogue/manifest_reads.go +++ b/go/catalogue/manifest_reads.go @@ -57,7 +57,7 @@ var artifactReferenceForms = []string{ReferenceFormSeat} const ReferenceFormSeatReach = "seat-reach" // claimsAtMesh says whether a manifest claims seat at the mesh's scope (a claim with no scope is the seat's own). The -// store holder's own manifest, a claim of ContainerImageRegistrySeat, needs form:seat-reach this way (ADR 0632 §2): read +// store holder's own manifest, a claim of containerImageRegistrySeat, needs form:seat-reach this way (ADR 0632 §2): read // on every node, it leaves an engine before the reading without the old way to find the store, so it is held from every // node whose engine does not state it. func claimsAtMesh(top map[string]any, seat string) bool { @@ -102,7 +102,7 @@ func ManifestReads() []string { set[ReadForm+f] = true } set[ReadForm+ReferenceFormSeatReach] = true - for _, s := range DefaultSeats() { + for _, s := range Seats() { set[ReadSeat+s.Name] = true } return sortedSet(set) @@ -164,7 +164,7 @@ func ManifestNeeds(raw []byte) ([]string, error) { } used := map[string]bool{} textNeeds(raw, used) - if claimsAtMesh(top, ContainerImageRegistrySeat) { + if claimsAtMesh(top, containerImageRegistrySeat) { used[ReadForm+ReferenceFormSeatReach] = true } if _, has := top[ConfigField]; has || takesConfigKey.Match(raw) { @@ -216,7 +216,7 @@ func ManifestNeeds(raw []byte) ([]string, error) { // and a bucket's share with a seat. func seatNamesUsed(top map[string]any) []string { known := map[string]bool{} - for _, s := range DefaultSeats() { + for _, s := range Seats() { known[s.Name] = true } set := map[string]bool{} diff --git a/go/catalogue/node_resolver.go b/go/catalogue/node_resolver.go index 313f478..3c7c325 100644 --- a/go/catalogue/node_resolver.go +++ b/go/catalogue/node_resolver.go @@ -1,9 +1,7 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // ResolverSeat is the machine's own resolver (novox/hq ADR 0247). -const ResolverSeat = contractseats.NodeResolver +const ResolverSeat = "node-resolver" // ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where // one is held. diff --git a/go/catalogue/operators_machine_test.go b/go/catalogue/operators_machine_test.go index eb27b85..979bad0 100644 --- a/go/catalogue/operators_machine_test.go +++ b/go/catalogue/operators_machine_test.go @@ -139,26 +139,3 @@ func TestResetFailedWantedByAndUnlinkDanglingAreRequiredVerbsOfTheServiceManager t.Fatalf("read back from the row, a holder serving all twelve is refused: %v", err) } } - -// The optional mark is not stored, so a seat set read back from the store's rows takes it from the -// compiled seat: otherwise `failed`, seeded into the row, would come back required. -func TestAnOptionalVerbStaysOptionalInASetReadFromTheStore(t *testing.T) { - defer UseSeats(DefaultSeats()) - var rows []Seat - for _, s := range DefaultSeats() { - row := s - row.Serves = nil - for _, v := range s.Serves { - row.Serves = append(row.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input}) - } - rows = append(rows, row) - } - UseSeats(rows) - seat, _ := SeatNamed(ServiceManagerSeat) - holder := Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode, - Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", - "reset-failed", "wanted-by", "unlink-dangling"}}}} - if err := CanHold(holder, seat); err != nil { - t.Fatalf("a set read from rows refuses a holder without the optional failed: %v", err) - } -} diff --git a/go/catalogue/placement_seat.go b/go/catalogue/placement_seat.go index 5b3e672..5dab82f 100644 --- a/go/catalogue/placement_seat.go +++ b/go/catalogue/placement_seat.go @@ -1,9 +1,6 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // PlacementSeat is the seat the assigner holds for placement. -const PlacementSeat = contractseats.MeshPlacement // The seat's five verbs (ADR 0567 §10). const ( diff --git a/go/catalogue/reads.go b/go/catalogue/reads.go index f9fdfc7..5b8b515 100644 --- a/go/catalogue/reads.go +++ b/go/catalogue/reads.go @@ -1,9 +1,7 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310). -const ReadsSeat = contractseats.NodeReads +const ReadsSeat = "node-reads" // ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is // kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5). diff --git a/go/catalogue/replaces_test.go b/go/catalogue/replaces_test.go index 2f10676..f21f787 100644 --- a/go/catalogue/replaces_test.go +++ b/go/catalogue/replaces_test.go @@ -62,29 +62,3 @@ func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { t.Fatalf("replaces not read: %v", got) } } - -// A seat row the store seeded before `replaces` existed carries none; the working set takes the compiled -// one, so the `tools` answer says it on a mesh whose rows exist. -func TestASeededRowTakesWhatItsVerbsReplaceFromTheCompiledSeat(t *testing.T) { - was := Seats() - t.Cleanup(func() { UseSeats(was) }) - var row Seat - for _, s := range DefaultSeats() { - if s.Name == ServiceManagerSeat { - row = s - } - } - stored := make([]Verb, len(row.Serves)) - for i, v := range row.Serves { - v.Replaces = nil - stored[i] = v - } - row.Serves = stored - UseSeats([]Seat{row}) - got, _ := SeatNamed(ServiceManagerSeat) - for _, v := range got.Serves { - if v.Name == "journal" && (len(v.Replaces) == 0 || v.Replaces[0] != "journalctl") { - t.Fatalf("the seeded journal verb says it replaces %v", v.Replaces) - } - } -} diff --git a/go/catalogue/seat_contributions.go b/go/catalogue/seat_contributions.go index a6b02fa..0df1e9a 100644 --- a/go/catalogue/seat_contributions.go +++ b/go/catalogue/seat_contributions.go @@ -1,15 +1,13 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5). -const HotkeysSeat = contractseats.NodeHotkeys +const HotkeysSeat = "node-hotkeys" // MessageBusSeat is the machine's D-Bus (novox/hq ADR 0215). -const MessageBusSeat = contractseats.NodeMessageBus +const MessageBusSeat = "node-message-bus" // BackupSeat is the machine's backups (novox/hq ADR 0214, to-be 43). -const BackupSeat = contractseats.NodeBackup +const BackupSeat = "node-backup" // SeatContribution is one piece of configuration a module gives a seat's holder to place. type SeatContribution struct { diff --git a/go/catalogue/seat_dependencies.go b/go/catalogue/seat_dependencies.go index 756d22f..3e3d53a 100644 --- a/go/catalogue/seat_dependencies.go +++ b/go/catalogue/seat_dependencies.go @@ -4,16 +4,14 @@ import ( "fmt" "sort" "strings" - - contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation, // the seed and the messages all turn on these strings. const ( - ServiceManagerSeat = contractseats.NodeServiceManager - PackageManagerSeat = contractseats.NodePackageManager - ContainerRuntimeSeat = contractseats.NodeContainerRuntime + ServiceManagerSeat = "node-service-manager" + PackageManagerSeat = "node-package-manager" + ContainerRuntimeSeat = "node-container-runtime" ) // appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207 diff --git a/go/catalogue/seat_from_definition.go b/go/catalogue/seat_from_definition.go index f67eb57..317b35e 100644 --- a/go/catalogue/seat_from_definition.go +++ b/go/catalogue/seat_from_definition.go @@ -14,6 +14,12 @@ func SeatFromDefinition(d seatdef.Definition) Seat { s := Seat{Name: d.Seat, Scope: d.Scope, Replicated: d.Replicated, Delivers: d.Delivers, Accepts: seatdef.MessageNames(d.Accepts), Emits: seatdef.MessageNames(d.Events), PulledByKind: d.PulledByKind, Yields: d.Yields, HeldByTheEngine: d.HeldByTheEngine, Decision: d.Decision} + if len(s.Accepts) == 0 { + s.Accepts = nil + } + if len(s.Emits) == 0 { + s.Emits = nil + } for _, v := range d.Verbs { s.Serves = append(s.Serves, Verb{Name: v.Name, Description: v.Description, Input: seatdef.ResolveRefs(v.Input, d.Defs), Output: seatdef.ResolveRefs(v.Answer, d.Defs), @@ -22,7 +28,7 @@ func SeatFromDefinition(d seatdef.Definition) Seat { for _, r := range d.Receives { rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered} if r.Shape != nil { - sh := &Shape{Order: r.Shape.Order, Examples: r.Shape.Examples, Text: r.Shape.Text} + sh := &Shape{Order: r.Shape.Order, Examples: examples(r.Shape.Examples), Text: r.Shape.Text} for _, f := range r.Shape.Places { sh.Places = append(sh.Places, PlaceField{Field: f.Field, Values: f.Values}) } @@ -55,3 +61,14 @@ func SeatsFromDefinitions(defs []seatdef.Definition) []Seat { }) return out } + +func examples(in []map[string]any) []map[string]any { + if in == nil { + return nil + } + out := make([]map[string]any, len(in)) + for i, e := range in { + out[i], _ = seatdef.StringLists(e).(map[string]any) + } + return out +} diff --git a/go/catalogue/seat_paths_test.go b/go/catalogue/seat_paths_test.go index d2401ca..53ff900 100644 --- a/go/catalogue/seat_paths_test.go +++ b/go/catalogue/seat_paths_test.go @@ -31,17 +31,6 @@ func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) { } } -// What the store's rows carry is never the paths: a row read back keeps the compiled ones. -func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) { - before := seats - t.Cleanup(func() { seats = before }) - UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}}) - s, _ := SeatNamed("node-hostname") - if len(s.Paths) != 2 { - t.Fatalf("node-hostname read back from its row names %v", s.Paths) - } -} - func hostnameSeat(t *testing.T) Seat { t.Helper() s, ok := SeatNamed("node-hostname") diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 6bf3737..17a21e1 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -3,8 +3,6 @@ package catalogue import ( "fmt" "strings" - - contractseats "git.novox.be/novox/mesh-contracts/go/seats" ) // Seat is one role the mesh defines. @@ -85,130 +83,22 @@ type Receivable struct { Offered bool } -// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the -// fallback when it has none (novox/hq ADR 0122). **It is generated, never written here** (novox/hq ADR 0608 §5, -// ADR 0613 §2, design 26 *The seat set has one source*): every seat's definition lives in mesh-contracts, and its -// generated seat list is what this runtime reads, in the order a person reads it: the mesh's own, then a node's. What -// a definition cannot say, being code, is attached here: a contribution kind's checks (withShapeCode). -var defaultSeats = fromContracts(contractseats.List()) +// seats is the working set the lookups read, empty until a host gives it the seat list (UseSeats): mesh-sdk compiles no +// seat list and imports no contracts module (novox/hq ADR 0635 §1). A host reads the list from the bus +// (mesh-sdk go/seatlist, which holds the foundation's bootstrapped definitions until the indexer is heard) and gives +// its seats here at start and at each change. +var seats []Seat -// fromContracts is the generated seat list in this runtime's types, each data-shaped kind given its checks. -func fromContracts(list []contractseats.Seat) []Seat { - out := make([]Seat, 0, len(list)) - for _, c := range list { - s := Seat{Name: c.Name, Scope: c.Scope, Replicated: c.Replicated, Delivers: c.Delivers, Accepts: c.Accepts, - Emits: c.Emits, PulledByKind: c.PulledByKind, Yields: c.Yields, HeldByTheEngine: c.HeldByTheEngine, - Decision: c.Decision} - for _, v := range c.Serves { - s.Serves = append(s.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input, Output: v.Output, - Optional: v.Optional, Replaces: v.Replaces}) - } - for _, r := range c.Receives { - rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered} - if r.Shape != nil { - sh := &Shape{Order: r.Shape.Order, Examples: r.Shape.Examples, Text: r.Shape.Text} - for _, f := range r.Shape.Places { - sh.Places = append(sh.Places, PlaceField{Field: f.Field, Values: f.Values}) - } - withShapeCode(c.Name, r.Kind, sh) - rv.Shape = sh - } - s.Receives = append(s.Receives, rv) - } - for _, p := range c.Paths { - s.Paths = append(s.Paths, SeatPath{Path: p.Path, StepsBackFor: p.StepsBackFor}) - } - out = append(out, s) - } - return out -} - -// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by -// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a -// change to data, not to this code. -var seats = defaultSeats - -// DefaultSeats is the set the mesh ships with, for seeding the store's seat table. -func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) } - -// UseSeats replaces the working set with the one the control plane read from its store. +// UseSeats replaces the working set with the seat list a host read (seatlist.Reader.Seats, via SeatsFromDefinitions). // -// **Empty is ignored on purpose.** A store that has not been seeded yet — or one that could not be -// read — must leave the compiled defaults in force rather than emptying the set: an empty set would -// refuse every claim and could stop the control plane composing at all, which is a far worse failure -// than running on the set the binary shipped with. So the store can only ever *replace* the set with -// a non-empty one, never erase it. +// **Empty is ignored on purpose.** A list that was not read yet — or one that could not be — must leave the set in force +// rather than emptying it: an empty set would refuse every claim and could stop a composition altogether, which is a +// far worse failure than running on the set the host last had. func UseSeats(s []Seat) { if len(s) == 0 { return } - // **The store's rows carry no protocol yet, and the protocol is what the bus is derived - // from.** ADR 0129 gives a seat what it accepts, emits and serves; ADR 0122 moved the set into - // a table that has name, scope, delivers and decision and nothing else, and the columns for - // the rest are not there yet. So a row replacing a compiled entry would silently drop the - // protocol, and the roles' work queues would never be raised — found live as "no response - // from stream" the first time a build was submitted over the new bus (2026-09-28). Until the - // table gains the columns, a row without a protocol keeps the compiled one of the same name. - byName := map[string]Seat{} - for _, d := range defaultSeats { - byName[d.Name] = d - } - merged := make([]Seat, 0, len(s)) - for _, row := range s { - if len(row.Accepts)+len(row.Emits)+len(row.Serves) == 0 { - if d, known := byName[row.Name]; known { - row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves - } - } - // What a seat receives, whether it is replicated and the paths it names are never stored - // (novox/hq ADR 0212, ADR 0223, ADR 0287), so they are always the compiled ones. - if d, known := byName[row.Name]; known { - row.Receives = d.Receives - row.Paths = d.Paths - row.Replicated = d.Replicated - row.PulledByKind = d.PulledByKind - row.Yields = d.Yields // never stored (ADR 0553) - row.HeldByTheEngine = d.HeldByTheEngine // never stored (ADR 0563) - row.Serves = optionalAsCompiled(row.Serves, d.Serves) - } - merged = append(merged, row) - } - seats = merged -} - -// optionalAsCompiled is a row's verbs with each one the compiled seat marks optional marked so. The mark -// is never stored (Verb.Optional), and a row is what the working set holds once the store is read: a verb -// the seeding added to the row — `failed`, `checks` — would otherwise come back required, and every holder -// not serving it yet would be refused at registration and at handover, which the mark exists to prevent. -// -// **And what a verb replaces is the compiled one** (novox/hq ADR 0245): a row seeded before the field -// existed carries none, and re-seeding widens a verb's arguments only, so read from the row alone the -// `tools` answer — what the mesh MCP server's search and the agent's instructions are built from — would -// say no verb replaces anything on a mesh whose rows exist. The mesh's statement of what a role replaces -// is this binary's, like the optional mark. -func optionalAsCompiled(row, compiled []Verb) []Verb { - optional := map[string]bool{} - replaces := map[string][]string{} - for _, v := range compiled { - if v.Optional { - optional[v.Name] = true - } - if len(v.Replaces) > 0 { - replaces[v.Name] = v.Replaces - } - } - if len(optional) == 0 && len(replaces) == 0 { - return row - } - out := make([]Verb, len(row)) - for i, v := range row { - v.Optional = optional[v.Name] - if r, ok := replaces[v.Name]; ok { - v.Replaces = r - } - out[i] = v - } - return out + seats = append([]Seat(nil), s...) } // aliases maps a seat's former names to its current canonical name (novox/hq ADR 0122). Loaded from @@ -325,10 +215,3 @@ func SeatsWithAProtocol() []Seat { } return out } - -// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may -// snapshot the bus's streams and do nothing else (novox/hq ADR 0235). -// -// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshBroker, meshbroker.Seat); kept for its importers -// until their next change takes it from there (testdata/seat-names.txt). -const BrokerSeat = contractseats.MeshBroker diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index bd69cd5..f899561 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -137,7 +137,8 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { added = append(added, s.Name) } } - if want := []string{"go-package-registry", "container-image-registry", "oci-artifact-registry"}; !slices.Equal(added, want) { + slices.Sort(added) + if want := []string{"container-image-registry", "go-package-registry", "oci-artifact-registry"}; !slices.Equal(added, want) { t.Errorf("the generated set adds %v, want %v", added, want) } } diff --git a/go/catalogue/shares.go b/go/catalogue/shares.go index 92f905e..3a66edd 100644 --- a/go/catalogue/shares.go +++ b/go/catalogue/shares.go @@ -1,9 +1,7 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" - // NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263). -const NFSServerSeat = contractseats.NodeNfsServer +const NFSServerSeat = "node-nfs-server" // MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263). -const MountsSeat = contractseats.NodeMounts +const MountsSeat = "node-mounts" diff --git a/go/catalogue/test_seats_test.go b/go/catalogue/test_seats_test.go new file mode 100644 index 0000000..3862dce --- /dev/null +++ b/go/catalogue/test_seats_test.go @@ -0,0 +1,38 @@ +package catalogue + +import "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" + +// defaultSeats is the list the tests start from (seatlisttest: the contracts modules' definitions, read as a host reads +// them from the bus, ADR 0635 §1), and DefaultSeats gives a copy of it for a test to restore. +var defaultSeats = func() []Seat { + list := SeatsFromDefinitions(seatlisttest.Definitions()) + UseSeats(list) + return list +}() + +// DefaultSeats is the tests' seat list. +func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) } + +// The whole seats' names, which the tests use and the package does not (ADR 0634 §4; ADR 0613 §5 refuses them in +// non-test code). +const ( + CatalogSeat = "mesh-catalog" + CheckerSeat = "mesh-delivery-checker" + EnrolmentSeat = "mesh-enrolment" + PlacementSeat = "mesh-placement" + DeliverySeat = "mesh-delivery" + WantsSeat = "mesh-wants" + HandActsSeat = "mesh-hand-acts" + ConditionsSeat = "mesh-conditions" + WatchdogsSeat = "mesh-watchdogs" + SelfCheckSeat = "mesh-self-check" + HealersSeat = "mesh-healers" + VaultSeat = "mesh-vault" + ProvisionerSeat = "mesh-provisioner" + BrokerSeat = "mesh-broker" + EngineSeat = "node-engine" + NpmPackageRegistrySeat = npmPackageRegistrySeat + GoPackageRegistrySeat = goPackageRegistrySeat + ContainerImageRegistrySeat = containerImageRegistrySeat + OciArtifactRegistrySeat = ociArtifactRegistrySeat +) diff --git a/go/catalogue/two_resolvers_test.go b/go/catalogue/two_resolvers_test.go index dd08989..3693b84 100644 --- a/go/catalogue/two_resolvers_test.go +++ b/go/catalogue/two_resolvers_test.go @@ -91,9 +91,4 @@ func TestOnlyTheResolverIsReplicated(t *testing.T) { t.Errorf("%s replicated = %v; only mesh-dns-resolver is (ADR 0223)", s.Name, s.Replicated) } } - UseSeats([]Seat{{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution"}}) - defer UseSeats(DefaultSeats()) - if s, _ := SeatNamed("mesh-dns-resolver"); !s.Replicated { - t.Error("loading the set from the store, which has no column for it, lost the resolver's replication") - } } diff --git a/go/catalogue/uplink_verbs_test.go b/go/catalogue/uplink_verbs_test.go index b2f6caf..7c26036 100644 --- a/go/catalogue/uplink_verbs_test.go +++ b/go/catalogue/uplink_verbs_test.go @@ -50,16 +50,3 @@ func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) { t.Fatalf("a verb the seat does not promise was accepted: %v", err) } } - -func TestTheUplinkVerbsReadBackFromTheRowStayOptional(t *testing.T) { - before := seats - t.Cleanup(func() { seats = before }) - // The row as seeding stores it: both verbs, the mark not a column. - UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "resolvers"}, {Name: "links"}}}}) - seat := uplinkSeat(t) - holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, - Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}} - if err := CanHold(holder, seat); err != nil { - t.Fatalf("read back from the row, the verbs are required: %v", err) - } -} diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 2c1439d..185a90d 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -114,9 +114,9 @@ func (a BuiltArtifact) referenceProblem() string { } } if seat, path, ok := catalogue.InRegistrySeat(r); ok { - want := catalogue.ContainerImageRegistrySeat + want := "container-image-registry" if a.Kind == "bundle" || a.Kind == "archive" { - want = catalogue.OciArtifactRegistrySeat + want = "oci-artifact-registry" } module, rest, _ := strings.Cut(path, "/") if a.Kind == "package" || seat != want || module == "" || rest != a.Name+"@"+a.Digest { diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index 4526bbd..7afa69d 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -23,7 +23,7 @@ import ( // contract: every reader takes the names from here. const ( // EnrolmentModule is the module holding the seat mesh-enrolment. - EnrolmentModule = catalogue.EnrolmentSeat + EnrolmentModule = "mesh-enrolment" // EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares. EnrolmentsState = "enrolments" EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState diff --git a/go/delivery/seats_for_tests_test.go b/go/delivery/seats_for_tests_test.go new file mode 100644 index 0000000..51763c3 --- /dev/null +++ b/go/delivery/seats_for_tests_test.go @@ -0,0 +1,9 @@ +package delivery + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as a host gives the ones it read from the bus (ADR 0635 §1). +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/go/go.mod b/go/go.mod index e25d8c9..830ee76 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,7 +3,6 @@ module git.novox.be/novox/mesh-sdk/go go 1.26.0 require ( - git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/go/go.sum b/go/go.sum index 83ee555..98dc72c 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,3 @@ -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639 h1:Zt7CfWt2S2W/fVgWNyW2u2+xwz051LXvGxVTNIyfhdM= -git.novox.be/novox/mesh-contracts/go v0.0.0-20261104174344-ae5266e77639/go.mod h1:0dD1XEJBJ0G5UYDQxSZI84nuujb6m3tOpJl4C8rQVvg= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/go/handacts/handacts.go b/go/handacts/handacts.go index 36bc835..1288986 100644 --- a/go/handacts/handacts.go +++ b/go/handacts/handacts.go @@ -10,13 +10,11 @@ import ( "fmt" "strings" "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The seat, its holder and what it keeps (ADR 0565 clause 10). const ( - Seat = catalogue.HandActsSeat + Seat = "mesh-hand-acts" Module = "mesh-hand-acts" // ActsLog is the log of every act, kept 400 days; WarrantsState one key `.` per warrant, made once. ActsLog = "acts" diff --git a/go/health/findings.go b/go/health/findings.go index edafe11..2ae46c3 100644 --- a/go/health/findings.go +++ b/go/health/findings.go @@ -10,8 +10,6 @@ import ( "fmt" "strings" "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The modules holding the four health seats, and the states between them, as `_` buckets. @@ -274,6 +272,6 @@ type Would struct { // KeeperSeat and the self-check's run as `checked` on SelfCheckSeat: the controller's events and the take-over key that // chose between the two sources ended with it (novox/hq ADR 0590 clause 3). const ( - KeeperSeat = catalogue.ConditionsSeat - SelfCheckSeat = catalogue.SelfCheckSeat + KeeperSeat = "mesh-conditions" + SelfCheckSeat = "mesh-self-check" ) diff --git a/go/overlay/seats_for_tests_test.go b/go/overlay/seats_for_tests_test.go new file mode 100644 index 0000000..668a301 --- /dev/null +++ b/go/overlay/seats_for_tests_test.go @@ -0,0 +1,9 @@ +package overlay + +import ( + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" +) + +// The seats the tests judge against are given to catalogue as a host gives the ones it read from the bus (ADR 0635 §1). +func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) } diff --git a/go/packages_list_test.go b/go/packages_list_test.go index cb2ed4d..aa60388 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -15,7 +15,6 @@ import ( "strings" "testing" - contractseats "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/buscalls" ) @@ -27,14 +26,13 @@ var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", - "sdkversion", "seal", "seatdef", "testbus", "views", "views/viewstest", + "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } // clientSeats are the seats whose logic is their client's in mesh-contracts (novox/hq ADR 0627 §1, §4): mesh-sdk names // none of them outside catalogue, and holds no package over them. -var clientSeats = []string{contractseats.OperatorChannel, contractseats.MeshVault, "node-engine", - contractseats.NpmPackageRegistry, contractseats.GoPackageRegistry, contractseats.ContainerImageRegistry, - contractseats.OciArtifactRegistry} +var clientSeats = []string{"operator-channel", "mesh-vault", "node-engine", "npm-package-registry", "go-package-registry", + "container-image-registry", "oci-artifact-registry"} // moduleWord is a module's name in a literal (mesh-[-...], or one of mesh-delivery's parts by its short name // before -views or _read_), but a seat's that go/views records through (mesh-hand-acts) and an import path's mesh-sdk. @@ -49,6 +47,10 @@ var notInSDK = []string{"asks", "asker", "publish"} var untilTheJoin = []string{ "delivery/signers.go: mesh-vault", "provisions/provisions.go: mesh-vault", + // Names of whole seats in packages that leave in S-7 (delivery, buscompose's grants-part names), with their packages. + "buscompose/users.go: mesh-vault", + "delivery/builder.go: container-image-registry", + "delivery/builder.go: oci-artifact-registry", } // computesNot are the functions whose computation is one owner's alone (ADR 0593 §2, §7): the indexer computes a @@ -68,7 +70,16 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { // name is mesh-contracts' (go/seats), so no string literal here names a defined seat or its subjects, and no exported // name is given to one, beyond what testdata/seat-names.txt keeps from before the move (each `: `), which only shrinks as areas move. A literal naming a module of the same name as a seat counts too. - defined := contractseats.Names() + // The seats the definitions define are the files of seatlist/seatlisttest/seats, one per seat (ADR 0635 §1: mesh-sdk + // imports no contracts module, so the test reads the copy the catalogue's own tests read). + var defined []string + if entries, err := os.ReadDir("seatlist/seatlisttest/seats"); err != nil { + t.Fatal(err) + } else { + for _, e := range entries { + defined = append(defined, strings.TrimSuffix(e.Name(), ".json")) + } + } namesSeat := func(s string) bool { for _, seat := range defined { if s == seat || strings.HasPrefix(s, "mesh.seat."+seat+".") { @@ -99,8 +110,8 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { if !strings.HasSuffix(path, ".go") { return nil } - // A test's import is a requirement of mesh-sdk's go.mod too (ADR 0625 §1, §3): a test may import the seat set, - // which catalogue already requires, and no other contracts module. + // ADR 0635 §1, §6: mesh-sdk imports no contracts module, in a test either (a test's import is a requirement of + // go.mod too, ADR 0625 §1). if strings.HasSuffix(path, "_test.go") { file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) if err != nil { @@ -108,8 +119,8 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } for _, imp := range file.Imports { p, _ := strconv.Unquote(imp.Path.Value) - if strings.HasPrefix(p, "git.novox.be/novox/mesh-contracts/") && p != contractsSeatSet { - pending = append(pending, filepath.ToSlash(path)+": imports "+p) + if strings.HasPrefix(p, "git.novox.be/novox/mesh-contracts") { + t.Errorf("%s imports %s: mesh-sdk imports no contracts module (ADR 0635 §1)", path, p) } } return nil @@ -124,11 +135,11 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } where := filepath.ToSlash(path) + ": " inCatalogue := dir == "catalogue" - // ADR 0625 §3: mesh-sdk's only import of a contracts module is catalogue's of the seat set. + // ADR 0635 §1: mesh-sdk imports no contracts module, the seat list being read from the bus. for _, imp := range file.Imports { p, _ := strconv.Unquote(imp.Path.Value) - if strings.HasPrefix(p, "git.novox.be/novox/mesh-contracts/") && (!inCatalogue || p != contractsSeatSet) { - pending = append(pending, where+"imports "+p) + if strings.HasPrefix(p, "git.novox.be/novox/mesh-contracts") { + t.Errorf("%simports %s: mesh-sdk imports no contracts module (ADR 0635 §1)", where, p) } } ast.Inspect(file, func(n ast.Node) bool { @@ -146,20 +157,6 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { pending = append(pending, where+v) } } - case *ast.ValueSpec: - for i, name := range n.Names { - if i < len(n.Values) && name.IsExported() { - if sel, ok := n.Values[i].(*ast.SelectorExpr); ok { - if x, ok := sel.X.(*ast.Ident); ok && x.Name == "contractseats" { - seatNames = append(seatNames, where+name.Name) - if !inCatalogue && slices.Contains([]string{"OperatorChannel", "MeshVault", "NodeEngine", - "NpmPackageRegistry", "GoPackageRegistry", "ContainerImageRegistry", "OciArtifactRegistry"}, sel.Sel.Name) { - pending = append(pending, where+name.Name) - } - } - } - } - } } return true }) @@ -317,8 +314,5 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) { } } -// contractsSeatSet is the seat-set package of mesh-contracts, catalogue's one import of a contracts module. -const contractsSeatSet = "git.novox.be/novox/mesh-contracts/go/seats" - // sdkModule is mesh-sdk's Go module path. const sdkModule = "git.novox.be/novox/mesh-sdk/go" diff --git a/go/provisions/provisions.go b/go/provisions/provisions.go index e42b024..3889dc3 100644 --- a/go/provisions/provisions.go +++ b/go/provisions/provisions.go @@ -18,7 +18,7 @@ import ( // The seat, its holder and its two states, and the names a reader declares in its `reads`. const ( - Seat = catalogue.ProvisionerSeat + Seat = "mesh-provisioner" Module = "mesh-provisioner" ProvisionsState = "provisions" PlansState = "plans" diff --git a/go/seatdef/refs.go b/go/seatdef/refs.go index e3fc540..435005a 100644 --- a/go/seatdef/refs.go +++ b/go/seatdef/refs.go @@ -2,14 +2,14 @@ package seatdef import "strings" -// ResolveRefs is a schema with every `{"$ref": "#/defs/"}` replaced by the definition's named type, as the seat +// ResolveRefs is a schema (its all-string lists as []string, StringLists) with every `{"$ref": "#/defs/"}` replaced by the definition's named type, as the seat // list's readers need it (a verb's input and answer are one schema each, not a schema and its types). A reference to a // name the definition does not define stays as it is; nesting deeper than 32 levels stops there. func ResolveRefs(schema map[string]any, defs map[string]map[string]any) map[string]any { if schema == nil { return nil } - m, _ := resolveValue(schema, defs, 0).(map[string]any) + m, _ := StringLists(resolveValue(schema, defs, 0)).(map[string]any) return m } @@ -38,3 +38,35 @@ func resolveValue(v any, defs map[string]map[string]any, depth int) any { } return v } + +// StringLists is a schema with every list whose items are all strings (`required`, `enum`) as a []string, the form the +// compiled seat list wrote them in; a JSON decoder reads them as []any. +func StringLists(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, e := range x { + out[k] = StringLists(e) + } + return out + case []any: + strs := make([]string, 0, len(x)) + for _, e := range x { + s, ok := e.(string) + if !ok { + strs = nil + break + } + strs = append(strs, s) + } + if strs != nil && len(x) > 0 { + return strs + } + out := make([]any, len(x)) + for i, e := range x { + out[i] = StringLists(e) + } + return out + } + return v +} diff --git a/go/seatlist/seatlisttest/parts/registry.json b/go/seatlist/seatlisttest/parts/registry.json new file mode 100644 index 0000000..ca0022f --- /dev/null +++ b/go/seatlist/seatlisttest/parts/registry.json @@ -0,0 +1,436 @@ +{ + "format": 1, + "part": "registry", + "description": "The verbs and events every registry seat shares, written once so the four cannot drift (design 51, design 26). Who may call each verb is the holder's trusted config value listing its callers (ADR 0363's form), checked against the caller the tool runner proved.", + "verbs": [ + { + "name": "publish", + "description": "Open a transfer, naming the artifact and the digest of every part; answers the digests the holder lacks, which the caller then sends by publish-part. A transfer already open answers the parts held, so a cut transfer goes on from there. With no parts and a version, the held digest is published under that version and nothing is stored (the trunk's claim).", + "callers": "the build modules", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk)" + }, + "version": { + "type": "string", + "description": "the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone" + }, + "kind": { + "type": "string", + "enum": [ + "package", + "image", + "bundle", + "archive", + "upstream" + ], + "description": "what it is" + }, + "tag": { + "type": "string", + "description": "the tag it carries, for a tagged version" + }, + "digest": { + "type": "string", + "description": "its digest, sha256: (a Go package's h1: hash beside it in the version's record)" + }, + "fingerprint": { + "type": "string", + "description": "the build's fingerprint" + }, + "commit": { + "type": "string", + "description": "the source commit it was built from" + }, + "transfer": { + "type": "string", + "description": "the transfer's name, the caller's own; the same name opens the same transfer again" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/defs/Part" + }, + "description": "every part's digest and size; none when the digest is already held" + } + }, + "required": [ + "transfer", + "name", + "kind", + "digest" + ] + }, + "answer": { + "type": "object", + "properties": { + "transfer": { + "type": "string", + "description": "the transfer" + }, + "lacks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the digests of the parts the holder lacks, to send by publish-part" + }, + "held": { + "type": "boolean", + "description": "the digest is held already: nothing to send; the outcome follows as an event" + } + }, + "required": [ + "transfer" + ] + }, + "optional": true + }, + { + "name": "publish-part", + "description": "Send one chunk of one part of an open transfer; the last chunk of the last part ends the transfer, and the holder hashes what it received, asks builder_versions, stores it and says published or refused. A chunk's message stays under the bus's largest message (1 MiB), so at most 760 KiB of data, base64.", + "callers": "the build modules, and only the proved caller that opened the transfer", + "input": { + "type": "object", + "properties": { + "transfer": { + "type": "string", + "description": "the transfer" + }, + "part": { + "type": "string", + "description": "the part's digest" + }, + "sequence": { + "type": "integer", + "description": "the chunk's place in its part, from 0" + }, + "last": { + "type": "boolean", + "description": "this chunk is the part's last" + }, + "data": { + "type": "string", + "description": "the chunk's bytes, base64 (at most 760 KiB before encoding)" + } + }, + "required": [ + "transfer", + "part", + "sequence", + "last", + "data" + ] + }, + "answer": { + "type": "object", + "properties": { + "transfer": { + "type": "string", + "description": "the transfer" + }, + "part": { + "type": "string", + "description": "the part" + }, + "received": { + "type": "integer", + "description": "the chunks of the part held so far" + }, + "complete": { + "type": "boolean", + "description": "the part is whole and its bytes match its digest" + } + }, + "required": [ + "transfer", + "part" + ] + }, + "optional": true + }, + { + "name": "versions", + "description": "The versions the seat holds of one name, newest first: each one's version, tag, digest, kind and when it was published.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "kind": { + "type": "string", + "description": "narrowed to one kind (optional)" + } + }, + "required": [ + "name" + ] + }, + "answer": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "versions": { + "type": "array", + "items": { + "$ref": "#/defs/Artifact" + }, + "description": "newest first" + } + }, + "required": [ + "name", + "versions" + ] + }, + "optional": true + }, + { + "name": "remove", + "description": "Remove one tagged version; the holder refuses an untagged one but for the retention removal of a bundle, archive or copied base by the artifact sweep's holder. Every removal emits removed with its reason. A tagged version the store cannot remove alone, because another tag names its digest, stays whole, its tag and bytes kept: the answer says kept with the reason shared-digest and the tags still naming the digest, removed false, and kept is emitted, never removed; the caller ends there without a retry.", + "callers": "the builder, for a tagged version; the artifact sweep's holder, for retention", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "version": { + "type": "string", + "description": "the version" + }, + "tag": { + "type": "string", + "description": "the tag (for a tagged version)" + }, + "digest": { + "type": "string", + "description": "the digest (for a bundle, archive or copied base)" + }, + "reason": { + "type": "string", + "enum": [ + "tag-gone", + "retention" + ], + "description": "why it goes" + } + }, + "required": [ + "name", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "removed": { + "type": "boolean", + "description": "it is gone" + }, + "kept": { + "type": "boolean", + "description": "it stays whole, its tag and its bytes: the store cannot remove the tag alone while another tag names its digest (reason, tags)" + }, + "reason": { + "type": "string", + "enum": [ + "shared-digest" + ], + "description": "why it was kept: shared-digest, another tag names its digest" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the other tags still naming its digest, when kept" + }, + "artifact": { + "$ref": "#/defs/Artifact" + } + }, + "required": [ + "removed" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "published", + "body": { + "$ref": "#/defs/Outcome" + } + }, + { + "name": "refused", + "body": { + "$ref": "#/defs/Outcome" + } + }, + { + "name": "removed", + "body": { + "$ref": "#/defs/Outcome" + } + }, + { + "name": "kept", + "body": { + "$ref": "#/defs/Outcome" + } + } + ], + "defs": { + "Artifact": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk)" + }, + "version": { + "type": "string", + "description": "the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone" + }, + "kind": { + "type": "string", + "enum": [ + "package", + "image", + "bundle", + "archive", + "upstream" + ], + "description": "what it is" + }, + "tag": { + "type": "string", + "description": "the tag it carries, for a tagged version" + }, + "digest": { + "type": "string", + "description": "its digest, sha256: (a Go package's h1: hash beside it in the version's record)" + }, + "fingerprint": { + "type": "string", + "description": "the build's fingerprint" + }, + "commit": { + "type": "string", + "description": "the source commit it was built from" + } + }, + "required": [ + "name", + "kind", + "digest" + ], + "description": "one published thing, as every registry event and answer names it" + }, + "Part": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the part's digest, sha256:: an image's layer or config, a package's file, a bundle's blob" + }, + "size": { + "type": "integer", + "description": "its size in bytes" + } + }, + "required": [ + "digest", + "size" + ], + "description": "one part of a transfer" + }, + "Outcome": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the package's, image's, bundle's or archive's name; a package's is its name in its own language (design 32): a Go package's the go.mod module path (git.novox.be/novox/mesh-sdk/go), an npm package's package.json's name (@novox/mesh-sdk)" + }, + "module": { + "type": "string", + "description": "the Go module path a Go package is fetched under, equal to name for a Go package (go-package-registry's published); empty for every other kind of artifact" + }, + "version": { + "type": "string", + "description": "the version the builder gave it (design 51 *The versions*); empty for a bundle or archive published by its digest alone" + }, + "kind": { + "type": "string", + "enum": [ + "package", + "image", + "bundle", + "archive", + "upstream" + ], + "description": "what it is" + }, + "tag": { + "type": "string", + "description": "the tag it carries, for a tagged version" + }, + "digest": { + "type": "string", + "description": "its digest, sha256: (a Go package's h1: hash beside it in the version's record)" + }, + "fingerprint": { + "type": "string", + "description": "the build's fingerprint" + }, + "commit": { + "type": "string", + "description": "the source commit it was built from" + }, + "holder": { + "type": "string", + "description": "the module holding the seat that said it" + }, + "transfer": { + "type": "string", + "description": "the transfer it ends, when it ended one" + }, + "reference": { + "type": "string", + "description": "the reference the seat answers: //@sha256:, or @ for a package (ADR 0605 §9)" + }, + "reason": { + "type": "string", + "description": "why: a refusal's cause, a removal's (retention, tag-gone, by-hand), or why a removal was kept (shared-digest)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "for kept: the other tags still naming its digest" + } + }, + "required": [ + "name", + "kind", + "digest", + "holder" + ], + "description": "the body of published, refused and removed" + } + } +} diff --git a/go/seatlist/seatlisttest/seatlisttest.go b/go/seatlist/seatlisttest/seatlisttest.go new file mode 100644 index 0000000..b26a073 --- /dev/null +++ b/go/seatlist/seatlisttest/seatlisttest.go @@ -0,0 +1,50 @@ +// Package seatlisttest is the seat list for tests (novox/hq ADR 0635 §1): mesh-sdk compiles no seat list, so a test that +// judges a manifest, a claim or a composition against the mesh's seats gives them to catalogue from here, as a host gives +// the ones it read from the bus: +// +// catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) +// +// The definitions are copies of the contracts modules' files (seats/.json, parts/.json), resolved with the +// parts they include. A seat's definition is its contracts module's; these are replaced where a seat moves or changes. +// It imports seatdef alone. +package seatlisttest + +import ( + "embed" + "fmt" + "sort" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +//go:embed seats/*.json parts/*.json +var files embed.FS + +// Definitions are every seat's definition, in seat order. It panics on a file that does not parse. +func Definitions() []seatdef.Definition { + parts := map[string]seatdef.Part{} + pe, _ := files.ReadDir("parts") + for _, e := range pe { + raw, _ := files.ReadFile("parts/" + e.Name()) + p, err := seatdef.ParsePart(raw) + if err != nil { + panic(fmt.Sprintf("seatlisttest: parts/%s: %v", e.Name(), err)) + } + parts[p.Part] = p + } + var out []seatdef.Definition + se, _ := files.ReadDir("seats") + for _, e := range se { + raw, _ := files.ReadFile("seats/" + e.Name()) + d, err := seatdef.Parse(raw) + if err != nil { + panic(fmt.Sprintf("seatlisttest: seats/%s: %v", e.Name(), err)) + } + if d, err = seatdef.Resolve(d, parts); err != nil { + panic(fmt.Sprintf("seatlisttest: seats/%s: %v", e.Name(), err)) + } + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i].Seat < out[j].Seat }) + return out +} diff --git a/go/seatlist/seatlisttest/seats/container-image-registry.json b/go/seatlist/seatlisttest/seats/container-image-registry.json new file mode 100644 index 0000000..b1a595f --- /dev/null +++ b/go/seatlist/seatlisttest/seats/container-image-registry.json @@ -0,0 +1,344 @@ +{ + "format": 1, + "seat": "container-image-registry", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0605", + "description": "The mesh's container images, in the one artifact store: the OCI distribution protocol's read side, an image's tags and mirroring an outside image.", + "includes": [ + "registry" + ], + "verbs": [ + { + "name": "mirror", + "description": "Copy an outside image into the store by its pinned digest; emits published or refused.", + "callers": "mesh-build-image's holder of the image build seat", + "input": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "the outside image's reference" + }, + "digest": { + "type": "string", + "description": "its pinned digest, sha256:" + }, + "name": { + "type": "string", + "description": "the name it is kept under" + } + }, + "required": [ + "source", + "digest", + "name" + ] + }, + "answer": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "the reference the seat answers" + }, + "digest": { + "type": "string", + "description": "the digest stored" + } + }, + "required": [ + "reference", + "digest" + ] + }, + "optional": true + }, + { + "name": "tags", + "description": "The tags of one image and the digest each names.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the image" + } + }, + "required": [ + "name" + ] + }, + "answer": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the image" + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "the tag" + }, + "digest": { + "type": "string", + "description": "its digest" + } + }, + "required": [ + "tag", + "digest" + ] + }, + "description": "every tag" + } + }, + "required": [ + "name", + "tags" + ] + }, + "optional": true + }, + { + "name": "hold", + "description": "Keep one untagged bundle, archive or copied base from removal by retention, as the artifact sweep's rules keep it (ADR 0617 §2); never an image of the holder's own. Emits held.", + "callers": "the module that runs the artifact sweep alone", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "which of the sweep's rules keeps it" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "held": { + "type": "boolean", + "description": "it is held" + }, + "since": { + "type": "string", + "description": "when the hold began, RFC 3339" + } + }, + "required": [ + "held" + ] + }, + "optional": true + }, + { + "name": "release", + "description": "Let a hold go; emits released. A release of what is not held answers released false and emits nothing, never an error: the outcome asked holds already. By hand only through the holder's own tool, the operator's session as the proved caller, recorded as a hand act.", + "callers": "the module that runs the artifact sweep; the operator's session through the holder's tool", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why it is let go" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "released": { + "type": "boolean", + "description": "a hold was let go; false when there was none" + } + }, + "required": [ + "released" + ] + }, + "optional": true + }, + { + "name": "list", + "description": "What the store holds of one repository, or of every repository the seat holds when none is named: every image manifest and index, each with its digest, whether it is an index (and the manifests an index names), the tags naming it, its size and whether it is held. Read from the store itself, so a manifest no tag names is listed too; it reads only (ADR 0632 §3).", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository, /; every repository of the seat when not given" + } + } + }, + "answer": { + "type": "object", + "properties": { + "repositories": { + "type": "array", + "items": { + "$ref": "#/defs/Repository" + }, + "description": "each repository named, in order of its name" + } + }, + "required": [ + "repositories" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "held", + "body": { + "$ref": "#/defs/Hold" + } + }, + { + "name": "released", + "body": { + "$ref": "#/defs/Hold" + } + } + ], + "family": { + "name": "container-image-registry.events", + "max-age-days": 7 + }, + "defs": { + "Hold": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why" + }, + "holder": { + "type": "string", + "description": "the module holding the seat" + }, + "by": { + "type": "string", + "description": "the caller the holder proved" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + } + }, + "required": [ + "name", + "digest", + "holder" + ], + "description": "the body of held and released" + }, + "Repository": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository" + }, + "manifests": { + "type": "array", + "items": { + "$ref": "#/defs/Listed" + }, + "description": "each manifest it holds, by digest" + } + }, + "required": [ + "name", + "manifests" + ] + }, + "Listed": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the manifest's digest, sha256:" + }, + "index": { + "type": "boolean", + "description": "it is an index, naming a manifest per platform" + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "for an index, the digests of the manifests it names" + }, + "artifact": { + "type": "string", + "description": "for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the tags naming it, none for an untagged one" + }, + "size": { + "type": "integer", + "description": "its bytes: its own content and every blob it names" + }, + "held": { + "type": "boolean", + "description": "a hold keeps it from retention" + } + }, + "required": [ + "digest", + "index", + "size", + "held" + ] + } + } +} diff --git a/go/seatlist/seatlisttest/seats/git.json b/go/seatlist/seatlisttest/seats/git.json new file mode 100644 index 0000000..836285a --- /dev/null +++ b/go/seatlist/seatlisttest/seats/git.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "seat": "git", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0111", + "delivers": "git" +} diff --git a/go/seatlist/seatlisttest/seats/go-package-registry.json b/go/seatlist/seatlisttest/seats/go-package-registry.json new file mode 100644 index 0000000..edf3998 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/go-package-registry.json @@ -0,0 +1,34 @@ +{ + "format": 1, + "seat": "go-package-registry", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0605", + "description": "The mesh's Go modules: Go's module proxy protocol's read side, and publishing over the bus.", + "delivers": "go-package-registry", + "includes": [ + "registry" + ], + "family": { + "name": "go-package-registry.events", + "max-age-days": 7 + }, + "provisions": [ + { + "name": "go-package-registry", + "form": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "the read side's address" + } + }, + "required": [ + "url" + ], + "description": "form none: the Go module proxy's read side, answered anonymously, with no credential (design 51)" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-artifact-store.json b/go/seatlist/seatlisttest/seats/mesh-artifact-store.json new file mode 100644 index 0000000..84c8a57 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-artifact-store.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "mesh-artifact-store", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0075" +} diff --git a/go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json b/go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json new file mode 100644 index 0000000..602a5f8 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json @@ -0,0 +1,103 @@ +{ + "format": 1, + "seat": "mesh-artifact-sweep", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0571", + "verbs": [ + { + "name": "artifacts", + "description": "Every artifact the mesh made that lives in its artifact store, each with its repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", + "input": { + "properties": { + "collected": { + "description": "\"true\": list the collected references too (counted always)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "repository": { + "description": "only this repository's references, as \u003cmodule\u003e/\u003cartifact\u003e (the counts stay whole)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "held", + "description": "Whether the artifact store holds each reference given, asked with one HEAD each: held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. Reads only.", + "input": { + "properties": { + "references": { + "description": "the artifacts' references, artifact-store://\u003cmodule\u003e/\u003cartifact\u003e/blobs/sha256:\u003chex\u003e: a list, or one text separated by commas", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "references" + ], + "type": "object" + } + }, + { + "name": "collect", + "description": "The artifact store's sweep, now: what is eligible is let go of. Without confirm a dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 seconds; what is left is said. Never touches a digest the mesh did not make.", + "input": { + "properties": { + "confirm": { + "description": "\"true\": let go of what is eligible (needs why); without it nothing is done", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "most": { + "description": "let go of at most this many (500 by default, at most 5000)", + "type": "string" + }, + "why": { + "description": "with confirm: why, required and recorded", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "mirrors", + "description": "The bases the mesh copied into its artifact store, each kept (a kept build stood on it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "images", + "description": "Every container image one machine's want and have name, each with the resources naming it: what the machine keeps when it prunes its images. Reads only.", + "input": { + "properties": { + "node": { + "description": "the machine's name", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + } + } + ], + "events": [ + { + "name": "artifacts-gone" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-broker.json b/go/seatlist/seatlisttest/seats/mesh-broker.json new file mode 100644 index 0000000..f0ebf4a --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-broker.json @@ -0,0 +1,569 @@ +{ + "format": 1, + "seat": "mesh-broker", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0079", + "delivers": "mesh-bus", + "description": "The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone.", + "verbs": [ + { + "name": "list", + "description": "Every entry the bus module created, its template, its settings and its fill against its cap.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "answer": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/defs/Held" + }, + "description": "every entry" + } + }, + "required": [ + "entries" + ] + }, + "optional": true + }, + { + "name": "show", + "description": "One entry, its template, settings and fill.", + "callers": "any caller whose role grants it", + "input": { + "$ref": "#/defs/Named" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "check", + "description": "Compare what the bus holds with what was asked, entry by entry; a difference that only grows is said as such.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "answer": { + "type": "object", + "properties": { + "differences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry" + }, + "asked": { + "type": "object", + "description": "asked" + }, + "held": { + "type": "object", + "description": "held" + }, + "grows": { + "type": "boolean", + "description": "the difference only grows: apply may act on it" + } + }, + "required": [ + "module", + "local-name" + ] + }, + "description": "every difference" + } + }, + "required": [ + "differences" + ] + }, + "optional": true + }, + { + "name": "orphans", + "description": "Entries the bus holds that no manifest asks for; never one whose manifest the judge refused or outgrew, nor one of the foundation's old sections.", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/defs/Held" + }, + "description": "every orphan" + } + }, + "required": [ + "entries" + ] + }, + "optional": true + }, + { + "name": "create", + "description": "Create one entry now.", + "callers": "the bus module's own process only", + "input": { + "$ref": "#/defs/Entry" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "apply", + "description": "Apply a difference that only grows: a larger cap, a longer age.", + "callers": "the bus module's own process only", + "input": { + "$ref": "#/defs/Named" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "recreate", + "description": "Rebuild an entry whose settings differ in a way that cannot grow, from its data where the storage type allows, after a stream snapshot.", + "callers": "the bus module's process, on the operator's warrant", + "input": { + "$ref": "#/defs/Warranted" + }, + "answer": { + "$ref": "#/defs/Held" + }, + "optional": true + }, + { + "name": "drop", + "description": "Remove one orphan, after a stream snapshot.", + "callers": "the bus module's process, on the operator's warrant", + "input": { + "$ref": "#/defs/Warranted" + }, + "answer": { + "type": "object", + "properties": { + "dropped": { + "type": "boolean", + "description": "it is gone" + }, + "snapshot": { + "type": "string", + "description": "the snapshot taken first" + } + }, + "required": [ + "dropped" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "created", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "refused", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "drifted", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "drift-cleared", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "full", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "full-cleared", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "orphaned", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "recreated", + "body": { + "$ref": "#/defs/Said" + } + }, + { + "name": "dropped", + "body": { + "$ref": "#/defs/Said" + } + } + ], + "family": { + "name": "mesh-broker.events", + "max-age-days": 7 + }, + "provisions": [ + { + "name": "bus-state", + "form": { + "$ref": "#/defs/Binding" + } + } + ], + "defs": { + "Entry": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + } + }, + "required": [ + "module", + "local-name", + "storage-type" + ], + "description": "one piece of bus state as a manifest asks it" + }, + "Fill": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "messages, keys or objects held" + }, + "bytes": { + "type": "integer", + "description": "bytes held" + }, + "cap": { + "type": "integer", + "description": "its cap in bytes" + } + }, + "required": [ + "count", + "bytes", + "cap" + ] + }, + "Held": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + }, + "bus-name": { + "type": "string", + "description": "its name on the bus, _" + }, + "settings": { + "type": "object", + "description": "the settings the bus holds" + }, + "fill": { + "$ref": "#/defs/Fill" + }, + "state": { + "type": "string", + "enum": [ + "held", + "refused", + "waiting", + "drifted", + "full", + "orphaned" + ], + "description": "the entry's state: held as asked, refused, waiting to be made, drifted from what is asked, full, or orphaned (no manifest asks it)" + }, + "why": { + "type": "string", + "description": "why it is in its state, in plain words; empty when held as asked" + } + }, + "required": [ + "module", + "local-name", + "storage-type", + "bus-name" + ], + "description": "one entry as the bus holds it" + }, + "Said": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module asking it" + }, + "local-name": { + "type": "string", + "description": "its local name in the module's manifest" + }, + "storage-type": { + "type": "string", + "enum": [ + "stream", + "key-value", + "object-store", + "ledger" + ], + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "enum": [ + "state", + "events", + "work-queue", + "log", + "objects", + "ledger", + "" + ], + "description": "its template; empty for a configuration of its own" + }, + "options": { + "type": "object", + "description": "the template's options the module set, within their bounds" + }, + "configuration": { + "type": "object", + "description": "its own configuration, where no template fits" + }, + "bus-name": { + "type": "string", + "description": "its name on the bus" + }, + "asked": { + "type": "object", + "description": "the settings asked" + }, + "held": { + "type": "object", + "description": "the settings held" + }, + "why": { + "type": "string", + "description": "why: a refusal's cause, a drift's difference, a drop's or recreate's reason" + }, + "fill": { + "$ref": "#/defs/Fill" + }, + "snapshot": { + "type": "string", + "description": "the stream snapshot taken before a recreate or drop" + }, + "binding": { + "$ref": "#/defs/Binding" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict the bus module acted on (ADR 0622 §2), so a reader orders what it hears" + } + }, + "required": [ + "module", + "local-name", + "bus-name" + ], + "description": "the body of every event of the seat" + }, + "Named": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry's local name" + } + }, + "required": [ + "module", + "local-name" + ] + }, + "Warranted": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "local-name": { + "type": "string", + "description": "the entry's local name" + }, + "warrant": { + "type": "string", + "description": "the operator's warrant, as the operator channel decided it (ADR 0259 §6)" + }, + "why": { + "type": "string", + "description": "why, kept in the record" + } + }, + "required": [ + "module", + "local-name", + "warrant", + "why" + ] + }, + "Binding": { + "type": "object", + "properties": { + "bus-name": { + "type": "string", + "description": "the entry's name on the bus" + }, + "storage-type": { + "type": "string", + "description": "how the bus keeps it" + }, + "template": { + "type": "string", + "description": "its template" + }, + "subjects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "its subjects, for a stream" + } + }, + "required": [ + "bus-name", + "storage-type" + ], + "description": "the binding a module receives in its membership for one entry (ADR 0595 §1, §7)" + } + } +} diff --git a/go/seatlist/seatlisttest/seats/mesh-catalog.json b/go/seatlist/seatlisttest/seats/mesh-catalog.json new file mode 100644 index 0000000..40b6618 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-catalog.json @@ -0,0 +1,69 @@ +{ + "format": 1, + "seat": "mesh-catalog", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0604", + "verbs": [ + { + "name": "modules", + "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "module", + "description": "One module whole: its repository, directory, commit and fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + }, + { + "name": "provides", + "description": "Which modules provide a provision (a module provides its own name too) and which require it, from the module graph's provision edges. Reads only.", + "optional": true, + "input": { + "properties": { + "provision": { + "description": "the provision, such as postgres-database", + "type": "string" + } + }, + "required": [ + "provision" + ], + "type": "object" + } + }, + { + "name": "dependents", + "description": "The modules whose build edges reach a module, directly or through others (a base, a mesh package it publishes, the build module that builds them): what a change of it builds again, from the module graph. Reads only.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module's name", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-conditions.json b/go/seatlist/seatlisttest/seats/mesh-conditions.json new file mode 100644 index 0000000..5513eba --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-conditions.json @@ -0,0 +1,95 @@ +{ + "format": 1, + "seat": "mesh-conditions", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0564", + "verbs": [ + { + "name": "conditions", + "description": "What is wrong with the mesh now: every open condition, urgent first, then oldest, raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition lately.", + "input": { + "properties": { + "days": { + "description": "with history: how many days back (default 7, at most 90)", + "type": "string" + }, + "history": { + "description": "\"true\": every raising, change, silence and clearing lately, oldest first", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "key": { + "description": "a condition's key: that one whole; with history, only its transitions", + "type": "string" + }, + "machine": { + "description": "only those about this machine", + "type": "string" + }, + "scope": { + "description": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", + "type": "string" + }, + "severity": { + "description": "only urgent, or only warning", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "silence", + "description": "Stop one condition's messages for a while, a hand act with why: it stays open and in status, and the silence is kept as its transition in history.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word (the condition's kind when absent)", + "type": "string" + }, + "for": { + "description": "how long: 30m, 4h, 2d; at most 7d", + "type": "string" + }, + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why, required and recorded", + "type": "string" + } + }, + "required": [ + "key", + "for", + "why" + ], + "type": "object" + } + }, + { + "name": "resync", + "description": "Read every observer's findings again and raise and clear what differs from the open conditions: the recovery of a keeper that missed a change.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "events": [ + { + "name": "condition-raised" + }, + { + "name": "condition-changed" + }, + { + "name": "condition-cleared" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-config.json b/go/seatlist/seatlisttest/seats/mesh-config.json new file mode 100644 index 0000000..d095825 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-config.json @@ -0,0 +1,222 @@ +{ + "format": 1, + "seat": "mesh-config", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0348, ADR 0565", + "verbs": [ + { + "name": "config", + "description": "Set, clear, list or read the history of config values: mesh config or node config for a predefined key, or a module's own value on one node, each set with why, validated, and naming each module on each node whose value moves.", + "optional": true, + "input": { + "properties": { + "action": { + "description": "list (when not given), set, clear or history", + "type": "string" + }, + "by": { + "description": "who gave the value at the controller's terminal", + "type": "string" + }, + "key": { + "description": "the config key", + "type": "string" + }, + "limit": { + "description": "for history: at most this many, newest", + "type": "number" + }, + "module": { + "description": "a module: its own value, with node", + "type": "string" + }, + "node": { + "description": "a node", + "type": "string" + }, + "value": { + "description": "for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list" + }, + "why": { + "description": "for set and clear: why", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the values asked, or the one set or cleared, or its history" + } + }, + { + "name": "renames", + "optional": true, + "description": "The open renames of config values (design 32 *A config value renamed*): per rename the module, the old and new name, when it moved, and the nodes still holding the old name open.", + "input": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "one module (optional)" + } + } + }, + "callers": "any caller whose role grants it; the checker asks it", + "answer": { + "type": "object", + "properties": { + "renames": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "from": { + "type": "string", + "description": "the old name" + }, + "to": { + "type": "string", + "description": "the new name" + }, + "at": { + "type": "string", + "description": "when it moved, RFC 3339" + }, + "open": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the nodes still holding the old name open" + } + }, + "required": [ + "module", + "from", + "to" + ] + }, + "description": "every open rename" + } + }, + "required": [ + "renames" + ] + } + } + ], + "description": "The mesh's config values: read and set one, and the renames still open. mesh-config's own events stay under its own subjects (ADR 0620 §2).", + "holder-events": [ + { + "name": "config-changed", + "subject": "mesh.mod..event.config-changed" + }, + { + "name": "renamed", + "subject": "mesh.stream..event.renamed", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "from": { + "type": "string", + "description": "the old name" + }, + "to": { + "type": "string", + "description": "the new name" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + } + }, + "required": [ + "module", + "from", + "to", + "at" + ] + } + }, + { + "name": "retiring", + "subject": "mesh.stream..event.retiring", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "key": { + "type": "string", + "description": "the value's or secret's name" + }, + "kind": { + "type": "string", + "enum": [ + "value", + "secret" + ] + }, + "since": { + "type": "string", + "description": "since when, RFC 3339" + }, + "ask": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "module", + "key", + "kind", + "since" + ] + } + }, + { + "name": "retired", + "subject": "mesh.stream..event.retired", + "family": "config.events", + "body": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module" + }, + "name": { + "type": "string", + "description": "the name" + }, + "node": { + "type": "string", + "description": "the node" + }, + "ask": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "module", + "name" + ] + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json b/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json new file mode 100644 index 0000000..7b56ab5 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json @@ -0,0 +1,245 @@ +{ + "format": 1, + "seat": "mesh-delivery-checker", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0547", + "verbs": [ + { + "name": "try", + "description": "Try a pushed head before its pull request is opened: the checker judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most 4 wait or run at once, mesh-wide.", + "optional": true, + "replaces": [ + "sh merge-check.sh", + "docker run postgres" + ], + "input": { + "properties": { + "head": { + "description": "the full commit to try, 40 or 64 hex digits", + "type": "string" + }, + "level": { + "description": "unit or integration: only this level; both when absent", + "type": "string" + }, + "repository": { + "description": "/", + "type": "string" + }, + "tests": { + "description": "a go test -run pattern: only those tests run, and nothing is kept", + "type": "string" + }, + "trunk": { + "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "type": "string" + } + }, + "required": [ + "repository", + "head" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the try's id" + }, + "state": { + "type": "string", + "description": "its state" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "try-read", + "description": "Where a try stands, as stored: queued, running, or its end (pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit it was merged onto, the time it took and what failed first. Read-only.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the try's id, try-", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the try: its state, its steps and its verdict when it has one" + } + }, + { + "name": "try-end", + "description": "End a try that waits or runs, as error, by hand: say why (kept in what it says). Its place among the tries is free at once and the next try of its head asks anew with a new id. A try that ended already is refused.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the try's id, try-", + "type": "string" + }, + "why": { + "description": "why it is ended, in words", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the try" + }, + "ended": { + "type": "boolean", + "description": "it is ended" + } + }, + "required": [ + "id", + "ended" + ] + } + } + ], + "description": "The merge check's tries: start one, read one, end one. The checker's own events stay under its own subjects (ADR 0620 §2).", + "holder-events": [ + { + "name": "verdict", + "subject": "mesh.stream..event.verdict", + "family": "checker.asks", + "body": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "owner/repo" + }, + "pull": { + "type": "integer", + "description": "the pull request's number, for a pull request's check" + }, + "head": { + "type": "string", + "description": "the commit checked" + }, + "verdict": { + "type": "string", + "description": "pass or fail" + }, + "out-of-step": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the generated files out of step with their seat definitions, each path (design 26)" + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module whose artifact's series changes" + }, + "artifact": { + "type": "string", + "description": "its artifact, or the package's ecosystem name as the module's packages name it" + }, + "from": { + "type": "string", + "description": "the series on trunk" + }, + "to": { + "type": "string", + "description": "the series the checked head gives it" + } + }, + "required": [ + "module", + "artifact", + "from", + "to" + ] + }, + "description": "each change of an artifact's series the checked head makes against trunk's manifests, once each (design 32); absent when it changes none" + } + }, + "required": [ + "repository", + "head", + "verdict" + ] + } + }, + { + "name": "tag-wanted", + "subject": "mesh.stream..event.tag-wanted", + "family": "checker.asks", + "body": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "owner/repo" + }, + "pull": { + "type": "integer", + "description": "the pull request" + }, + "head": { + "type": "string", + "description": "its head" + }, + "branch": { + "type": "string", + "description": "the pull request's branch: its tag is removed when the branch ends" + }, + "module": { + "type": "string", + "description": "the group member" + }, + "package": { + "type": "string", + "description": "the package it declares" + }, + "tag": { + "type": "string", + "description": "the tag wanted" + } + }, + "required": [ + "repository", + "head", + "branch", + "module", + "package" + ] + } + }, + { + "name": "preview.written", + "subject": "mesh.stream..event.preview.written", + "family": "checks" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-delivery.json b/go/seatlist/seatlisttest/seats/mesh-delivery.json new file mode 100644 index 0000000..bafd85c --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-delivery.json @@ -0,0 +1,226 @@ +{ + "format": 1, + "seat": "mesh-delivery", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0239", + "verbs": [ + { + "name": "deliveries", + "description": "Every delivery not final, and those that ended in the last day, one line each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, repository or group.", + "input": { + "properties": { + "all": { + "description": "\"true\": the final ones of the last thirty days too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "group": { + "description": "a group's id (its branch name)", + "type": "string" + }, + "repository": { + "description": "owner/repository", + "type": "string" + }, + "state": { + "description": "one state, e.g. delivering or held", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "times", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the delivery view (nox-delivery): each machine's want and have, and since when", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "show", + "description": "One delivery or group whole: its delivery plan (what it builds, what each machine receives, what is not an ordinary send), every transition with when and why, the machine steps of its walk, its group and its order.", + "input": { + "properties": { + "id": { + "description": "a delivery's id, or a group's", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + { + "name": "groups", + "description": "Every delivery group: its members in order, why each pair is ordered (declared, built by, version skew, engine before controller, by name), its composed check and its state, derived from its members.", + "input": { + "properties": { + "all": { + "description": "\"true\": the groups that ended too", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "what-if", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the merge check's composition, which composes every machine a change touches", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "checks", + "description": "What the mesh's checks said of a pull request's head or of one commit: each of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, mesh/delivery, …) with its state, description and when it was set; the merge check's full verdict as the controller said it — each layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's protection would let it merge, every required status being success.", + "optional": true, + "input": { + "properties": { + "commit": { + "description": "a commit's sha, or the start of one, instead of a pull request", + "type": "string" + }, + "number": { + "description": "a pull request's number: its head is read", + "type": "string" + }, + "repository": { + "description": "owner/repository", + "type": "string" + } + }, + "required": [ + "repository" + ], + "type": "object" + } + }, + { + "name": "table", + "description": "The state table every delivery runs by: each transition with its guard, each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "stalled", + "description": "Every delivery held past its state's bound, with the transition the table lets healer H2 take for it, or none.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "recheck", + "description": "Check a rejected or ready delivery again: it goes back to proposed and the controller is asked for its check. With why.", + "input": { + "properties": { + "id": { + "description": "the delivery's id", + "type": "string" + }, + "why": { + "description": "why, kept with the transition", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + } + }, + { + "name": "release", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "stop", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "close", + "description": "Healer H2's verb: take the one transition the table names for a delivery held past its bound. Refused for any other.", + "input": { + "properties": { + "id": { + "description": "the delivery's id", + "type": "string" + }, + "why": { + "description": "the condition it answers", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + } + }, + { + "name": "retire-history", + "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: nothing to retire: the gate keeps no delivery after its merge", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "settle", + "description": "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another reason, naming why.", + "optional": true, + "input": { + "additionalProperties": false, + "properties": { + "ids": { + "description": "the held deliveries' ids (owner/repository@commit)", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "why": { + "description": "why they may be settled, shown to the operator", + "type": "string" + } + }, + "required": [ + "ids", + "why" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-dns-resolver.json b/go/seatlist/seatlisttest/seats/mesh-dns-resolver.json new file mode 100644 index 0000000..14480ac --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-dns-resolver.json @@ -0,0 +1,9 @@ +{ + "format": 1, + "seat": "mesh-dns-resolver", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0194, ADR 0223", + "delivers": "wildcard-resolution", + "replicated": true +} diff --git a/go/seatlist/seatlisttest/seats/mesh-enrolment.json b/go/seatlist/seatlisttest/seats/mesh-enrolment.json new file mode 100644 index 0000000..b611558 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-enrolment.json @@ -0,0 +1,170 @@ +{ + "format": 1, + "seat": "mesh-enrolment", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0587", + "verbs": [ + { + "name": "issue", + "description": "Issue a one-time token for a new machine, on the operator's Approve: refused at once, naming every finding, for a name that is not a node name or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise reserves the lowest free private-network address, asks the operator under the seat's own name and, on the warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.", + "input": { + "properties": { + "expires": { + "description": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", + "type": "string" + }, + "node": { + "description": "the new machine's name in the mesh", + "type": "string" + }, + "overlay_key": { + "description": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", + "type": "string" + }, + "why": { + "description": "why it joins, shown to the operator with the ask", + "type": "string" + } + }, + "required": [ + "node", + "why", + "overlay_key" + ], + "type": "object" + } + }, + { + "name": "redeem", + "description": "Spend a token: asked by a joining node-engine dialled as enrol.\u003cnode\u003e alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the vault for the node's account and certificate, writes the node into placement, and answers the account sealed to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys it finishes what is not done and answers the same; with other keys it is refused.", + "input": { + "additionalProperties": false, + "properties": { + "node": { + "description": "the node the token was issued for", + "type": "string" + }, + "overlay_key": { + "description": "the private network key's public half", + "type": "string" + }, + "profile": { + "description": "what the machine is, as its record says it", + "type": "object" + }, + "proof": { + "description": "the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64", + "type": "string" + }, + "public_key": { + "description": "the identity key's public half, base64", + "type": "string" + }, + "sealing_key": { + "description": "the sealing key's public half, which the account is sealed to", + "type": "string" + }, + "serving_key": { + "description": "the serving key's public half, which the certificate binds", + "type": "string" + }, + "tunnel": { + "description": "a tunnel found on the machine: everything but its private key", + "type": "object" + } + }, + "required": [ + "node", + "public_key", + "sealing_key", + "proof" + ], + "type": "object" + } + }, + { + "name": "confirm", + "description": "Confirm an enrolment: asked by the node-engine dialled as its own node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the enrolment.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "revoke", + "description": "End an enrolment at any stage, with why, without an Approve: withdraws an open ask, deletes the node's key and its address, has the vault retire an account it already minted for it, records the act as a hand act and says enrolment-revoked.", + "input": { + "properties": { + "node": { + "description": "the node", + "type": "string" + }, + "why": { + "description": "why, recorded with the hand act", + "type": "string" + } + }, + "required": [ + "node", + "why" + ], + "type": "object" + } + }, + { + "name": "tokens", + "description": "Read every open enrolment, or one: its stage, expiry, ask and revision, the bucket's fill against its cap. For a node at live, its token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With history, the ended enrolments from the seat's events, as far back as the events are kept.", + "input": { + "properties": { + "history": { + "description": "\"true\": the ended enrolments too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "node": { + "description": "one node", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "again", + "description": "Raise an issue's ask again when the operator's channel does not hold it, and nothing more.", + "input": { + "properties": { + "node": { + "description": "the node whose ask is raised again", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + } + } + ], + "events": [ + { + "name": "enrolment-issued" + }, + { + "name": "node-enrolled" + }, + { + "name": "enrolment-refused" + }, + { + "name": "enrolment-revoked" + }, + { + "name": "enrolment-expired" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-hand-acts.json b/go/seatlist/seatlisttest/seats/mesh-hand-acts.json new file mode 100644 index 0000000..a789dbb --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-hand-acts.json @@ -0,0 +1,97 @@ +{ + "format": 1, + "seat": "mesh-hand-acts", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0565", + "verbs": [ + { + "name": "hand-act", + "description": "Record one act done by hand: what, why, its cause in a word or a condition's kind (the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the runtime proves, never said by the caller. Said as act-recorded.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word, or a condition's kind", + "type": "string" + }, + "condition": { + "description": "the key of the condition it addressed, if any (optional)", + "type": "string" + }, + "verb": { + "description": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + "type": "string" + }, + "what": { + "description": "what was done, in a line", + "type": "string" + }, + "why": { + "description": "why it had to be done by hand", + "type": "string" + } + }, + "required": [ + "what", + "why" + ], + "type": "object" + } + }, + { + "name": "hand-acts", + "description": "What was done by hand lately, over this log and the controller's: who, why and the cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + "input": { + "properties": { + "cause": { + "description": "only this cause", + "type": "string" + }, + "days": { + "description": "how many days back (default 14)", + "type": "string" + }, + "warrants": { + "description": "\"true\": every warrant recorded", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "who": { + "description": "only acts whose who contains this", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "warranted", + "description": "Record what a module did on the operator's warrant: who chose, through which channel, with which proofs and which answer read from the router's own record of that module's ask, never from the caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + "input": { + "properties": { + "ask": { + "description": "its ask's id", + "type": "string" + }, + "asker": { + "description": "the module that asked", + "type": "string" + } + }, + "required": [ + "asker", + "ask" + ], + "type": "object" + } + } + ], + "events": [ + { + "name": "act-recorded" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-healers.json b/go/seatlist/seatlisttest/seats/mesh-healers.json new file mode 100644 index 0000000..0f463da --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-healers.json @@ -0,0 +1,73 @@ +{ + "format": 1, + "seat": "mesh-healers", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0564", + "verbs": [ + { + "name": "healers", + "description": "The healers: each registered response to one kind of condition, its repair, its budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.", + "input": { + "properties": { + "days": { + "description": "how many days of acts back (default 7)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "heal", + "description": "Take one healer's act for one open condition now, within its budget and the brake.", + "input": { + "properties": { + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why now, kept with the act", + "type": "string" + } + }, + "required": [ + "key", + "why" + ], + "type": "object" + } + }, + { + "name": "drill", + "description": "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it tests. A drill is never a repair: no healer is wanted for it however often it is run.", + "input": { + "properties": { + "condition": { + "description": "the key of the condition the drill is meant to raise, if any (optional)", + "type": "string" + }, + "what": { + "description": "what was done on purpose, in a line", + "type": "string" + }, + "why": { + "description": "what the drill tests", + "type": "string" + } + }, + "required": [ + "what", + "why" + ], + "type": "object" + } + } + ], + "events": [ + { + "name": "healer-acted" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-placement.json b/go/seatlist/seatlisttest/seats/mesh-placement.json new file mode 100644 index 0000000..cc3d44c --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-placement.json @@ -0,0 +1,137 @@ +{ + "format": 1, + "seat": "mesh-placement", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0567", + "verbs": [ + { + "name": "placement", + "description": "Read placement: one node's key, the nodes one module is placed on, or every key, each with its revision, and how full the state is against its cap. With history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the proposal's changes from its placement-changed event.", + "optional": true, + "input": { + "properties": { + "history": { + "description": "\"true\": the changes in order, from the stream and the state's revisions", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module: the nodes it is placed on", + "type": "string" + }, + "node": { + "description": "one node", + "type": "string" + }, + "seats": { + "description": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "proposals", + "description": "Every open proposal of placement: its changes, the revision of each key it was laid over, its candidates, every node's answer, the differences computed and answered, its verdict and its ask. Narrowed to one by id.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "one proposal's id", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "propose", + "description": "Propose a change of placement, with why: laid over the current keys and refused at once, naming every finding, when the result cannot be placed (two modules on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", + "optional": true, + "input": { + "additionalProperties": false, + "properties": { + "changes": { + "description": "the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)", + "items": { + "required": [ + "op", + "node" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "warrant": { + "description": "for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address", + "required": [ + "node", + "address", + "ask", + "warrant" + ], + "type": "object" + }, + "why": { + "description": "why, shown to the operator with the proposal", + "type": "string" + } + }, + "required": [ + "why", + "changes" + ], + "type": "object" + } + }, + { + "name": "withdraw", + "description": "Withdraw an open proposal: its key and its candidates are deleted and placement-withdrawn carries it whole. With why.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + }, + "why": { + "description": "why, kept in the event", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + } + }, + { + "name": "again", + "description": "Judge an open proposal now with the answers that stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. Nothing more: the recovery of a proposal whose judgement or ask was lost.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-provisioner.json b/go/seatlist/seatlisttest/seats/mesh-provisioner.json new file mode 100644 index 0000000..fe16d81 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-provisioner.json @@ -0,0 +1,35 @@ +{ + "format": 1, + "seat": "mesh-provisioner", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0349", + "verbs": [ + { + "name": "provisioning", + "description": "Read what the provisioner keeps: the registry (which domain answers which kind of placeholder), the needs each module on each node recorded, what is applied where, or the items of every collection, narrowed by node and module. Reads only.", + "optional": true, + "input": { + "properties": { + "collection": { + "description": "for collect: a collection", + "type": "string" + }, + "module": { + "description": "a module: for collect, a contributor", + "type": "string" + }, + "node": { + "description": "a node", + "type": "string" + }, + "what": { + "description": "registry (when not given), needs, applied or collect", + "type": "string" + } + }, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-self-check.json b/go/seatlist/seatlisttest/seats/mesh-self-check.json new file mode 100644 index 0000000..56ba074 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-self-check.json @@ -0,0 +1,43 @@ +{ + "format": 1, + "seat": "mesh-self-check", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0564", + "verbs": [ + { + "name": "doctor", + "description": "The self-check: the last run's verdict at once, each probe passed, failed, deferred (its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one probe's verdict whole. Runs every five minutes on its own; each failure is a finding.", + "input": { + "properties": { + "probe": { + "description": "one probe's id (D6): its verdict alone, with every finding", + "type": "string" + }, + "run": { + "description": "\"true\": run every probe now and answer the verdict", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "probes", + "description": "The registry: what each probe asserts, where it reads its facts, and the finding it writes.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "events": [ + { + "name": "checked" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-store.json b/go/seatlist/seatlisttest/seats/mesh-store.json new file mode 100644 index 0000000..f02517a --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-store.json @@ -0,0 +1,45 @@ +{ + "format": 1, + "seat": "mesh-store", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0079", + "delivers": "postgres-database", + "verbs": [ + { + "name": "databases", + "description": "Every database the store holds, with its on-disk size.", + "replaces": [ + "psql -l" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "query", + "description": "One read-only statement against one database the store holds.", + "replaces": [ + "psql" + ], + "input": { + "properties": { + "database": { + "description": "the database to query", + "type": "string" + }, + "sql": { + "description": "the read-only statement", + "type": "string" + } + }, + "required": [ + "database", + "sql" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-vault.json b/go/seatlist/seatlisttest/seats/mesh-vault.json new file mode 100644 index 0000000..5a115a6 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-vault.json @@ -0,0 +1,761 @@ +{ + "format": 1, + "seat": "mesh-vault", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0161", + "delivers": "secret", + "verbs": [ + { + "name": "get", + "description": "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the bus proved it. Never the value in the clear, and never to a private key.", + "optional": true, + "input": { + "properties": { + "name": { + "description": "the secret's name", + "type": "string" + }, + "public_key": { + "description": "the asker's X25519 public key as SPKI PEM — never a private key", + "type": "string" + } + }, + "required": [ + "name", + "public_key" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "secret-ask", + "description": "Ask the operator for a module's own secret at a desk, from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR 0563 §8): the ask is recorded in the vault's store and bounded before the prompt opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", + "optional": true, + "input": { + "properties": { + "asked_by": { + "description": "who asked, as the bus named its caller", + "type": "string" + }, + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "desk": { + "description": "the machine whose prompt asks", + "type": "string" + }, + "module": { + "description": "the module", + "type": "string" + }, + "name": { + "description": "the secret's name in the module", + "type": "string" + }, + "node": { + "description": "the machine the secret is for", + "type": "string" + }, + "outcome": { + "description": "instead of sealed: how the prompt ended", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "issued", + "description": "Take the credential a provider's software issued for a provision, from the controller alone, which asks the provider's tool with a key the vault made for the call; kept as the provision's one credential, or, when the tool answers a value per holder named, each as that holder's own; the same value again writes nothing.", + "optional": true, + "input": { + "properties": { + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "holders": { + "description": "the provision's holders from that provider, each {node, module}: its tool may answer a value for each, kept as that holder's own and handed to it alone", + "items": { + "properties": { + "module": { + "description": "the consuming module", + "type": "string" + }, + "node": { + "description": "the holder's machine", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "module": { + "description": "the provider's module", + "type": "string" + }, + "provider": { + "description": "the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "rotate", + "description": "Make a secret anew. Refused for a value a provider's software issued and for an accepted credential. Never answers the value.", + "optional": true, + "input": { + "properties": { + "by": { + "description": "who asked, when the controller carries the act", + "type": "string" + }, + "module": { + "description": "the module", + "type": "string" + }, + "name": { + "description": "the secret's name in the module", + "type": "string" + }, + "node": { + "description": "the machine, for a module's own secret", + "type": "string" + }, + "provider": { + "description": "the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "move-in", + "description": "Take one value of the controller's store in, as it is, from the node-engine of a machine it was sealed to and no other caller: name the row, its origin and generation and get a call and a key made for it, then send the call, the value sealed to that key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read back sealed to that key.", + "optional": true, + "input": { + "properties": { + "call": { + "description": "the second step: the call the first step answered", + "type": "string" + }, + "consumer": { + "description": "the consumer's machine", + "type": "string" + }, + "consumer_module": { + "description": "the consuming module", + "type": "string" + }, + "generation": { + "description": "the controller's generation for the value", + "type": "string" + }, + "local": { + "description": "the credential's local name, when it has one", + "type": "string" + }, + "made_at": { + "description": "when it was made (RFC 3339)", + "type": "string" + }, + "module": { + "description": "its module", + "type": "string" + }, + "name": { + "description": "its name in the module", + "type": "string" + }, + "node": { + "description": "a module's own secret: its machine", + "type": "string" + }, + "origin": { + "description": "made, accepted or issued, as the controller's store says", + "type": "string" + }, + "provider": { + "description": "a pair credential: the provider's machine", + "type": "string" + }, + "provision": { + "description": "the provision", + "type": "string" + }, + "read_back_to": { + "description": "the second step: a raw X25519 public key the read-back is sealed to", + "type": "string" + }, + "sealed": { + "description": "the second step: the value sealed to the call's key — never the value", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "node-key", + "description": "State this machine's sealing key to the vault, in its own name, from the node-engine of the machine alone: at its start and whenever the key changes. A first or changed key is recorded and the machine's core secrets are said changed; the same key again changes nothing.", + "optional": true, + "input": { + "properties": { + "key": { + "description": "the machine's sealing key, a raw X25519 public key in base64", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "own", + "description": "Answer the node-engine of one machine, about its own machine alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A credential the vault does not hold is named missing; never a value in the clear.", + "optional": true, + "input": { + "properties": { + "credentials": { + "description": "the pair credentials the machine's composition reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local}", + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "issue-account", + "description": "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", + "optional": true, + "input": { + "properties": { + "user": { + "description": "the bus user, e.g. ace.mesh-build-go", + "type": "string" + } + }, + "required": [ + "user" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "sign", + "description": "Sign with the vault's key or authority (novox/hq ADR 0550 §2): kind node-certificate signs the certificate of a machine's internal name, asked by that machine's node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", + "optional": true, + "input": { + "properties": { + "kind": { + "description": "node-certificate or enrolment", + "type": "string" + }, + "node": { + "description": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", + "type": "string" + }, + "reply": { + "description": "the redeem answer's bytes in base64, for an enrolment", + "type": "string" + }, + "serving_key": { + "description": "from mesh-enrolment: the serving key's public half the enrolling node presented", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "for a node certificate the certificate as PEM and the authority that signed it, for an enrolment the signature in base64; always the signer's fingerprint and the digest; never a key's private half", + "properties": { + "kind": { + "type": "string", + "description": "node-certificate or enrolment, as asked" + }, + "node": { + "type": "string", + "description": "the machine, for a node certificate" + }, + "certificate": { + "type": "string", + "description": "the node certificate as PEM, for a node certificate" + }, + "authority": { + "type": "string", + "description": "the authority's certificate that signed it, as PEM" + }, + "signature": { + "type": "string", + "description": "the signature in base64, for an enrolment" + }, + "signer": { + "type": "string", + "description": "the signer's fingerprint" + }, + "digest": { + "type": "string", + "description": "sha256: of the bytes signed" + } + }, + "required": [ + "kind", + "signer", + "digest" + ] + } + }, + { + "name": "enrolment-token", + "description": "Make a one-time token for a node mesh-enrolment issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash written as the bus user enrol.. Answers only what the joining machine must trust (the bus's address, the bus certificate's fingerprint, the vault's public signing key), never the token; with sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", + "optional": true, + "input": { + "properties": { + "drop": { + "description": "\"true\": drop the node's token and its hash", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "expires": { + "description": "when the token expires (RFC 3339), as the enrolment's key says", + "type": "string" + }, + "node": { + "description": "the node the token is for", + "type": "string" + }, + "sealed": { + "description": "\"true\": answer the token sealed to the operator key", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "tunnel": { + "additionalProperties": false, + "description": "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", + "properties": { + "address": { + "description": "the joining node's tunnel address", + "type": "string" + }, + "hub_endpoint": { + "description": "the hub's endpoint, host:port", + "type": "string" + }, + "hub_key": { + "description": "the hub's tunnel public key", + "type": "string" + }, + "key": { + "description": "the joining machine's own tunnel public key, as given at issue", + "type": "string" + }, + "range": { + "description": "the private network's range, a prefix", + "type": "string" + } + }, + "required": [ + "address", + "hub_endpoint", + "hub_key", + "key", + "range" + ], + "type": "object" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what the joining machine must trust, never the token: the bus's address, its certificate's fingerprint and the vault's public signing key; the token sealed to the operator key when asked sealed; or that a drop left neither the token nor its hash", + "properties": { + "node": { + "type": "string", + "description": "the node the token is for" + }, + "broker": { + "type": "string", + "description": "the bus's address the machine dials" + }, + "fingerprint": { + "type": "string", + "description": "the fingerprint of the bus's certificate" + }, + "signer": { + "type": "string", + "description": "the vault's public signing key in base64" + }, + "sealed": { + "type": "string", + "description": "the token sealed to the operator key, when asked sealed, relayed as it is" + }, + "dropped": { + "type": "boolean", + "description": "a drop left neither the token nor its hash" + } + }, + "required": [ + "node" + ] + } + }, + { + "name": "node-account", + "description": "Mint a joining node's bus account node., sealed to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the node's node-logins key naming node.. Asked by mesh-enrolment alone. Never the value in the clear.", + "optional": true, + "input": { + "properties": { + "keys_digest": { + "description": "the digest of every public key the node presented (delivery.KeysDigest)", + "type": "string" + }, + "node": { + "description": "the joining node", + "type": "string" + }, + "retire": { + "description": "\"true\": retire the account minted for this node", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "sealing_key": { + "description": "the sealing key's public half the node presented, which the account is sealed to", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the login minted and the account sealed to the sealing key the node presented, never in the clear; or that a retire left no account", + "properties": { + "node": { + "type": "string", + "description": "the joining node" + }, + "login": { + "type": "string", + "description": "the login minted" + }, + "account": { + "type": "string", + "description": "the account sealed to the node's sealing key, in base64" + }, + "retired": { + "type": "boolean", + "description": "a retire left no account" + } + }, + "required": [ + "node" + ] + } + }, + { + "name": "rotate-node", + "description": "Begin a node's account rotation to its other login: answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.", + "optional": true, + "input": { + "properties": { + "act": { + "description": "the id of the hand act recorded for it in mesh-hand-acts", + "type": "string" + }, + "node": { + "description": "the node whose account rotates", + "type": "string" + }, + "why": { + "description": "why, in a sentence", + "type": "string" + } + }, + "required": [ + "node", + "why", + "act" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "node-account-confirm", + "description": "Confirm a node's switch to its new login: asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. The old login's hash is then removed, and rotation-done said once the bus node's have shows the list without it.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "abandon", + "description": "Abandon a node's rotation before its old login is retired: the new login and its value dropped, the old one kept; a node that switched switches back first.", + "optional": true, + "input": { + "properties": { + "node": { + "description": "the node whose rotation is abandoned", + "type": "string" + } + }, + "required": [ + "node" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the holder's answer" + } + }, + { + "name": "retire", + "optional": true, + "description": "Retire one module's own secret, by node, module and name, on the operator's warrant bound to exactly those three (design 32 *A value no longer read*). Emits own-retired.", + "callers": "the holder of the seat mesh-config alone", + "input": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "module": { + "type": "string", + "description": "the module" + }, + "name": { + "type": "string", + "description": "the own secret's name" + }, + "warrant": { + "type": "string", + "description": "the operator's warrant for this node, module and name: the hand act's id" + }, + "ask": { + "type": "string", + "description": "the id of the operator's ask the warrant answers" + } + }, + "required": [ + "node", + "module", + "name", + "warrant", + "ask" + ] + }, + "answer": { + "type": "object", + "properties": { + "retired": { + "type": "boolean", + "description": "the secret is gone" + } + }, + "required": [ + "retired" + ] + } + } + ], + "description": "The mesh's secrets. accept and move-asks, which no holder served and nothing called, are left out (ADR 0620 §3). The vault's own events stay under its own subjects (ADR 0620 §2).", + "holder-events": [ + { + "name": "provisioned", + "subject": "mesh.mod..event.provisioned" + }, + { + "name": "rotated", + "subject": "mesh.mod..event.rotated" + }, + { + "name": "secret-changed", + "subject": "mesh.mod..event.secret-changed" + }, + { + "name": "account-hashed", + "subject": "mesh.mod..event.account-hashed" + }, + { + "name": "own-replaced", + "subject": "mesh.mod..event.own-replaced" + }, + { + "name": "account-issued", + "subject": "mesh.mod..event.account-issued" + }, + { + "name": "signed", + "subject": "mesh.mod..event.signed", + "body": { + "type": "object", + "description": "who asked, the kind, the node and the digest of what was signed; never a key", + "properties": { + "caller": { + "type": "string", + "description": "the caller the vault proved" + }, + "kind": { + "type": "string", + "description": "node-certificate or enrolment" + }, + "node": { + "type": "string", + "description": "the machine, for a node certificate" + }, + "digest": { + "type": "string", + "description": "sha256: of the bytes signed" + }, + "signer": { + "type": "string", + "description": "the signer's fingerprint" + }, + "at": { + "type": "string", + "description": "when it was signed, RFC 3339" + } + }, + "required": [ + "caller", + "kind", + "digest", + "signer", + "at" + ] + } + }, + { + "name": "rotation-begun", + "subject": "mesh.mod..event.rotation-begun" + }, + { + "name": "node-account-ready", + "subject": "mesh.mod..event.node-account-ready" + }, + { + "name": "rotation-done", + "subject": "mesh.mod..event.rotation-done" + }, + { + "name": "own-retired", + "subject": "mesh.mod..event.own-retired", + "body": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "module": { + "type": "string", + "description": "the module" + }, + "name": { + "type": "string", + "description": "the secret's name" + }, + "ask": { + "type": "string", + "description": "the ask's id" + }, + "caller": { + "type": "string", + "description": "the caller the vault proved" + } + }, + "required": [ + "node", + "module", + "name", + "caller" + ] + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-wants.json b/go/seatlist/seatlisttest/seats/mesh-wants.json new file mode 100644 index 0000000..8b50c1a --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-wants.json @@ -0,0 +1,51 @@ +{ + "format": 1, + "seat": "mesh-wants", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0565", + "verbs": [ + { + "name": "upgrade", + "description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + "input": { + "properties": { + "compare": { + "description": "\"true\": each node's own composition, by its digest, and the assigner's findings", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module", + "type": "string" + }, + "policy": { + "description": "with module: roll, together, record, or default to take a person's choice back", + "type": "string" + }, + "why": { + "description": "with policy: why, required and kept", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "bus", + "description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "events": [ + { + "name": "policy-changed" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/mesh-watchdogs.json b/go/seatlist/seatlisttest/seats/mesh-watchdogs.json new file mode 100644 index 0000000..3ad9f3c --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-watchdogs.json @@ -0,0 +1,93 @@ +{ + "format": 1, + "seat": "mesh-watchdogs", + "scope": "mesh", + "grain": "seat-list", + "decision": "novox/hq ADR 0564", + "verbs": [ + { + "name": "signals", + "description": "Every watchdog of the signals table the watchman keeps, each a key that expires unless its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its expiry wrote.", + "input": { + "properties": { + "row": { + "description": "one row, e.g. S1", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "stuck", + "description": "Every want, build or step the watchman says stopped moving past its bound, with since when and the words of its finding.", + "input": { + "properties": { + "machine": { + "description": "only those on this machine", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "presence", + "description": "Every machine and tool runner the watchman sees connected, with its last word, and those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", + "input": { + "properties": { + "machine": { + "description": "one machine", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "dead-letters", + "description": "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one whole. With deliver or drop, a hand act with why.", + "input": { + "properties": { + "cause": { + "description": "with deliver or drop: the cause in a word (dead-letter when absent)", + "type": "string" + }, + "consumer": { + "description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on", + "type": "string" + }, + "deliver": { + "description": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", + "type": "string" + }, + "drop": { + "description": "a dead letter's id: drop it for good (needs why)", + "type": "string" + }, + "id": { + "description": "a dead letter's id, as the list gives it: that one whole, with its message", + "type": "string" + }, + "limit": { + "description": "how many to list, newest first (default 50); only when listing", + "type": "string" + }, + "why": { + "description": "with deliver or drop: why, required and recorded", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "resync", + "description": "Rebuild presence from the bus's connections, and the pulses and findings from the keys the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", + "input": { + "properties": {}, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-backup.json b/go/seatlist/seatlisttest/seats/node-backup.json new file mode 100644 index 0000000..465f68f --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-backup.json @@ -0,0 +1,80 @@ +{ + "format": 1, + "seat": "node-backup", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0214", + "verbs": [ + { + "name": "backed-up", + "description": "What this machine backs up: each module, what it declared, its last good night, how many restore points are kept and the repository's size.", + "replaces": [ + "restic snapshots" + ], + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "now", + "description": "Take a backup now, of one module or of every module on this machine — before a migration, a retirement or anything else that could go wrong.", + "replaces": [ + "restic backup" + ], + "input": { + "properties": { + "module": { + "description": "one module (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "restore", + "description": "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as \u003cpath\u003e.restored-\u003cdate\u003e. Swapping it in is a person's act. Lists the restore points when none is named.", + "replaces": [ + "restic restore" + ], + "input": { + "properties": { + "module": { + "description": "the module", + "type": "string" + }, + "path": { + "description": "one of the module's directories (all of them when omitted)", + "type": "string" + }, + "snapshot": { + "description": "the restore point (from `backed-up`; the newest when omitted)", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + } + } + ], + "receives": [ + { + "kind": "backup", + "comment": "#", + "dirs": true + }, + { + "kind": "data", + "comment": "#", + "dirs": true + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-bar.json b/go/seatlist/seatlisttest/seats/node-bar.json new file mode 100644 index 0000000..dc73191 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-bar.json @@ -0,0 +1,75 @@ +{ + "format": 1, + "seat": "node-bar", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "receives": [ + { + "kind": "block", + "comment": "#", + "offered": true, + "shape": { + "places": [ + { + "field": "bar", + "values": [ + "bottom", + "top" + ] + }, + { + "field": "place", + "values": [ + "resources", + "status" + ] + } + ], + "order": "order", + "examples": [ + { + "bar": "bottom", + "place": "status", + "shows": "battery" + }, + { + "bar": "bottom", + "options": { + "device": "BAT0" + }, + "place": "status", + "shows": "battery" + }, + { + "bar": "bottom", + "options": { + "command": "echo \"a line\"", + "interval": 5 + }, + "place": "status", + "shows": "command" + }, + { + "bar": "bottom", + "options": { + "state": "power.draw" + }, + "place": "status", + "shows": "state" + }, + { + "bar": "bottom", + "options": { + "key": "a-machine", + "state": "power.draw" + }, + "place": "status", + "shows": "state" + } + ], + "text": true + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-build-go.json b/go/seatlist/seatlisttest/seats/node-build-go.json new file mode 100644 index 0000000..5beead8 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-build-go.json @@ -0,0 +1,46 @@ +{ + "format": 1, + "seat": "node-build-go", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", + "pulled-by-kind": true, + "yields": true, + "verbs": [ + { + "name": "current", + "description": "The Go asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "pause", + "description": "Take no new Go ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "resume", + "description": "Take Go asks again on this node.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "accepts": [ + { + "name": "delivery" + }, + { + "name": "check" + }, + { + "name": "recheck" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-build-image.json b/go/seatlist/seatlisttest/seats/node-build-image.json new file mode 100644 index 0000000..96065eb --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-build-image.json @@ -0,0 +1,46 @@ +{ + "format": 1, + "seat": "node-build-image", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", + "pulled-by-kind": true, + "yields": true, + "verbs": [ + { + "name": "current", + "description": "The image asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "pause", + "description": "Take no new image ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "resume", + "description": "Take image asks again on this node.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "accepts": [ + { + "name": "delivery" + }, + { + "name": "check" + }, + { + "name": "recheck" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-build-node.json b/go/seatlist/seatlisttest/seats/node-build-node.json new file mode 100644 index 0000000..d2b8fb7 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-build-node.json @@ -0,0 +1,46 @@ +{ + "format": 1, + "seat": "node-build-node", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", + "pulled-by-kind": true, + "yields": true, + "verbs": [ + { + "name": "current", + "description": "The Node asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "pause", + "description": "Take no new Node ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "resume", + "description": "Take Node asks again on this node.", + "input": { + "properties": {}, + "type": "object" + } + } + ], + "accepts": [ + { + "name": "delivery" + }, + { + "name": "check" + }, + { + "name": "recheck" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-clipboard.json b/go/seatlist/seatlisttest/seats/node-clipboard.json new file mode 100644 index 0000000..0f21e51 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-clipboard.json @@ -0,0 +1,38 @@ +{ + "format": 1, + "seat": "node-clipboard", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "history", + "description": "What the clipboard held lately, newest first.", + "input": { + "properties": { + "limit": { + "description": "how many (optional, default 20)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "copy", + "description": "Put text on the operator's clipboard.", + "input": { + "properties": { + "text": { + "description": "the text", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-compositor.json b/go/seatlist/seatlisttest/seats/node-compositor.json new file mode 100644 index 0000000..4ce95ee --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-compositor.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-compositor", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208" +} diff --git a/go/seatlist/seatlisttest/seats/node-container-runtime.json b/go/seatlist/seatlisttest/seats/node-container-runtime.json new file mode 100644 index 0000000..4a68219 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-container-runtime.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-container-runtime", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0166, ADR 0207" +} diff --git a/go/seatlist/seatlisttest/seats/node-display-server.json b/go/seatlist/seatlisttest/seats/node-display-server.json new file mode 100644 index 0000000..8523e84 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-display-server.json @@ -0,0 +1,42 @@ +{ + "format": 1, + "seat": "node-display-server", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "displays", + "description": "The monitors connected now, each with its identity, its modes and where it is placed; and the layout profile in force, if one matches.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "layout", + "description": "The monitor layout profiles, keyed by the connected monitors' identities: list them, save the current arrangement under a name, or apply one.", + "input": { + "properties": { + "action": { + "description": "list, save or apply", + "enum": [ + "list", + "save", + "apply" + ], + "type": "string" + }, + "name": { + "description": "the profile to save or apply (save and apply only)", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-display-session.json b/go/seatlist/seatlisttest/seats/node-display-session.json new file mode 100644 index 0000000..6edac04 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-display-session.json @@ -0,0 +1,44 @@ +{ + "format": 1, + "seat": "node-display-session", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "reload", + "description": "Reload the session's configuration in place, keeping its windows.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "workspaces", + "description": "The session's workspaces: each one's name, output, and whether it is visible or focused.", + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "windows", + "description": "The session's windows: each one's title, class, workspace and whether it has focus; narrowed to one workspace when named.", + "input": { + "properties": { + "workspace": { + "description": "one workspace (optional)", + "type": "string" + } + }, + "type": "object" + } + } + ], + "receives": [ + { + "kind": "config", + "comment": "#" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-engine.json b/go/seatlist/seatlisttest/seats/node-engine.json new file mode 100644 index 0000000..dfcaeb8 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-engine.json @@ -0,0 +1,1055 @@ +{ + "format": 1, + "seat": "node-engine", + "scope": "node", + "grain": "whole", + "decision": "novox/hq ADR 0563", + "description": "The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.., and each event is published on mesh.seat.node-engine.event.., the node's name its last token.", + "held-by-the-engine": true, + "verbs": [ + { + "name": "hand-over", + "description": "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, §5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, reached through no link) and refuses in words, or asks the operator on their channel at the level approve, binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands it over. One ask is open per path; acts shows where it stands.", + "input": { + "properties": { + "path": { + "description": "the directory's absolute path, as the engine states it", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "answer": { + "$ref": "#/defs/Asked" + } + }, + { + "name": "access-act", + "description": "Ask the operator for the take act on a library whose root holds data outside its group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes sets the library's group, group write and setgid under its root. One ask is open per access.", + "input": { + "properties": { + "access": { + "description": "the access, library:", + "type": "string" + }, + "act": { + "description": "the act: take", + "type": "string" + }, + "why": { + "description": "why it is asked, in words the operator reads", + "type": "string" + } + }, + "required": [ + "act", + "access" + ], + "type": "object" + }, + "answer": { + "$ref": "#/defs/Asked" + } + }, + { + "name": "judge-directories", + "description": "Which of this machine's declared directories the engine would refuse to make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Said" + } + }, + { + "name": "setuid-search", + "description": "Throw away the engine's last search for setuid programs and start a full one (novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while one runs, and when one was started within the hour, naming when the next may start.", + "input": { + "properties": { + "why": { + "description": "why it is asked, in words", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Said" + } + }, + { + "name": "root-free", + "description": "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root without a person, and the login shell's execute not served here. Anything else is no pass, and says why. Answered by this machine's engine alone.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "machines": { + "type": "array", + "items": { + "$ref": "#/defs/RootVerdict" + }, + "description": "this machine's verdict" + } + }, + "required": [ + "machines" + ] + } + }, + { + "name": "acts", + "description": "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the operator's warrant. Read-only.", + "input": { + "properties": { + "id": { + "description": "one act, by its id", + "type": "string" + }, + "limit": { + "description": "at most this many, newest", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "$ref": "#/defs/Acts" + } + }, + { + "name": "packages", + "description": "The packages this machine's compositions declare, present or absent, each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller hands its lease away, beside the controller's mirror for this machine and their difference. With write, the key written again from what the engine composed.", + "optional": true, + "input": { + "properties": { + "write": { + "description": "\"true\": write this machine's key again from what the engine composed", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "source": { + "type": "string", + "description": "where the key is read: the engine's state packages" + }, + "key": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string", + "description": "the package" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the modules that declare it" + }, + "absent": { + "type": "boolean", + "description": "declared absent" + } + }, + "required": [ + "package", + "modules", + "absent" + ] + }, + "description": "its packages sorted by name, absent after present" + } + }, + "required": [ + "node", + "packages" + ], + "description": "the node's key as the bus holds it; absent when the engine wrote none" + }, + "key_error": { + "type": "string", + "description": "why the key could not be read" + }, + "wrote": { + "type": "boolean", + "description": "with write: the key was written again (false: it already held what the engine composed)" + }, + "note": { + "type": "string", + "description": "a note, such as that no key is written yet" + } + }, + "required": [ + "node", + "source" + ] + } + }, + { + "name": "composition", + "description": "One module's composition on this machine beside what runs of it, read-only: the composition its want's build has in the bucket composed- (the latest revision, or the one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs (applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers that join or leave.", + "optional": true, + "input": { + "properties": { + "diff": { + "description": "\"true\": list the resources that differ from what runs", + "type": "string" + }, + "digest": { + "description": "a composition's digest (sha256:…), to read that revision rather than the latest", + "type": "string" + }, + "module": { + "description": "the module, as placed on this machine; node for the node's own", + "type": "string" + } + }, + "required": [ + "module" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "module": { + "type": "string", + "description": "the module" + }, + "want": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "its build's fingerprint" + }, + "composed": { + "type": "string", + "description": "the composition's digest it names" + }, + "yes": { + "type": "string", + "description": "the digest a person said yes to" + }, + "removed": { + "type": "boolean", + "description": "the module is removed" + }, + "self": { + "type": "boolean", + "description": "the node composes the module itself (ADR 0547 §2)" + }, + "backup": { + "type": "string", + "description": "the operator's answer to a failed backup, again or skip" + }, + "words": { + "type": "string", + "description": "its words" + }, + "revision": { + "type": "integer", + "description": "the revision of the want's key read last" + } + }, + "required": [ + "fp", + "revision" + ], + "description": "the module's want as the agent holds it; absent when it holds none" + }, + "attempt": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "the fingerprint tried" + }, + "composed": { + "type": "string", + "description": "the digest tried" + }, + "want_revision": { + "type": "integer", + "description": "the want's revision it was tried at" + }, + "composition_revision": { + "type": "integer", + "description": "the composition's revision it was tried at" + }, + "held": { + "type": "boolean", + "description": "held for a failed backup" + }, + "answered_revision": { + "type": "integer", + "description": "the want's revision the hold took" + }, + "transient": { + "type": "boolean", + "description": "it failed because the bus did not answer for a moment" + } + }, + "required": [ + "fp", + "want_revision", + "composition_revision" + ], + "description": "the module's last apply that did not end running; absent when none" + }, + "started": { + "type": "boolean", + "description": "the agent has looked at the module once since it started" + }, + "have": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "fingerprint" + }, + "composed": { + "type": "string", + "description": "digest" + }, + "phase": { + "type": "string", + "description": "running, failed, held or waiting-yes" + }, + "detail": { + "type": "string", + "description": "detail" + }, + "judgement": { + "type": "string", + "description": "judgement" + }, + "waits": { + "type": "string", + "description": "the digest that waits for a yes" + } + }, + "required": [ + "phase" + ], + "description": "what the module's have key says now; absent when it says nothing" + }, + "composition": { + "$ref": "#/defs/Revision" + }, + "revisions": { + "type": "array", + "items": { + "$ref": "#/defs/Revision" + }, + "description": "the revisions of its key the bucket keeps, oldest first" + }, + "runs": { + "type": "object", + "properties": { + "fp": { + "type": "string", + "description": "fingerprint" + }, + "composed": { + "type": "string", + "description": "digest" + }, + "phase": { + "type": "string", + "description": "phase" + }, + "from": { + "type": "string", + "description": "applied, have or kept" + }, + "resources": { + "type": "integer", + "description": "how many resources run" + } + }, + "required": [ + "from", + "resources" + ], + "description": "what runs of the module; absent when nothing runs" + }, + "judged": { + "type": "object", + "properties": { + "judgement": { + "type": "string", + "description": "the judgement" + }, + "words": { + "type": "string", + "description": "its words" + }, + "waits_for_yes": { + "type": "boolean", + "description": "the agent would wait for a person's yes before applying it" + } + }, + "required": [ + "judgement", + "words", + "waits_for_yes" + ], + "description": "the move from what runs to the composition read" + }, + "diff": { + "type": "array", + "items": { + "type": "object", + "description": "one resource added, removed or changed, as the engine's judge says it" + }, + "description": "every resource that differs from what runs, when asked" + }, + "own": { + "type": "object", + "properties": { + "provisioner_account": { + "type": "string", + "description": "the account the provisioner's client runs as, as the revision names it" + }, + "runs_provisioner_account": { + "type": "string", + "description": "as what runs names it" + }, + "adoption": { + "type": "object", + "description": "the revision's adoption; absent for a converged node" + }, + "identities": { + "type": "integer", + "description": "identities the revision names" + }, + "groups": { + "type": "integer", + "description": "groups" + }, + "libraries": { + "type": "integer", + "description": "libraries" + }, + "held": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "each module the node's composer held in its last pass, with why" + } + }, + "required": [ + "provisioner_account" + ], + "description": "of the node's own composition, its own fields beside what runs names" + } + }, + "required": [ + "node", + "module", + "started", + "composition", + "judged" + ] + } + }, + { + "name": "compose", + "description": "Compose this machine's modules now and say how it went, so a composition that failed, or a module held, recovers without a restart: what the pass refused or held, each with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried again. Changes only what a pass the engine makes on its own would change.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "a module placed on this machine whose apply is also tried again (optional)", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "retried": { + "type": "string", + "description": "the module whose apply was tried again" + }, + "pass": { + "type": "object", + "properties": { + "at": { + "type": "string", + "description": "when the pass ended, RFC 3339" + }, + "refused": { + "type": "string", + "description": "why the node composed none of its modules" + }, + "held": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "each module refused alone, with why" + }, + "handed": { + "type": "integer", + "description": "how many compositions the pass handed the agent that differ from before" + }, + "again": { + "type": "string", + "description": "when the node composes again on its own, RFC 3339" + }, + "earlier": { + "type": "boolean", + "description": "the answer is the last pass, made moments ago" + } + }, + "required": [ + "at", + "handed" + ], + "description": "how the pass went" + } + }, + "required": [ + "pass" + ] + } + }, + { + "name": "compose-candidate", + "description": "Compose a check's candidate on this machine in shadow, with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it composes (with its digest and whether the candidate changes it), is refused by the candidate or is held without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", + "optional": true, + "input": { + "properties": { + "candidate": { + "description": "the key of the indexer's candidate, .", + "type": "string" + }, + "input": { + "description": "also answer the composition input the candidate was composed from, for the sandbox path", + "type": "boolean" + } + }, + "required": [ + "candidate" + ], + "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "candidate": { + "type": "string", + "description": "the candidate's key" + }, + "verdict": { + "type": "string", + "enum": [ + "composed", + "refused", + "unready", + "not-judged" + ], + "description": "the node's verdict (ADR 0593 §7, §9)" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/defs/Module" + }, + "description": "every module placed on the node, sorted by name" + }, + "own": { + "$ref": "#/defs/Module" + }, + "refused": { + "type": "string", + "description": "why the node could not compose with the candidate at all" + }, + "took": { + "type": "integer", + "description": "how long the engine took, in milliseconds" + }, + "input": { + "type": "object", + "description": "when asked: the composition input it composed from" + } + }, + "required": [ + "v", + "node", + "candidate", + "verdict", + "modules", + "own", + "took" + ] + } + }, + { + "name": "manifest-reads", + "optional": true, + "description": "The reader capabilities this machine's engine states (manifest words, forms and seat names it reads, novox/hq ADR 0618 §3) and when it last stated them. An engine built before it said them states none, which a reader takes as the baseline. Read-only. Answered by this machine's engine alone.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "the reader capabilities its engine states: the manifest words, forms and seat names it reads; empty for an engine built before it said them, read as the baseline" + }, + "stated-at": { + "type": "string", + "description": "when the engine last stated them, RFC 3339" + } + }, + "required": [ + "v", + "node", + "stated-at" + ], + "description": "what this machine's engine reads of a manifest (ADR 0618 §3)" + } + } + ], + "events": [ + { + "name": "asked", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "done", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "refused", + "by-caller": true, + "body": { + "$ref": "#/defs/ActSaid" + } + }, + { + "name": "manifest-reads-changed", + "by-caller": true, + "body": { + "$ref": "#/defs/ManifestReadsChanged" + } + } + ], + "family": { + "name": "node-engine.events", + "max-age-days": 7 + }, + "defs": { + "Act": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the act's id" + }, + "act": { + "type": "string", + "description": "the act: hand-over, access-act, judge-directories or setuid-search" + }, + "subject": { + "type": "string", + "description": "the path of a hand-over, or the access of an access act" + }, + "caller": { + "type": "string", + "description": "the caller as the runtime proved it" + }, + "why": { + "type": "string", + "description": "why it was asked, in words" + }, + "at": { + "type": "string", + "description": "when it was asked, RFC 3339" + }, + "state": { + "type": "string", + "enum": [ + "asked", + "done", + "refused", + "answered" + ], + "description": "where it stands" + }, + "said": { + "type": "string", + "description": "what the engine said of it: its outcome or why it was refused" + }, + "ended": { + "type": "string", + "description": "when it ended, RFC 3339" + }, + "ask": { + "type": "string", + "description": "the operator's ask of a hand-over or an access act" + }, + "sets": { + "type": "string", + "description": "the owner, group and mode it sets, in words" + }, + "disk": { + "type": "string", + "description": "what the engine read on disk when it judged" + }, + "path": { + "type": "string", + "description": "the directory the act changes" + }, + "expires": { + "type": "string", + "description": "when the ask ends unanswered, RFC 3339" + }, + "by": { + "type": "string", + "description": "who answered, as the operator's warrant names them" + }, + "via": { + "type": "string", + "description": "the channel the warrant came on" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the warrant's proofs" + }, + "auto": { + "type": "boolean", + "description": "the engine asked it itself, on finding a library's root holding data outside its group (ADR 0563 §4)" + } + }, + "required": [ + "id", + "act", + "caller", + "at", + "state" + ], + "description": "one act asked through the seat, as acts reads it (ADR 0563 §9)" + }, + "ActSaid": { + "type": "object", + "properties": { + "act": { + "type": "string", + "description": "the act" + }, + "subject": { + "type": "string", + "description": "the path or the access" + }, + "caller": { + "type": "string", + "description": "the caller as the runtime proved it" + }, + "ask": { + "type": "string", + "description": "the operator's ask" + }, + "state": { + "type": "string", + "enum": [ + "asked", + "done", + "refused" + ], + "description": "where the act stands" + }, + "said": { + "type": "string", + "description": "what the engine said of it" + }, + "node": { + "type": "string", + "description": "the node whose engine says it" + } + }, + "required": [ + "act", + "caller", + "state", + "node" + ], + "description": "what the seat says of an act: the act, the path or access, the caller as the runtime proved it and the ask (ADR 0563 §1)" + }, + "Acts": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "acts": { + "type": "array", + "items": { + "$ref": "#/defs/Act" + }, + "description": "the acts, newest first" + } + }, + "required": [ + "node", + "acts" + ], + "description": "the acts kept" + }, + "Asked": { + "type": "object", + "properties": { + "asked": { + "type": "string", + "description": "the ask's id: acts shows where it stands" + }, + "node": { + "type": "string", + "description": "the node" + }, + "says": { + "type": "string", + "description": "what happens next, in words" + } + }, + "required": [ + "asked", + "node", + "says" + ], + "description": "the answer of an act the operator is asked" + }, + "ManifestReadsChanged": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout's version" + }, + "node": { + "type": "string", + "description": "the node" + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "what its engine states it reads now" + }, + "at": { + "type": "string", + "description": "when it says it, RFC 3339" + }, + "why": { + "type": "string", + "enum": [ + "started", + "returned" + ], + "description": "started: the engine started or what it states changed; returned: its node returned after being away" + } + }, + "required": [ + "v", + "node", + "at", + "why" + ], + "description": "what a node-engine says when what it reads changed or its node returned (ADR 0618 §4)" + }, + "Module": { + "type": "object", + "properties": { + "module": { + "type": "string", + "description": "the module, node for the node's own composition" + }, + "fp": { + "type": "string", + "description": "the fingerprint it was composed at" + }, + "state": { + "type": "string", + "enum": [ + "composed", + "refused", + "held" + ], + "description": "composed (digest set), refused by the candidate, or held without it too (words set)" + }, + "digest": { + "type": "string", + "description": "the composition's digest when composed" + }, + "changed": { + "type": "boolean", + "description": "the candidate changes it from the one composed without it" + }, + "words": { + "type": "string", + "description": "why, for a refused or held one" + } + }, + "required": [ + "module", + "state" + ], + "description": "one module's composition with the candidate" + }, + "Revision": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "the composition's key" + }, + "revision": { + "type": "integer", + "description": "its revision" + }, + "digest": { + "type": "string", + "description": "its digest" + }, + "resources": { + "type": "integer", + "description": "how many resources it names" + }, + "latest": { + "type": "boolean", + "description": "the latest revision" + }, + "refused": { + "type": "string", + "description": "why it cannot be read or trusted" + } + }, + "required": [ + "key" + ], + "description": "one revision of a composition key" + }, + "RootVerdict": { + "type": "object", + "properties": { + "machine": { + "type": "string", + "description": "the machine" + }, + "free": { + "type": "boolean", + "description": "the machine is root-free" + }, + "why": { + "type": "string", + "description": "why, in words" + }, + "judged": { + "type": "string", + "description": "when the engine judged it, RFC 3339" + } + }, + "required": [ + "machine", + "free", + "why", + "judged" + ], + "description": "the engine's root-free verdict (ADR 0563 §6)" + }, + "Said": { + "type": "object", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "said": { + "type": "string", + "description": "the engine's judgement or search, one line per directory or program" + } + }, + "required": [ + "node", + "said" + ], + "description": "the answer of a read-only judgement" + } + } +} diff --git a/go/seatlist/seatlisttest/seats/node-environment.json b/go/seatlist/seatlisttest/seats/node-environment.json new file mode 100644 index 0000000..064beb0 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-environment.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-environment", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0203" +} diff --git a/go/seatlist/seatlisttest/seats/node-files.json b/go/seatlist/seatlisttest/seats/node-files.json new file mode 100644 index 0000000..7ad768f --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-files.json @@ -0,0 +1,589 @@ +{ + "format": 1, + "seat": "node-files", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0394", + "verbs": [ + { + "name": "stat", + "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". At most 200 paths in one call. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "stat", + "ls -ld", + "namei -l", + "findmnt -T" + ], + "input": { + "properties": { + "follow": { + "description": "\"true\": also say where the path leads once every link is followed", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "paths": { + "description": "one absolute path, or several separated by line breaks", + "type": "string" + } + }, + "required": [ + "paths" + ], + "type": "object" + } + }, + { + "name": "readlink", + "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "readlink", + "readlink -f", + "realpath" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "list", + "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "ls", + "ls -la" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path to a directory", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "read", + "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "cat", + "head", + "tail", + "less" + ], + "input": { + "properties": { + "bytes": { + "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + "type": "string" + }, + "from": { + "description": "the first line to answer, counted from 1 (optional)", + "type": "string" + }, + "lines": { + "description": "how many lines to answer (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "search", + "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "grep", + "grep -r", + "grep -F" + ], + "input": { + "properties": { + "case": { + "description": "\"false\": match whatever the case (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "depth": { + "description": "how many directories deep, up to 8 (optional)", + "type": "string" + }, + "matches": { + "description": "the most matches to answer, up to 500 (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file or a directory", + "type": "string" + }, + "text": { + "description": "the fixed string to find", + "type": "string" + } + }, + "required": [ + "text", + "path" + ], + "type": "object" + } + }, + { + "name": "account", + "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "replaces": [ + "getent passwd", + "getent group", + "id", + "groups" + ], + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "type": "string" + }, + "name": { + "description": "an account's name or number (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "write", + "description": "Write one text file whole, replacing what it holds; the old content goes to the node's trash. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "tee", + "\u003e" + ], + "input": { + "properties": { + "content": { + "description": "the text to write", + "type": "string" + }, + "mode": { + "description": "the file's mode, such as 0644 (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "path", + "content", + "reason" + ], + "type": "object" + } + }, + { + "name": "copy", + "description": "Copy a file or directory to a path on this node. A target on another node (\u003cnode\u003e:/path) is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "cp", + "cp -a", + "ssh \u003cnode\u003e cp" + ], + "input": { + "properties": { + "from": { + "description": "an absolute path", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "to": { + "description": "an absolute path on this node", + "type": "string" + } + }, + "required": [ + "from", + "to", + "reason" + ], + "type": "object" + } + }, + { + "name": "move", + "description": "Move or rename a file or directory. A target on another node is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "mv" + ], + "input": { + "properties": { + "from": { + "description": "an absolute path", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "to": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "from", + "to", + "reason" + ], + "type": "object" + } + }, + { + "name": "delete", + "description": "Delete a file or directory into the node's trash, where restore can bring it back. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "rm", + "rm -r" + ], + "input": { + "properties": { + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": delete a directory with what it holds", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "reason" + ], + "type": "object" + } + }, + { + "name": "mkdir", + "description": "Make a directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "mkdir", + "mkdir -p" + ], + "input": { + "properties": { + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "parents": { + "description": "\"true\": make the missing parents too", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "path", + "reason" + ], + "type": "object" + } + }, + { + "name": "chmod", + "description": "Change the mode of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "chmod" + ], + "input": { + "properties": { + "mode": { + "description": "the mode, such as 0644 or u+x", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": also everything under a directory", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "mode", + "reason" + ], + "type": "object" + } + }, + { + "name": "chown", + "description": "Change the owner and group of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "chown", + "chgrp" + ], + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "owner": { + "description": "an account's name or number", + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "recursive": { + "description": "\"true\": also everything under a directory", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "required": [ + "path", + "owner", + "reason" + ], + "type": "object" + } + }, + { + "name": "upload", + "description": "Send one file of this node into a bucket of the object store, over a short signed link and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, before anything is sent. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", + "input": { + "properties": { + "bucket": { + "description": "the bucket the file goes into", + "type": "string" + }, + "key": { + "description": "the object's key (optional; the file's SHA-256 when not given)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path", + "bucket" + ], + "type": "object" + } + }, + { + "name": "download", + "description": "Write one object of a bucket to a file of this node, over a short signed link and never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "input": { + "properties": { + "bucket": { + "description": "the bucket", + "type": "string" + }, + "key": { + "description": "the object's key", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path", + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + }, + "sha256": { + "description": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)", + "type": "string" + } + }, + "required": [ + "bucket", + "key", + "path", + "reason" + ], + "type": "object" + } + }, + { + "name": "trash", + "description": "What the node's trash holds: each deleted or replaced item with its id, original path and when it went.", + "replaces": [ + "ls ~/.local/share/Trash" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "restore", + "description": "Put an item of the node's trash back where it was; refuses when something is there now. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", + "replaces": [ + "trash-restore" + ], + "input": { + "properties": { + "id": { + "description": "an id from trash", + "type": "string" + }, + "override": { + "description": "a reason, to change a path a module owns anyway (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "reason": { + "description": "why the file is changed, for the audit trail", + "type": "string" + } + }, + "required": [ + "id", + "reason" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-hostname.json b/go/seatlist/seatlisttest/seats/node-hostname.json new file mode 100644 index 0000000..0f646af --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-hostname.json @@ -0,0 +1,73 @@ +{ + "format": 1, + "seat": "node-hostname", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0199, ADR 0223", + "verbs": [ + { + "name": "entries", + "description": "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.", + "replaces": [ + "cat /etc/hosts", + "getent hosts" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "add", + "description": "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's.", + "replaces": [ + "edit /etc/hosts", + "HOSTALIASES" + ], + "input": { + "properties": { + "address": { + "description": "the IPv4 or IPv6 address", + "type": "string" + }, + "names": { + "description": "the names for it, separated by spaces", + "type": "string" + } + }, + "required": [ + "address", + "names" + ], + "type": "object" + } + }, + { + "name": "remove", + "description": "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", + "replaces": [ + "sed -i /etc/hosts" + ], + "input": { + "properties": { + "name": { + "description": "a host name, or an address to remove every line of", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + ], + "paths": [ + { + "path": "/etc/hosts" + }, + { + "path": "/etc/hostname" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-hotkeys.json b/go/seatlist/seatlisttest/seats/node-hotkeys.json new file mode 100644 index 0000000..cedd627 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-hotkeys.json @@ -0,0 +1,13 @@ +{ + "format": 1, + "seat": "node-hotkeys", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0212", + "receives": [ + { + "kind": "trigger", + "comment": "#" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-intrusion-prevention.json b/go/seatlist/seatlisttest/seats/node-intrusion-prevention.json new file mode 100644 index 0000000..a987a70 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-intrusion-prevention.json @@ -0,0 +1,89 @@ +{ + "format": 1, + "seat": "node-intrusion-prevention", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0121", + "verbs": [ + { + "name": "status", + "description": "Every jail on this machine with how many it is watching and holding now, and the totals since the jail started; one jail's detail when named.", + "replaces": [ + "fail2ban-client status" + ], + "input": { + "properties": { + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "banned", + "description": "Every address banned on this machine right now, with the jail that holds it and when the ban ends.", + "replaces": [ + "fail2ban-client banned" + ], + "input": { + "properties": { + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "ban", + "description": "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.", + "replaces": [ + "fail2ban-client set banip" + ], + "input": { + "properties": { + "ip": { + "description": "the address", + "type": "string" + }, + "jail": { + "description": "the jail to hold it", + "type": "string" + } + }, + "required": [ + "ip", + "jail" + ], + "type": "object" + } + }, + { + "name": "unban", + "description": "Let one address go, from one jail or from every jail when none is named.", + "replaces": [ + "fail2ban-client unban", + "fail2ban-client set unbanip" + ], + "input": { + "properties": { + "ip": { + "description": "the address", + "type": "string" + }, + "jail": { + "description": "one jail (optional)", + "type": "string" + } + }, + "required": [ + "ip" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-launcher.json b/go/seatlist/seatlisttest/seats/node-launcher.json new file mode 100644 index 0000000..3eab2eb --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-launcher.json @@ -0,0 +1,68 @@ +{ + "format": 1, + "seat": "node-launcher", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "menu", + "description": "Put a menu of choices in front of the operator and answer with the one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.", + "input": { + "properties": { + "choices": { + "description": "the lines to choose between, in order", + "items": { + "type": "string" + }, + "type": "array" + }, + "prompt": { + "description": "what the menu asks (optional)", + "type": "string" + } + }, + "required": [ + "choices" + ], + "type": "object" + } + }, + { + "name": "secret", + "description": "Ask the operator for a value in a prompt that does not show what is typed, and answer it sealed to the key the asker gives — never in the clear — or cancelled when the prompt was dismissed or not answered in time.", + "optional": true, + "input": { + "properties": { + "module": { + "description": "the module whose own secret is asked for", + "type": "string" + }, + "node": { + "description": "the machine the module runs on", + "type": "string" + }, + "seal_to": { + "description": "the asker's public sealing key: the answer is sealed to it", + "type": "string" + }, + "secret": { + "description": "the own secret's name", + "type": "string" + }, + "timeout_seconds": { + "description": "give up after this long (optional)", + "type": "string" + } + }, + "required": [ + "module", + "secret", + "node", + "seal_to" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-lock-screen.json b/go/seatlist/seatlisttest/seats/node-lock-screen.json new file mode 100644 index 0000000..4cd19ed --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-lock-screen.json @@ -0,0 +1,17 @@ +{ + "format": 1, + "seat": "node-lock-screen", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "lock", + "description": "Lock the operator's session now.", + "input": { + "properties": {}, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-login-manager.json b/go/seatlist/seatlisttest/seats/node-login-manager.json new file mode 100644 index 0000000..e0a70ba --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-login-manager.json @@ -0,0 +1,17 @@ +{ + "format": 1, + "seat": "node-login-manager", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "sessions", + "description": "The sessions the login manager offers on this machine, and which one the operator account starts by default.", + "input": { + "properties": {}, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-login-shell.json b/go/seatlist/seatlisttest/seats/node-login-shell.json new file mode 100644 index 0000000..c868e5b --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-login-shell.json @@ -0,0 +1,30 @@ +{ + "format": 1, + "seat": "node-login-shell", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0204", + "verbs": [ + { + "name": "execute", + "description": "Run one command on this machine as the operator account, in a non-interactive login shell in its home; answers with what it printed and how it exited.", + "optional": true, + "input": { + "properties": { + "command": { + "description": "the command line, as you would type it", + "type": "string" + }, + "timeout_seconds": { + "description": "give up after this long, at most 25 (default 20)", + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-message-bus.json b/go/seatlist/seatlisttest/seats/node-message-bus.json new file mode 100644 index 0000000..83c6744 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-message-bus.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-message-bus", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0215" +} diff --git a/go/seatlist/seatlisttest/seats/node-mounts.json b/go/seatlist/seatlisttest/seats/node-mounts.json new file mode 100644 index 0000000..43f1655 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-mounts.json @@ -0,0 +1,111 @@ +{ + "format": 1, + "seat": "node-mounts", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0263", + "verbs": [ + { + "name": "list", + "description": "Every mount point on this machine: its fstab line, the mesh's mount and automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, and which settings asked for it. A password in a mount's options is never shown.", + "replaces": [ + "cat /etc/fstab", + "findmnt", + "systemctl list-units --type=mount" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "test", + "description": "Whether one share's or occasional source's server answers from this machine now, without touching its mount point.", + "replaces": [ + "showmount -e", + "ping" + ], + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + { + "name": "mount", + "description": "Mount one share or occasional source now, rather than at its first access.", + "replaces": [ + "mount" + ], + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + { + "name": "unmount", + "description": "Release one share or occasional source now; its automount stays armed, so the next access mounts it again.", + "replaces": [ + "umount" + ], + "input": { + "properties": { + "name": { + "description": "the share or source, by its name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + { + "name": "adopt", + "description": "Take over a mount a person wrote by hand: comment out the /etc/fstab line for a mount point the mesh's own units now serve, keeping a copy of the file — only when the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", + "replaces": [ + "sed -i /etc/fstab" + ], + "input": { + "properties": { + "confirm": { + "description": "\"true\": change it (needs why); anything else is a dry run", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "mountpoint": { + "description": "the mount point whose fstab line is taken over", + "type": "string" + }, + "why": { + "description": "why, for the record", + "type": "string" + } + }, + "required": [ + "mountpoint" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-nfs-server.json b/go/seatlist/seatlisttest/seats/node-nfs-server.json new file mode 100644 index 0000000..4cedbde --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-nfs-server.json @@ -0,0 +1,99 @@ +{ + "format": 1, + "seat": "node-nfs-server", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0263", + "verbs": [ + { + "name": "exports", + "description": "Every share this machine exports: its name, its path, read-write or read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that node's private address and access (only the nodes the server grants it to and that ask for it), what is granted and not asked for or asked for and not granted, and whether the kernel holds it now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in /etc/exports.", + "replaces": [ + "exportfs -v", + "cat /etc/exports", + "zfs get sharenfs" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "clients", + "description": "Which machines have mounted which share now, as the NFS server knows its clients.", + "replaces": [ + "ss -tn sport = :2049", + "cat /proc/fs/nfsd/clients/*/info" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "test", + "description": "Whether this machine exports one share for the mesh now, and whether its NFS service is up; with an address, also whether the share is exported to that address: a node's own private address, when the server grants it the share and the node asks for it. What a machine mounting the share asks before it mounts.", + "replaces": [ + "showmount -e" + ], + "input": { + "properties": { + "address": { + "description": "a node's private address, to ask whether the share is exported to it (optional)", + "type": "string" + }, + "share": { + "description": "the share, by its name", + "type": "string" + } + }, + "required": [ + "share" + ], + "type": "object" + } + }, + { + "name": "reload", + "description": "Have the kernel read this machine's export files again now (exportfs -ra) and answer the shares as it then holds them. The module's own process writes its export file; this is for after a change made outside it. Changes no shared path.", + "replaces": [ + "exportfs -ra" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "adopt", + "description": "Take over an export a person made by hand: clear a dataset's sharenfs property for a path the mesh's own export now serves — only when that export is live. Without confirm, says what it would do and changes nothing.", + "replaces": [ + "zfs set sharenfs=off" + ], + "input": { + "properties": { + "confirm": { + "description": "\"true\": change it (needs why); anything else is a dry run", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "path": { + "description": "the shared path whose hand-made export is taken over", + "type": "string" + }, + "why": { + "description": "why, for the record", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-notifier.json b/go/seatlist/seatlisttest/seats/node-notifier.json new file mode 100644 index 0000000..f391310 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-notifier.json @@ -0,0 +1,51 @@ +{ + "format": 1, + "seat": "node-notifier", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "send", + "description": "Show the operator a notification.", + "input": { + "properties": { + "body": { + "description": "its text (optional)", + "type": "string" + }, + "title": { + "description": "the notification's summary", + "type": "string" + }, + "urgency": { + "description": "low, normal (the default) or critical", + "enum": [ + "low", + "normal", + "critical" + ], + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + } + }, + { + "name": "history", + "description": "The notifications shown lately, newest first.", + "input": { + "properties": { + "limit": { + "description": "how many (optional, default 20)", + "type": "string" + } + }, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-package-manager.json b/go/seatlist/seatlisttest/seats/node-package-manager.json new file mode 100644 index 0000000..c2b32b0 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-package-manager.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-package-manager", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0207" +} diff --git a/go/seatlist/seatlisttest/seats/node-packet-filter.json b/go/seatlist/seatlisttest/seats/node-packet-filter.json new file mode 100644 index 0000000..fb69e1c --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-packet-filter.json @@ -0,0 +1,62 @@ +{ + "format": 1, + "seat": "node-packet-filter", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0121", + "verbs": [ + { + "name": "rules", + "description": "The packet filter as this machine enforces it now: the nftables ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or chain when asked.", + "replaces": [ + "nft list ruleset", + "iptables -L", + "iptables-save" + ], + "input": { + "properties": { + "chain": { + "description": "one chain of that table (optional)", + "type": "string" + }, + "table": { + "description": "one nftables table, as `family name` (optional)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "reload", + "description": "Load the mesh's own filter again from the file the mesh writes, and answer with the mesh's table as loaded.", + "replaces": [ + "nft -f" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "remove", + "description": "Remove one rule set the mesh did not write, named exactly as the host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an active found firewall's chains. An operator's act, by name, never a flush.", + "replaces": [ + "nft delete", + "iptables -X" + ], + "input": { + "properties": { + "where": { + "description": "the rule set, as `node show` lists it", + "type": "string" + } + }, + "required": [ + "where" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-power.json b/go/seatlist/seatlisttest/seats/node-power.json new file mode 100644 index 0000000..762a79f --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-power.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-power", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0211" +} diff --git a/go/seatlist/seatlisttest/seats/node-provisioner.json b/go/seatlist/seatlisttest/seats/node-provisioner.json new file mode 100644 index 0000000..9752f8b --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-provisioner.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-provisioner", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0349" +} diff --git a/go/seatlist/seatlisttest/seats/node-reads.json b/go/seatlist/seatlisttest/seats/node-reads.json new file mode 100644 index 0000000..938a71a --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-reads.json @@ -0,0 +1,179 @@ +{ + "format": 1, + "seat": "node-reads", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0310", + "verbs": [ + { + "name": "stat", + "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which holds no secret: a component it may not reach says so. At most 200 paths in one call.", + "replaces": [ + "stat", + "ls -ld", + "namei -l", + "findmnt -T" + ], + "input": { + "properties": { + "follow": { + "description": "\"true\": also say where the path leads once every link is followed", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "paths": { + "description": "one absolute path, or several separated by line breaks", + "type": "string" + } + }, + "required": [ + "paths" + ], + "type": "object" + } + }, + { + "name": "readlink", + "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists.", + "replaces": [ + "readlink", + "readlink -f", + "realpath" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "list", + "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name.", + "replaces": [ + "ls", + "ls -la" + ], + "input": { + "properties": { + "path": { + "description": "an absolute path to a directory", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "read", + "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may.", + "replaces": [ + "cat", + "head", + "tail", + "less" + ], + "input": { + "properties": { + "bytes": { + "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", + "type": "string" + }, + "from": { + "description": "the first line to answer, counted from 1 (optional)", + "type": "string" + }, + "lines": { + "description": "how many lines to answer (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + }, + { + "name": "search", + "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met.", + "replaces": [ + "grep", + "grep -r", + "grep -F" + ], + "input": { + "properties": { + "case": { + "description": "\"false\": match whatever the case (optional)", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "depth": { + "description": "how many directories deep, up to 8 (optional)", + "type": "string" + }, + "matches": { + "description": "the most matches to answer, up to 500 (optional)", + "type": "string" + }, + "path": { + "description": "an absolute path to a file or a directory", + "type": "string" + }, + "text": { + "description": "the fixed string to find", + "type": "string" + } + }, + "required": [ + "text", + "path" + ], + "type": "object" + } + }, + { + "name": "account", + "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field.", + "replaces": [ + "getent passwd", + "getent group", + "id", + "groups" + ], + "input": { + "properties": { + "group": { + "description": "a group's name or number (optional)", + "type": "string" + }, + "name": { + "description": "an account's name or number (optional)", + "type": "string" + } + }, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-resolver.json b/go/seatlist/seatlisttest/seats/node-resolver.json new file mode 100644 index 0000000..8689bcd --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-resolver.json @@ -0,0 +1,77 @@ +{ + "format": 1, + "seat": "node-resolver", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0247", + "verbs": [ + { + "name": "routes", + "description": "What this machine's own resolver sends where: the mesh's resolvers, which answer every name not routed elsewhere, and each link given servers of its own with the domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who wrote it as far as the file says, whether a module took it, and when the resolver's own file was put back.", + "replaces": [ + "resolvectl status", + "resolvectl dns", + "resolvectl domain" + ], + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "route", + "description": "Send these domains, and every name under them, to these servers over this link — and only them: the link is never the machine's default route for names, and the mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away takes its route with it.", + "replaces": [ + "resolvectl dns", + "resolvectl domain", + "resolvectl default-route" + ], + "input": { + "properties": { + "domains": { + "description": "the domains to route there, separated by spaces or commas", + "type": "string" + }, + "link": { + "description": "the network link the servers are reached over, by name (a VPN's tunnel interface)", + "type": "string" + }, + "servers": { + "description": "the servers' addresses, separated by spaces or commas", + "type": "string" + } + }, + "required": [ + "link", + "domains", + "servers" + ], + "type": "object" + } + }, + { + "name": "unroute", + "description": "Take one link's route away: its domains go back to the mesh's resolvers. Nothing changes when the link has none.", + "replaces": [ + "resolvectl revert" + ], + "input": { + "properties": { + "link": { + "description": "the network link, by name", + "type": "string" + } + }, + "required": [ + "link" + ], + "type": "object" + } + } + ], + "paths": [ + { + "path": "/etc/resolv.conf" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-secret-service.json b/go/seatlist/seatlisttest/seats/node-secret-service.json new file mode 100644 index 0000000..fd5b84b --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-secret-service.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "node-secret-service", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208" +} diff --git a/go/seatlist/seatlisttest/seats/node-service-manager.json b/go/seatlist/seatlisttest/seats/node-service-manager.json new file mode 100644 index 0000000..9270cc2 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-service-manager.json @@ -0,0 +1,305 @@ +{ + "format": 1, + "seat": "node-service-manager", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0177", + "verbs": [ + { + "name": "units", + "description": "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", + "replaces": [ + "systemctl list-units" + ], + "input": { + "properties": { + "pattern": { + "description": "a glob the unit's name must match (optional)", + "type": "string" + }, + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "status", + "description": "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", + "replaces": [ + "systemctl status", + "systemctl is-active" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "start", + "description": "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", + "replaces": [ + "systemctl start" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "stop", + "description": "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", + "replaces": [ + "systemctl stop" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "restart", + "description": "Restart one unit.", + "replaces": [ + "systemctl restart" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "enable", + "description": "Make one unit start at boot (or at the account's login, in user scope).", + "replaces": [ + "systemctl enable" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "disable", + "description": "Stop one unit starting at boot (or at login, in user scope).", + "replaces": [ + "systemctl disable" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "journal", + "description": "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as [redacted: \u003cwhat it was\u003e].", + "replaces": [ + "journalctl" + ], + "input": { + "properties": { + "lines": { + "description": "how many lines from the end of what matches (default 100, at most 2000)", + "type": "string" + }, + "match": { + "description": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", + "type": "string" + }, + "priority": { + "description": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", + "type": "string" + }, + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "since": { + "description": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + }, + "until": { + "description": "the window's end, in the same forms (optional; now when absent)", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "failed", + "description": "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, never as nothing failed.", + "optional": true, + "replaces": [ + "systemctl --failed" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" or \"user\": only that manager (both when absent)", + "type": "string" + } + }, + "type": "object" + } + }, + { + "name": "reset-failed", + "description": "Clear one unit's failed record in its manager — its failed state and its start-limit count — and answer its state after. It removes no file and starts nothing.", + "replaces": [ + "systemctl reset-failed" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "wanted-by", + "description": "What wants, requires or triggers one unit, read-only: the manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), each with where it points and whether it dangles — the account's own directory included in user scope.", + "replaces": [ + "systemctl list-dependencies --reverse", + "systemctl show -p WantedBy", + "ls ~/.config/systemd/user/*.wants" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + } + }, + "required": [ + "unit" + ], + "type": "object" + } + }, + { + "name": "unlink-dangling", + "description": "Remove the dangling enable links named for one unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the runtime placed, is left and said.", + "replaces": [ + "rm ~/.config/systemd/user/*.wants/\u003cunit\u003e", + "unlink /etc/systemd/system/*.wants/\u003cunit\u003e" + ], + "input": { + "properties": { + "scope": { + "description": "\"system\" (the default) or \"user\": the operator account's own manager", + "type": "string" + }, + "unit": { + "description": "the unit's name, as the service manager knows it", + "type": "string" + }, + "why": { + "description": "the person's reason for removing the links, kept in the record", + "type": "string" + } + }, + "required": [ + "unit", + "why" + ], + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-terminal-emulator.json b/go/seatlist/seatlisttest/seats/node-terminal-emulator.json new file mode 100644 index 0000000..732a5ba --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-terminal-emulator.json @@ -0,0 +1,26 @@ +{ + "format": 1, + "seat": "node-terminal-emulator", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0208", + "verbs": [ + { + "name": "open", + "description": "Open a terminal window in the operator's session, running a command or the login shell, in a directory or the account's home.", + "input": { + "properties": { + "command": { + "description": "what to run in it (optional; the login shell when absent)", + "type": "string" + }, + "directory": { + "description": "where it starts (optional; the account's home when absent)", + "type": "string" + } + }, + "type": "object" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/node-uplink.json b/go/seatlist/seatlisttest/seats/node-uplink.json new file mode 100644 index 0000000..0ecfbf1 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/node-uplink.json @@ -0,0 +1,33 @@ +{ + "format": 1, + "seat": "node-uplink", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0117", + "verbs": [ + { + "name": "resolvers", + "description": "The machine's resolver file as it is now: the resolvers, search domains and options it lists, whether it is the file the mesh declares, and when it is not, who wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link in its place, a known writer running.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + }, + { + "name": "links", + "description": "Every network link on the machine: its state, its addresses, whether the default route leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's tunnel included.", + "optional": true, + "input": { + "properties": {}, + "type": "object" + } + } + ], + "paths": [ + { + "path": "/etc/resolv.conf", + "steps-back-for": "node-resolver" + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/npm-package-registry.json b/go/seatlist/seatlisttest/seats/npm-package-registry.json new file mode 100644 index 0000000..61f77c3 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/npm-package-registry.json @@ -0,0 +1,34 @@ +{ + "format": 1, + "seat": "npm-package-registry", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0109", + "description": "The mesh's npm packages: the npm registry protocol's read side, and publishing over the bus.", + "delivers": "npm-package-registry", + "includes": [ + "registry" + ], + "family": { + "name": "npm-package-registry.events", + "max-age-days": 7 + }, + "provisions": [ + { + "name": "npm-package-registry", + "form": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "the read side's address" + } + }, + "required": [ + "url" + ], + "description": "form none: the npm registry's read side, answered anonymously, with no credential (design 51)" + } + } + ] +} diff --git a/go/seatlist/seatlisttest/seats/oci-artifact-registry.json b/go/seatlist/seatlisttest/seats/oci-artifact-registry.json new file mode 100644 index 0000000..9d37fce --- /dev/null +++ b/go/seatlist/seatlisttest/seats/oci-artifact-registry.json @@ -0,0 +1,247 @@ +{ + "format": 1, + "seat": "oci-artifact-registry", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0605", + "description": "The mesh's bundles and archives, by digest, in the one artifact store: the OCI distribution protocol's read side.", + "includes": [ + "registry" + ], + "verbs": [ + { + "name": "hold", + "description": "Keep one untagged bundle, archive or copied base from removal by retention, as the artifact sweep's rules keep it (ADR 0617 §2); never an image of the holder's own. Emits held.", + "callers": "the module that runs the artifact sweep alone", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "which of the sweep's rules keeps it" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "held": { + "type": "boolean", + "description": "it is held" + }, + "since": { + "type": "string", + "description": "when the hold began, RFC 3339" + } + }, + "required": [ + "held" + ] + }, + "optional": true + }, + { + "name": "release", + "description": "Let a hold go; emits released. A release of what is not held answers released false and emits nothing, never an error: the outcome asked holds already. By hand only through the holder's own tool, the operator's session as the proved caller, recorded as a hand act.", + "callers": "the module that runs the artifact sweep; the operator's session through the holder's tool", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why it is let go" + } + }, + "required": [ + "name", + "digest", + "reason" + ] + }, + "answer": { + "type": "object", + "properties": { + "released": { + "type": "boolean", + "description": "a hold was let go; false when there was none" + } + }, + "required": [ + "released" + ] + }, + "optional": true + }, + { + "name": "list", + "description": "What the store holds of one repository, or of every repository the seat holds when none is named: every manifest keeping one bundle or archive, each with its digest, whether it is an index (and the manifests an index names), the tags naming it, its size and whether it is held. Read from the store itself, so a manifest no tag names is listed too; it reads only (ADR 0632 §3).", + "callers": "any caller whose role grants it", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository, /; every repository of the seat when not given" + } + } + }, + "answer": { + "type": "object", + "properties": { + "repositories": { + "type": "array", + "items": { + "$ref": "#/defs/Repository" + }, + "description": "each repository named, in order of its name" + } + }, + "required": [ + "repositories" + ] + }, + "optional": true + } + ], + "events": [ + { + "name": "held", + "body": { + "$ref": "#/defs/Hold" + } + }, + { + "name": "released", + "body": { + "$ref": "#/defs/Hold" + } + } + ], + "family": { + "name": "oci-artifact-registry.events", + "max-age-days": 7 + }, + "defs": { + "Hold": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the name" + }, + "digest": { + "type": "string", + "description": "the digest" + }, + "reason": { + "type": "string", + "description": "why" + }, + "holder": { + "type": "string", + "description": "the module holding the seat" + }, + "by": { + "type": "string", + "description": "the caller the holder proved" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + } + }, + "required": [ + "name", + "digest", + "holder" + ], + "description": "the body of held and released" + }, + "Repository": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "the repository" + }, + "manifests": { + "type": "array", + "items": { + "$ref": "#/defs/Listed" + }, + "description": "each manifest it holds, by digest" + } + }, + "required": [ + "name", + "manifests" + ] + }, + "Listed": { + "type": "object", + "properties": { + "digest": { + "type": "string", + "description": "the manifest's digest, sha256:" + }, + "index": { + "type": "boolean", + "description": "it is an index, naming a manifest per platform" + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + }, + "description": "for an index, the digests of the manifests it names" + }, + "artifact": { + "type": "string", + "description": "for a manifest keeping one bundle or archive, the digest of that blob: what a reference names it by" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the tags naming it, none for an untagged one" + }, + "size": { + "type": "integer", + "description": "its bytes: its own content and every blob it names" + }, + "held": { + "type": "boolean", + "description": "a hold keeps it from retention" + } + }, + "required": [ + "digest", + "index", + "size", + "held" + ] + } + } +} diff --git a/go/seatlist/seatlisttest/seats/operator-channel.json b/go/seatlist/seatlisttest/seats/operator-channel.json new file mode 100644 index 0000000..9770349 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/operator-channel.json @@ -0,0 +1,567 @@ +{ + "format": 1, + "seat": "operator-channel", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0259", + "holder-declares": true, + "description": "Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).", + "verbs": [ + { + "name": "open", + "description": "What is open now: every message the operator was sent about something still wrong, urgent first, oldest first.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the open messages" + } + }, + { + "name": "history", + "description": "What was said to the operator lately, newest first, the refusals, and what waits to be sent on each channel.", + "input": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "at most this many of each (default 50)" + } + } + }, + "answer": { + "type": "object", + "description": "the sends, refusals and what waits" + } + }, + { + "name": "notify", + "description": "Tell the operator something, as a module that uses the seat: a key, urgent or warning, one line, and what it is about; clear says it is over.", + "input": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "the message's key" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "subject": { + "type": "string", + "description": "what it is about" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "explanation": { + "type": "string", + "description": "one or two plain sentences" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "clear": { + "type": "boolean", + "description": "it is over" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "tell", + "description": "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380).", + "input": { + "type": "object", + "properties": { + "headline": { + "type": "string", + "description": "plain words" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "one or two plain lines" + }, + "subject": { + "type": "string", + "description": "what it is about" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "asks", + "description": "The asks the router holds: open first, then the last month's, each with how it ended.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the asks" + } + } + ], + "accepts": [ + { + "name": "ask", + "by-caller": true, + "body": { + "$ref": "#/defs/Ask" + } + }, + { + "name": "cancel", + "by-caller": true, + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "condition", + "by-caller": true, + "body": { + "$ref": "#/defs/Condition" + } + } + ], + "events": [ + { + "name": "decided", + "by-caller": true, + "body": { + "$ref": "#/defs/Warrant" + } + } + ], + "family": { + "name": "operator-channel.decided", + "max-age-days": 7 + }, + "states": [ + { + "name": "asks", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one ask the router holds and how it stands" + }, + "description": "one ask the router holds and how it stands" + } + ], + "description": "the asks a module raised and the router holds, one key per asker and ask" + } + ], + "holder-events": [ + { + "name": "refused", + "subject": "mesh.mod..event.refused" + }, + { + "name": "offered", + "subject": "mesh.stream..event.offered", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "headline": { + "type": "string", + "description": "its headline" + }, + "channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the channels it was offered on" + } + }, + "required": [ + "id", + "asker" + ] + } + }, + { + "name": "closed", + "subject": "mesh.stream..event.closed", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "how": { + "type": "string", + "enum": [ + "answered", + "expired", + "cancelled", + "replaced", + "unlinked" + ] + } + }, + "required": [ + "id", + "asker", + "how" + ] + } + } + ], + "defs": { + "Ask": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the asker's own id" + }, + "headline": { + "type": "string", + "description": "a few plain words" + }, + "explanation": { + "type": "string", + "description": "what happened and what it means" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/defs/Option" + }, + "description": "the answers it offers" + }, + "who": { + "type": "string", + "description": "who may answer: operator" + }, + "expires": { + "type": "string", + "description": "when it ends unanswered, RFC 3339" + }, + "on-expiry": { + "type": "string", + "description": "what the asker then does" + }, + "about": { + "type": "string", + "description": "what it is about: a newer ask about it replaces the older" + }, + "urgent": { + "type": "boolean", + "description": "urgent" + }, + "whole": { + "type": "string", + "description": "the explanation with its values whole" + }, + "details": { + "type": "string", + "description": "its secondary words" + }, + "whole-details": { + "type": "string", + "description": "the details whole" + } + }, + "required": [ + "id", + "headline", + "explanation", + "options", + "who", + "expires", + "on-expiry" + ] + }, + "Option": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the option's id" + }, + "label": { + "type": "string", + "description": "the button's label" + }, + "does": { + "type": "string", + "description": "what it does, in plain words" + }, + "level": { + "type": "string", + "description": "the proof its answer needs" + }, + "binds": { + "type": "string", + "description": "the digest of exactly what the asker performs when it is chosen" + } + }, + "required": [ + "id", + "label", + "does", + "level" + ] + }, + "Warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string", + "description": "the ask" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "about": { + "type": "string", + "description": "what it was about" + }, + "outcome": { + "type": "string", + "enum": [ + "chosen", + "expired", + "cancelled", + "replaced", + "refused" + ] + }, + "option": { + "type": "string", + "description": "the option chosen" + }, + "label": { + "type": "string", + "description": "its label" + }, + "level": { + "type": "string", + "description": "its level" + }, + "by": { + "$ref": "#/defs/Person" + }, + "channel": { + "type": "string", + "description": "the module it came through" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the proofs present" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + }, + "ask-digest": { + "type": "string", + "description": "the digest of the ask as the router took it" + }, + "words": { + "type": "string", + "description": "why it ended without a choice, or what refused it" + } + }, + "required": [ + "ask", + "asker", + "outcome", + "at" + ] + }, + "Person": { + "type": "object", + "properties": { + "who": { + "type": "string", + "description": "the role" + }, + "kind": { + "type": "string", + "description": "the channel kind" + }, + "identity": { + "type": "string", + "description": "the account" + }, + "display": { + "type": "string", + "description": "the name shown" + }, + "verified": { + "type": "string", + "description": "how the router knew" + } + }, + "required": [ + "who", + "kind", + "identity", + "verified" + ] + }, + "Condition": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the form's version, 1" + }, + "key": { + "type": "string", + "description": "the condition's key" + }, + "severity": { + "type": "string", + "description": "urgent or warning" + }, + "subject": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "scope" + }, + "id": { + "type": "string", + "description": "id" + }, + "machine": { + "type": "string", + "description": "machine" + } + }, + "required": [ + "scope", + "id" + ] + }, + "state": { + "type": "string", + "description": "raised or cleared" + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "needs": { + "type": "string", + "description": "what it needs" + }, + "explanation": { + "type": "string", + "description": "plain words" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "label" + }, + "verb": { + "type": "string", + "description": "verb" + }, + "machine": { + "type": "string", + "description": "machine" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "description": "level" + } + }, + "required": [ + "label", + "verb" + ] + }, + "description": "the answers it offers" + } + }, + "required": [ + "v", + "key", + "subject", + "state" + ] + } + } +} diff --git a/go/seatlist/seatlisttest/seats/the-private-network.json b/go/seatlist/seatlisttest/seats/the-private-network.json new file mode 100644 index 0000000..858f02e --- /dev/null +++ b/go/seatlist/seatlisttest/seats/the-private-network.json @@ -0,0 +1,7 @@ +{ + "format": 1, + "seat": "the-private-network", + "scope": "node", + "grain": "seat-list", + "decision": "novox/hq ADR 0110" +} diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 0c08efc..bf10265 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -1,51 +1,40 @@ buscompose/health_grants.go: mesh.seat.mesh-self-check.event. -catalogue/artifact_sweep_seat.go: ArtifactSweepSeat -catalogue/artifacts.go: ContainerImageRegistrySeat -catalogue/artifacts.go: GoPackageRegistrySeat -catalogue/artifacts.go: NpmPackageRegistrySeat -catalogue/artifacts.go: OciArtifactRegistrySeat -catalogue/artifacts.go: ProvisionerSeat -catalogue/artifacts.go: VaultSeat -catalogue/catalog_seat.go: CatalogSeat -catalogue/checker_seat.go: CheckerSeat -catalogue/control_records_seats.go: HandActsSeat -catalogue/control_records_seats.go: WantsSeat -catalogue/delivery_seat.go: DeliverySeat -catalogue/engine_seat.go: EngineSeat -catalogue/enrolment_seat.go: EnrolmentSeat -catalogue/environment_into.go: EnvironmentSeat -catalogue/environment_into.go: LoginShellSeat -catalogue/environment_into.go: PowerSeat -catalogue/files.go: FilesSeat -catalogue/graphical_session.go: BarSeat -catalogue/graphical_session.go: ClipboardSeat -catalogue/graphical_session.go: CompositorSeat -catalogue/graphical_session.go: DisplayServerSeat -catalogue/graphical_session.go: DisplaySessionSeat -catalogue/graphical_session.go: LauncherSeat -catalogue/graphical_session.go: LockScreenSeat -catalogue/graphical_session.go: LoginManagerSeat -catalogue/graphical_session.go: NotifierSeat -catalogue/graphical_session.go: SecretServiceSeat -catalogue/graphical_session.go: TerminalEmulatorSeat -catalogue/health_seats.go: ConditionsSeat -catalogue/health_seats.go: HealersSeat -catalogue/health_seats.go: SelfCheckSeat -catalogue/health_seats.go: WatchdogsSeat -catalogue/node_resolver.go: ResolverSeat -catalogue/placement_seat.go: PlacementSeat -catalogue/reads.go: ReadsSeat -catalogue/seat_contributions.go: BackupSeat -catalogue/seat_contributions.go: HotkeysSeat -catalogue/seat_contributions.go: MessageBusSeat -catalogue/seat_dependencies.go: ContainerRuntimeSeat -catalogue/seat_dependencies.go: PackageManagerSeat -catalogue/seat_dependencies.go: ServiceManagerSeat -catalogue/seats.go: BrokerSeat -catalogue/shares.go: MountsSeat -catalogue/shares.go: NFSServerSeat +buscompose/users.go: mesh-vault +catalogue/artifact_sweep_seat.go: mesh-artifact-sweep +catalogue/artifacts.go: container-image-registry +catalogue/artifacts.go: go-package-registry +catalogue/artifacts.go: npm-package-registry +catalogue/artifacts.go: oci-artifact-registry +catalogue/environment_into.go: node-environment +catalogue/environment_into.go: node-login-shell +catalogue/environment_into.go: node-power +catalogue/files.go: node-files +catalogue/graphical_session.go: node-bar +catalogue/graphical_session.go: node-clipboard +catalogue/graphical_session.go: node-compositor +catalogue/graphical_session.go: node-display-server +catalogue/graphical_session.go: node-display-session +catalogue/graphical_session.go: node-launcher +catalogue/graphical_session.go: node-lock-screen +catalogue/graphical_session.go: node-login-manager +catalogue/graphical_session.go: node-notifier +catalogue/graphical_session.go: node-secret-service +catalogue/graphical_session.go: node-terminal-emulator +catalogue/node_resolver.go: node-resolver +catalogue/reads.go: node-reads +catalogue/seat_contributions.go: node-backup +catalogue/seat_contributions.go: node-hotkeys +catalogue/seat_contributions.go: node-message-bus +catalogue/seat_dependencies.go: node-container-runtime +catalogue/seat_dependencies.go: node-package-manager +catalogue/seat_dependencies.go: node-service-manager +catalogue/shares.go: node-mounts +catalogue/shares.go: node-nfs-server +delivery/builder.go: container-image-registry +delivery/builder.go: oci-artifact-registry delivery/checker.go: mesh-delivery-checker delivery/composed.go: mesh-provisioner +delivery/enrolment.go: mesh-enrolment delivery/held.go: mesh.seat.mesh-artifact-sweep.event. delivery/placement.go: mesh-self-check delivery/provided.go: the-private-network -- 2.54.0 From b611bb9ad5a2c9f7a8f1536b04d13ad166363ab7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:42:20 +0100 Subject: [PATCH 091/144] =?UTF-8?q?Seat=20list=20reader:=20calls=20the=20s?= =?UTF-8?q?eat=20mesh-catalog's=20seat-definitions=20and=20hears=20its=20c?= =?UTF-8?q?hanged=20and=20removed=20events,=20acts=20on=20nothing=20new=20?= =?UTF-8?q?until=20read,=20two=20runtime=20tools;=20bootstrap=20gains=20me?= =?UTF-8?q?sh-conditions,=20operator-channel=20and=20the=20contracts'=20cu?= =?UTF-8?q?rrent=20files=20(ADR=200636=20=C2=A71-=C2=A74,=20=C2=A77;=20iss?= =?UTF-8?q?ue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatlist/bootstrap/mesh-catalog.json | 16 +- go/seatlist/bootstrap/mesh-conditions.json | 646 ++++++++++++++++++++ go/seatlist/bootstrap/mesh-config.json | 49 ++ go/seatlist/bootstrap/mesh-hand-acts.json | 231 ++++++- go/seatlist/bootstrap/mesh-watchdogs.json | 155 ++++- go/seatlist/bootstrap/node-engine.json | 361 +++++++++++ go/seatlist/bootstrap/operator-channel.json | 567 +++++++++++++++++ go/seatlist/seatlist.go | 356 +++++++---- go/seatlist/seatlist_test.go | 171 ++++-- go/testdata/seat-names.txt | 1 + 10 files changed, 2383 insertions(+), 170 deletions(-) create mode 100644 go/seatlist/bootstrap/mesh-conditions.json create mode 100644 go/seatlist/bootstrap/operator-channel.json diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index 40b6618..f44644f 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -3,8 +3,22 @@ "seat": "mesh-catalog", "scope": "mesh", "grain": "seat-list", - "decision": "novox/hq ADR 0604", + "decision": "novox/hq ADR 0636", "verbs": [ + { + "name": "seat-definitions", + "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "optional": true, + "input": { + "properties": { + "seat": { + "description": "the seat's name; none lists every trunk seat", + "type": "string" + } + }, + "type": "object" + } + }, { "name": "modules", "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", diff --git a/go/seatlist/bootstrap/mesh-conditions.json b/go/seatlist/bootstrap/mesh-conditions.json new file mode 100644 index 0000000..ee5b697 --- /dev/null +++ b/go/seatlist/bootstrap/mesh-conditions.json @@ -0,0 +1,646 @@ +{ + "format": 1, + "seat": "mesh-conditions", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.", + "verbs": [ + { + "name": "conditions", + "description": "What is wrong with the mesh now: every open condition, urgent first, then oldest, raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition lately.", + "input": { + "properties": { + "days": { + "description": "with history: how many days back (default 7, at most 90)", + "type": "string" + }, + "history": { + "description": "\"true\": every raising, change, silence and clearing lately, oldest first", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "key": { + "description": "a condition's key: that one whole; with history, only its transitions", + "type": "string" + }, + "machine": { + "description": "only those about this machine", + "type": "string" + }, + "scope": { + "description": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", + "type": "string" + }, + "severity": { + "description": "only urgent, or only warning", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the open conditions, or one whole, or the history" + } + }, + { + "name": "silence", + "description": "Stop one condition's messages for a while, a hand act with why: it stays open and in status, and the silence is kept as its transition in history.", + "input": { + "properties": { + "cause": { + "description": "the cause in a word (the condition's kind when absent)", + "type": "string" + }, + "for": { + "description": "how long: 30m, 4h, 2d; at most 7d", + "type": "string" + }, + "key": { + "description": "the condition's key", + "type": "string" + }, + "why": { + "description": "why, required and recorded", + "type": "string" + } + }, + "required": [ + "key", + "for", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the condition now silenced" + } + }, + { + "name": "resync", + "description": "Read every observer's findings again and raise and clear what differs from the open conditions: the recovery of a keeper that missed a change.", + "input": { + "properties": {}, + "type": "object" + }, + "answer": { + "type": "object", + "description": "what was raised and cleared again" + } + } + ], + "events": [ + { + "name": "condition-raised", + "body": { + "$ref": "#/defs/ConditionEvent" + } + }, + { + "name": "condition-changed", + "body": { + "$ref": "#/defs/ConditionEvent" + } + }, + { + "name": "condition-cleared", + "body": { + "$ref": "#/defs/ConditionEvent" + } + } + ], + "family": { + "name": "mesh-conditions.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "conditions", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one open condition", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "subject", + "severity", + "raised", + "last-observed" + ] + }, + "description": "one open condition, without its looks it keeps from its finding" + } + ], + "description": "the open conditions the keeper holds, one key each" + }, + { + "name": "findings", + "template": "state", + "writer": "caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "every observer's findings, one key per finding; a key written raises or changes the condition of the same key, a key deleted clears it" + } + ], + "defs": { + "Subject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "Evidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] + }, + "Action": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "ConditionEvent": { + "type": "object", + "description": "a condition and what changed", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + }, + "event": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "change": { + "type": "string", + "enum": [ + "raised", + "reopened", + "severity", + "resolver", + "silenced", + "silence-ended", + "cleared", + "words" + ] + }, + "was": { + "type": "string" + }, + "why": { + "type": "string" + }, + "cleared": { + "type": "string", + "format": "date-time" + }, + "show": { + "type": "string", + "description": "the tool call that shows it" + } + }, + "required": [ + "key", + "event", + "at", + "change" + ] + } + } +} diff --git a/go/seatlist/bootstrap/mesh-config.json b/go/seatlist/bootstrap/mesh-config.json index d095825..640ccbb 100644 --- a/go/seatlist/bootstrap/mesh-config.json +++ b/go/seatlist/bootstrap/mesh-config.json @@ -218,5 +218,54 @@ ] } } + ], + "states": [ + { + "name": "resolved", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "the value a module on a node takes for a config key, and where it came from", + "properties": { + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": {}, + "source": { + "type": "string" + }, + "predefined": { + "type": "string", + "description": "the predefined key the value falls back to" + }, + "required": { + "type": "boolean", + "description": "an unset value is the operator's own to set" + }, + "trusted": { + "type": "boolean" + } + }, + "required": [ + "node", + "module", + "key", + "source" + ] + }, + "description": "one resolved value" + } + ], + "description": "the value each module on each node takes for each config key, built from the sources alone" + } ] } diff --git a/go/seatlist/bootstrap/mesh-hand-acts.json b/go/seatlist/bootstrap/mesh-hand-acts.json index a789dbb..e635d7a 100644 --- a/go/seatlist/bootstrap/mesh-hand-acts.json +++ b/go/seatlist/bootstrap/mesh-hand-acts.json @@ -2,8 +2,9 @@ "format": 1, "seat": "mesh-hand-acts", "scope": "mesh", - "grain": "seat-list", - "decision": "novox/hq ADR 0565", + "grain": "whole", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.", "verbs": [ { "name": "hand-act", @@ -36,6 +37,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the act recorded" } }, { @@ -65,6 +70,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the acts lately" } }, { @@ -86,12 +95,226 @@ "ask" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the warrant recorded" } } ], "events": [ { - "name": "act-recorded" + "name": "act-recorded", + "body": { + "$ref": "#/defs/Act" + } } - ] + ], + "family": { + "name": "mesh-hand-acts.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "acts", + "template": "log", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + }, + "description": "one act" + } + ], + "max-age-days": 400, + "description": "every act, kept 400 days" + }, + { + "name": "warrants", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what a module did on the operator's warrant, made once per ask", + "properties": { + "ask": { + "type": "string" + }, + "asker": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "ask", + "asker" + ] + }, + "description": "one warrant" + } + ], + "description": "warrants recorded, one key per asker and ask" + } + ], + "defs": { + "Act": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + } + } } diff --git a/go/seatlist/bootstrap/mesh-watchdogs.json b/go/seatlist/bootstrap/mesh-watchdogs.json index 3ad9f3c..ecf09a3 100644 --- a/go/seatlist/bootstrap/mesh-watchdogs.json +++ b/go/seatlist/bootstrap/mesh-watchdogs.json @@ -2,8 +2,9 @@ "format": 1, "seat": "mesh-watchdogs", "scope": "mesh", - "grain": "seat-list", - "decision": "novox/hq ADR 0564", + "grain": "whole", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters.", "verbs": [ { "name": "signals", @@ -16,6 +17,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the watchdog table" } }, { @@ -29,6 +34,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "what stopped moving" } }, { @@ -42,6 +51,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "who is connected and who is silent" } }, { @@ -54,7 +67,7 @@ "type": "string" }, "consumer": { - "description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on", + "description": "a consumer's name, or .: only what that one gave up on", "type": "string" }, "deliver": { @@ -79,6 +92,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the dead letters, one whole, or the act done" } }, { @@ -87,7 +104,139 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what was rebuilt" } } + ], + "states": [ + { + "name": "presence", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "whether a node is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a node" + }, + { + "key": "part..", + "value": { + "type": "object", + "description": "whether a part's instance is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a part's instance" + } + ], + "history": 20, + "description": "who is connected; the entry's own time says since when" + }, + { + "name": "pulse", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "a watched key mirrored, its revision, and who owes the next move", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "owes": { + "type": "string" + } + }, + "required": [ + "bucket", + "key", + "revision" + ] + }, + "description": "the move watched at a key of a state" + } + ], + "description": "one key per watched move, living for the move's bound minus its age" + }, + { + "name": "stuck", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what stopped moving", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "waited": { + "type": "string", + "description": "what it waited for, in words" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "moved": { + "type": "boolean", + "description": "it has moved again" + } + }, + "required": [ + "bucket", + "key", + "since" + ] + }, + "description": "a key whose pulse expired" + } + ], + "description": "what stopped moving; no key is removed, one that moves again is written as moved" + } ] } diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json index dfcaeb8..0987936 100644 --- a/go/seatlist/bootstrap/node-engine.json +++ b/go/seatlist/bootstrap/node-engine.json @@ -709,6 +709,367 @@ "name": "node-engine.events", "max-age-days": 7 }, + "states": [ + { + "name": "have", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one module's state on a node: what the engine has built, composed and run", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string", + "description": "the fingerprint held" + }, + "composed": { + "type": "string", + "description": "the composition's digest" + }, + "build": { + "type": "string" + }, + "phase": { + "type": "string", + "description": "how far it has come" + }, + "health": { + "type": "string", + "description": "how it is" + }, + "detail": { + "type": "string" + }, + "user-list": { + "type": "object", + "description": "the user list written" + }, + "judgement": { + "type": "string" + }, + "waits": { + "type": "string", + "description": "what it waits for" + }, + "build-alone": { + "type": "boolean" + }, + "kept": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed-at": { + "type": "object" + }, + "pull-wait": { + "type": "object", + "description": "a pull the engine waits for" + }, + "handed": { + "type": "object", + "description": "the accounts handed" + } + }, + "required": [ + "v", + "phase", + "health" + ] + }, + "description": "one module on a node" + }, + { + "key": ".node", + "value": { + "type": "object", + "description": "a node's own record", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string" + }, + "windows": { + "type": "object" + }, + "busy": { + "type": "object" + }, + "strays": { + "type": "object" + }, + "filters": { + "type": "object" + }, + "profile": { + "type": "object" + }, + "record": { + "type": "object", + "description": "the node's record: its tunnel and certificate" + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed-at": { + "type": "object" + }, + "candidates": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "composes": { + "type": "string", + "description": "own, or the controller's" + }, + "composes-why": { + "type": "string" + } + }, + "required": [ + "v" + ] + }, + "description": "the node's own record" + } + ], + "history": 20, + "description": "what each node has of each module, written by its engine" + }, + { + "name": "composed", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one composition of a module on a node under a fingerprint", + "properties": { + "V": { + "type": "integer" + }, + "Node": { + "type": "string" + }, + "Module": { + "type": "string" + }, + "FP": { + "type": "string" + }, + "Data": { + "type": "boolean" + }, + "Resources": { + "type": "array", + "items": { + "type": "object" + } + }, + "Filled": { + "type": "array", + "items": { + "type": "string" + } + }, + "Own": { + "type": "object", + "description": "the node's own adoption, identities, groups, libraries and provisioner account" + }, + "Plan": { + "type": "object", + "description": "the module's plan" + }, + "Refused": { + "type": "string", + "description": "why the composition was refused" + } + }, + "required": [ + "Node", + "Module", + "FP" + ] + }, + "description": "one composition" + } + ], + "description": "the compositions of one node, kept in a bucket per node named composed-" + }, + { + "name": "data", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the data items a module has on a node", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "node", + "module", + "digest", + "items" + ] + }, + "description": "a module's data on a node" + } + ], + "description": "the data items of each module on each node" + }, + { + "name": "packages", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the packages a node declares", + "properties": { + "node": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "absent": { + "type": "boolean" + } + } + } + } + }, + "required": [ + "node", + "packages" + ] + }, + "description": "a node's declared packages" + } + ], + "description": "the packages each node declares" + }, + { + "name": "health", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "a node's health as its engine reports it", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "network": { + "type": "object" + }, + "peers": { + "type": "array", + "items": { + "type": "object" + } + }, + "units": { + "type": "object" + }, + "relogin": { + "type": "array", + "items": { + "type": "object" + } + }, + "found": { + "type": "array", + "items": { + "type": "object" + } + }, + "fallback": { + "type": "object" + }, + "root": { + "type": "array", + "items": { + "type": "object" + } + }, + "contract": { + "type": "integer" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "node", + "at" + ] + }, + "description": "a node's health" + } + ], + "description": "each node's health as its engine reports it" + } + ], "defs": { "Act": { "type": "object", diff --git a/go/seatlist/bootstrap/operator-channel.json b/go/seatlist/bootstrap/operator-channel.json new file mode 100644 index 0000000..9770349 --- /dev/null +++ b/go/seatlist/bootstrap/operator-channel.json @@ -0,0 +1,567 @@ +{ + "format": 1, + "seat": "operator-channel", + "scope": "mesh", + "grain": "whole", + "decision": "novox/hq ADR 0259", + "holder-declares": true, + "description": "Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).", + "verbs": [ + { + "name": "open", + "description": "What is open now: every message the operator was sent about something still wrong, urgent first, oldest first.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the open messages" + } + }, + { + "name": "history", + "description": "What was said to the operator lately, newest first, the refusals, and what waits to be sent on each channel.", + "input": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "at most this many of each (default 50)" + } + } + }, + "answer": { + "type": "object", + "description": "the sends, refusals and what waits" + } + }, + { + "name": "notify", + "description": "Tell the operator something, as a module that uses the seat: a key, urgent or warning, one line, and what it is about; clear says it is over.", + "input": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "the message's key" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "subject": { + "type": "string", + "description": "what it is about" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "explanation": { + "type": "string", + "description": "one or two plain sentences" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "clear": { + "type": "boolean", + "description": "it is over" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "tell", + "description": "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380).", + "input": { + "type": "object", + "properties": { + "headline": { + "type": "string", + "description": "plain words" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + }, + "description": "one or two plain lines" + }, + "subject": { + "type": "string", + "description": "what it is about" + } + } + }, + "answer": { + "type": "object", + "description": "what was sent" + } + }, + { + "name": "asks", + "description": "The asks the router holds: open first, then the last month's, each with how it ended.", + "input": { + "type": "object", + "properties": {}, + "description": "no arguments" + }, + "answer": { + "type": "object", + "description": "the asks" + } + } + ], + "accepts": [ + { + "name": "ask", + "by-caller": true, + "body": { + "$ref": "#/defs/Ask" + } + }, + { + "name": "cancel", + "by-caller": true, + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "condition", + "by-caller": true, + "body": { + "$ref": "#/defs/Condition" + } + } + ], + "events": [ + { + "name": "decided", + "by-caller": true, + "body": { + "$ref": "#/defs/Warrant" + } + } + ], + "family": { + "name": "operator-channel.decided", + "max-age-days": 7 + }, + "states": [ + { + "name": "asks", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one ask the router holds and how it stands" + }, + "description": "one ask the router holds and how it stands" + } + ], + "description": "the asks a module raised and the router holds, one key per asker and ask" + } + ], + "holder-events": [ + { + "name": "refused", + "subject": "mesh.mod..event.refused" + }, + { + "name": "offered", + "subject": "mesh.stream..event.offered", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "headline": { + "type": "string", + "description": "its headline" + }, + "channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the channels it was offered on" + } + }, + "required": [ + "id", + "asker" + ] + } + }, + { + "name": "closed", + "subject": "mesh.stream..event.closed", + "family": "router.asks", + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the ask's id" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "how": { + "type": "string", + "enum": [ + "answered", + "expired", + "cancelled", + "replaced", + "unlinked" + ] + } + }, + "required": [ + "id", + "asker", + "how" + ] + } + } + ], + "defs": { + "Ask": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the asker's own id" + }, + "headline": { + "type": "string", + "description": "a few plain words" + }, + "explanation": { + "type": "string", + "description": "what happened and what it means" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/defs/Option" + }, + "description": "the answers it offers" + }, + "who": { + "type": "string", + "description": "who may answer: operator" + }, + "expires": { + "type": "string", + "description": "when it ends unanswered, RFC 3339" + }, + "on-expiry": { + "type": "string", + "description": "what the asker then does" + }, + "about": { + "type": "string", + "description": "what it is about: a newer ask about it replaces the older" + }, + "urgent": { + "type": "boolean", + "description": "urgent" + }, + "whole": { + "type": "string", + "description": "the explanation with its values whole" + }, + "details": { + "type": "string", + "description": "its secondary words" + }, + "whole-details": { + "type": "string", + "description": "the details whole" + } + }, + "required": [ + "id", + "headline", + "explanation", + "options", + "who", + "expires", + "on-expiry" + ] + }, + "Option": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "the option's id" + }, + "label": { + "type": "string", + "description": "the button's label" + }, + "does": { + "type": "string", + "description": "what it does, in plain words" + }, + "level": { + "type": "string", + "description": "the proof its answer needs" + }, + "binds": { + "type": "string", + "description": "the digest of exactly what the asker performs when it is chosen" + } + }, + "required": [ + "id", + "label", + "does", + "level" + ] + }, + "Warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string", + "description": "the ask" + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "about": { + "type": "string", + "description": "what it was about" + }, + "outcome": { + "type": "string", + "enum": [ + "chosen", + "expired", + "cancelled", + "replaced", + "refused" + ] + }, + "option": { + "type": "string", + "description": "the option chosen" + }, + "label": { + "type": "string", + "description": "its label" + }, + "level": { + "type": "string", + "description": "its level" + }, + "by": { + "$ref": "#/defs/Person" + }, + "channel": { + "type": "string", + "description": "the module it came through" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the proofs present" + }, + "at": { + "type": "string", + "description": "when, RFC 3339" + }, + "ask-digest": { + "type": "string", + "description": "the digest of the ask as the router took it" + }, + "words": { + "type": "string", + "description": "why it ended without a choice, or what refused it" + } + }, + "required": [ + "ask", + "asker", + "outcome", + "at" + ] + }, + "Person": { + "type": "object", + "properties": { + "who": { + "type": "string", + "description": "the role" + }, + "kind": { + "type": "string", + "description": "the channel kind" + }, + "identity": { + "type": "string", + "description": "the account" + }, + "display": { + "type": "string", + "description": "the name shown" + }, + "verified": { + "type": "string", + "description": "how the router knew" + } + }, + "required": [ + "who", + "kind", + "identity", + "verified" + ] + }, + "Condition": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the form's version, 1" + }, + "key": { + "type": "string", + "description": "the condition's key" + }, + "severity": { + "type": "string", + "description": "urgent or warning" + }, + "subject": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "scope" + }, + "id": { + "type": "string", + "description": "id" + }, + "machine": { + "type": "string", + "description": "machine" + } + }, + "required": [ + "scope", + "id" + ] + }, + "state": { + "type": "string", + "description": "raised or cleared" + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string", + "description": "plain words" + }, + "needs": { + "type": "string", + "description": "what it needs" + }, + "explanation": { + "type": "string", + "description": "plain words" + }, + "resolved": { + "type": "string", + "description": "the line said when it is over" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "label" + }, + "verb": { + "type": "string", + "description": "verb" + }, + "machine": { + "type": "string", + "description": "machine" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "description": "level" + } + }, + "required": [ + "label", + "verb" + ] + }, + "description": "the answers it offers" + } + }, + "required": [ + "v", + "key", + "subject", + "state" + ] + } + } +} diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index ea5d413..e18878e 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -1,17 +1,21 @@ -// Package seatlist is the runtime's reader of the seat list (novox/hq ADR 0635 §1, §3): the seat definitions the -// indexer keeps on the bus, asked once through the owner's tool and followed on its event `seat-definition-changed`. -// The manifest validator, the node-engine, the tool runner, claude-code, the grants part and every other caller of -// `catalogue` get their seats here and give them to `catalogue` as an input; mesh-sdk compiles no seat list and imports -// no contracts module. +// Package seatlist is the runtime's reader of the seat list (novox/hq ADR 0635 §1, ADR 0636 §1, §3, §4): the seat +// definitions the seat mesh-catalog keeps, read through its verb `seat-definitions` and followed on its events +// `seat-definition-changed` and `seat-definition-removed`. The manifest validator, the node-engine, the tool runner, +// claude-code, the grants part and every other caller of `catalogue` get their seats here and give them to `catalogue` +// as an input; mesh-sdk compiles no seat list and imports no contracts module. // -// **What it asks and hears.** The owner's tool Tool, with no seat for every trunk definition (the answer's shape is -// Listing) and with one seat for that seat's (the answer is the owner's own, read as Answer); the owner's event -// ChangedEvent, whose body names the seat and its source (Changed). Only trunk definitions are held: a pull request's are -// the indexer's to judge. Nothing polls and nothing waits on a timer: a reader that could not ask says so in its -// Status and reads again on the next event or on Reload, which the host module offers as a tool (steering it). +// **What it asks and hears.** The seat's verb with no seat (every trunk definition's name and digest, Listing) and with +// one seat (that definition, Answer); the seat's two events, whose bodies name the seat (Changed). It calls the seat, +// never the module that holds it, and only trunk definitions are held: a pull request's are the indexer's to judge. +// Nothing polls and nothing waits on a timer: a reader that could not ask says so in its Status and reads again on the +// next event or on Reload, which the host offers as a tool (Tools). // -// **What it names.** The tool's and the event's names, which ADR 0635 §1 gives the owner. It names no module and no -// seat: the owner's module is the caller's (Config.Owner), the one its manifest's `invokes` lists. +// **A reader that has not heard acts on nothing new** (ADR 0636 §1): MayAct says what a host may start, compose, grant +// or judge meanwhile, which is only the modules whose seats the bootstrapped definitions name (ADR 0636 §2). It keeps +// running what it runs; a missing or removed definition only refuses a new claim, invoke or grant of that seat (Has). +// +// **What it names.** The one seat that serves the list (Seat and Verb), which ADR 0636 §4 fixes as the runtime's own +// way in; no module, no other seat. package seatlist import ( @@ -22,43 +26,56 @@ import ( "strings" "sync" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" ) const ( - // Tool is the owner's tool that answers a seat definition. - Tool = "indexer_seat_definition" - // ChangedEvent is the owner's event that says a definition moved. + // Seat is the seat that serves the seat list (ADR 0636 §4); the bootstrapped definitions name it (ADR 0636 §2). + Seat = "mesh-catalog" + // Verb is its verb: with no seat, every trunk seat with its digest; with a seat, that definition. + Verb = "seat-definitions" + // ChangedEvent and RemovedEvent are its events: a trunk's definition moved, or went. ChangedEvent = "seat-definition-changed" + RemovedEvent = "seat-definition-removed" // SourceTrunk is the only source this reader holds. SourceTrunk = "trunk" + // SourceBootstrap is what a definition is held from before the seat has named it. + SourceBootstrap = "bootstrap" ) -// Bus is what the reader needs of its host: a call of a tool by its key (`.`) and a subscription to an -// event pattern. StdioBus is the one a bundle has; the node-engine and the tool runner give theirs. +// Key is the address a reader asks (`seat:.`, as stdio.Ask takes it); a host's manifest `invokes` lists the +// verb. Pattern is what it subscribes to for an event (`.`), which the host's manifest `consumes`. +func Key() string { return "seat:" + Seat + "." + Verb } + +// Pattern is the subscription of one of the seat's events. +func Pattern(event string) string { return Seat + "." + event } + +// Bus is what the reader needs of its host: a call by its address and a subscription to an event pattern. StdioBus is +// the one a bundle has; the node-engine and the tool runner give theirs. type Bus interface { Ask(key string, body any) (json.RawMessage, error) Subscribe(pattern string, handle func(key string, body json.RawMessage) error) error } -// Entry is one definition as the owner answers it: the seat, its source, the digest of the file, and the definition -// resolved with the parts it includes. It is the owner's SeatDefinitionEntry, read here by the fields it needs. +// Entry is one definition as the seat answers it: the seat, its source, the digest of the file, and, in an answer for one +// seat, the definition resolved with the parts it includes. type Entry struct { Seat string `json:"seat"` - Source string `json:"source"` + Source string `json:"source,omitempty"` Digest string `json:"digest,omitempty"` Definition json.RawMessage `json:"definition,omitempty"` Unparsed string `json:"unparsed,omitempty"` } -// Listing is the owner's answer to the tool with no seat: every trunk definition and the revision of its state. +// Listing is the seat's answer with no seat: every trunk seat with its digest, and the revision of its state. type Listing struct { - Revision uint64 `json:"revision"` - Definitions []Entry `json:"definitions"` + Revision uint64 `json:"revision"` + Seats []Entry `json:"seats"` } -// Answer is the owner's answer to the tool with one seat. +// Answer is the seat's answer with one seat. type Answer struct { Seat string `json:"seat"` Found bool `json:"found"` @@ -66,20 +83,18 @@ type Answer struct { Revision uint64 `json:"revision"` } -// Changed is the event's body. +// Changed is an event's body. type Changed struct { Seat string `json:"seat"` - Source string `json:"source"` - Digest string `json:"digest"` + Source string `json:"source,omitempty"` + Digest string `json:"digest,omitempty"` } // Config is what a host gives Start. type Config struct { - // Owner is the module that keeps the definitions, as the host's manifest `invokes` names it. - Owner string - // Bus is the host's way to the owner; StdioBus() for a bundle. + // Bus is the host's way to the seat; StdioBus() for a bundle. Bus Bus - // Bootstrap are the definitions known before the owner is heard (ADR 0635 §3); Bootstrapped() is the foundation's. + // Bootstrap are the definitions known before the seat is heard (ADR 0636 §2); Bootstrapped() is the mesh's. Bootstrap []seatdef.Definition // OnChange, when set, is called after the held set changed, with the seats in reading order. It runs on the // goroutine that heard the change: it returns quickly. @@ -90,46 +105,69 @@ type Config struct { type Reader struct { cfg Config - mu sync.Mutex - defs map[string]seatdef.Definition // by seat - boot map[string]bool // seats held from the bootstrap, not yet from the owner - // what the last ask of the owner came to (Status) + mu sync.Mutex + defs map[string]seatdef.Definition // by seat + digests map[string]string // by seat: the digest of the trunk file held + boot map[string]bool // seats held from the bootstrap, not yet from the seat + refused map[string]string // seats whose trunk definition cannot be parsed, with why: the one that stands stays + // what the last ask of the seat came to (Status) spoken bool revision uint64 lastErr string } -// Status is what the reader holds and what it waits for (ADR 0635 §3), for the host's health and its tool: Heard says -// the owner has answered, Waiting are the seats still held from the bootstrap alone (the owner has not named them), -// LastError is why the last ask failed, and Revision is the owner's state revision of the last listing. +// Held is one definition a reader holds: its seat, where from, and the digest of the trunk file it came from. +type Held struct { + Seat string `json:"seat"` + Source string `json:"source"` + Digest string `json:"digest,omitempty"` +} + +// Status is what the reader holds and what it waits for (ADR 0636 §1, §7), for the host's health and its tool: Heard says +// the seat has answered, Waiting are the seats still held from the bootstrap alone (the seat has not named them), Refused +// are the seats whose trunk definition did not parse (the one held stands), LastError is why the last ask failed, and +// Revision is the seat's state revision of the last listing. type Status struct { - Heard bool `json:"heard"` - Held int `json:"held"` - Waiting []string `json:"waiting,omitempty"` - LastError string `json:"last-error,omitempty"` - Revision uint64 `json:"revision,omitempty"` + Heard bool `json:"heard"` + Held []Held `json:"held"` + Waiting []string `json:"waiting,omitempty"` + Refused map[string]string `json:"refused,omitempty"` + LastError string `json:"last-error,omitempty"` + Revision uint64 `json:"revision,omitempty"` } // Status is the reader's state of mind. func (r *Reader) Status() Status { r.mu.Lock() defer r.mu.Unlock() - st := Status{Heard: r.spoken, Held: len(r.defs), LastError: r.lastErr, Revision: r.revision} - for seat := range r.boot { - st.Waiting = append(st.Waiting, seat) + st := Status{Heard: r.spoken, LastError: r.lastErr, Revision: r.revision, Held: []Held{}} + for seat := range r.defs { + h := Held{Seat: seat, Source: SourceTrunk, Digest: r.digests[seat]} + if r.boot[seat] { + h.Source = SourceBootstrap + st.Waiting = append(st.Waiting, seat) + } + st.Held = append(st.Held, h) } + sort.Slice(st.Held, func(i, j int) bool { return st.Held[i].Seat < st.Held[j].Seat }) sort.Strings(st.Waiting) + if len(r.refused) > 0 { + st.Refused = map[string]string{} + for k, v := range r.refused { + st.Refused[k] = v + } + } return st } -// Health is Status as the one line a module's health says: empty when the owner has been heard and nothing waits. +// Health is Status as the one line a module's health says: empty when the seat has been heard and nothing waits. func (st Status) Health() string { switch { case !st.Heard: - return fmt.Sprintf("the seat list is held from the foundation's bootstrap (%d seats): the definitions' owner has not answered%s", - st.Held, withError(st.LastError)) + return fmt.Sprintf("the seat list is held from the bootstrap (%d seats) and nothing new is acted on: the seat %s has not answered%s", + len(st.Held), Seat, withError(st.LastError)) case len(st.Waiting) > 0: - return fmt.Sprintf("%d seats are held from the bootstrap alone, the owner names none of them: %s", len(st.Waiting), strings.Join(st.Waiting, ", ")) + return fmt.Sprintf("%d seats are held from the bootstrap alone, the seat %s names none of them: %s", len(st.Waiting), Seat, strings.Join(st.Waiting, ", ")) } return "" } @@ -141,24 +179,23 @@ func withError(e string) string { return " (" + e + ")" } -// Start returns a reader holding the bootstrap, asks the owner for every definition and follows its event. A first -// ask the owner did not answer is no failure: the reader holds the bootstrap and reads again on the owner's next -// event or on Reload. Start returns the subscription's error alone, which the host may ask again by calling Start again -// on a new reader. +// Start returns a reader holding the bootstrap, asks the seat for every definition and follows its two events. A first +// ask the seat did not answer is no failure: the reader holds the bootstrap, acts on nothing new (MayAct) and reads again +// on the seat's next event or on Reload. Start returns the subscription's error alone, which the host may ask again by +// calling Start again on a new reader. func Start(cfg Config) (*Reader, error) { if cfg.Bus == nil { return nil, errors.New("seatlist: no bus to read the seat list through") } - if cfg.Owner == "" || strings.ContainsAny(cfg.Owner, ". \t") { - return nil, fmt.Errorf("seatlist: the owner %q is not a module name", cfg.Owner) - } - r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, boot: map[string]bool{}} + r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, digests: map[string]string{}, boot: map[string]bool{}, refused: map[string]string{}} for _, d := range cfg.Bootstrap { r.defs[d.Seat] = d r.boot[d.Seat] = true } - if err := cfg.Bus.Subscribe("*."+ChangedEvent, r.heard); err != nil { - return r, fmt.Errorf("seatlist: following %s: %w", ChangedEvent, err) + for _, ev := range []string{ChangedEvent, RemovedEvent} { + if err := cfg.Bus.Subscribe(Pattern(ev), r.heard); err != nil { + return r, fmt.Errorf("seatlist: following %s: %w", ev, err) + } } _ = r.Reload() return r, nil @@ -182,6 +219,35 @@ func (r *Reader) Definitions() map[string]seatdef.Definition { return out } +// Has says whether the reader holds a definition of the seat: a claim, invoke or grant of a seat it does not hold is +// refused, and nothing already running is stopped for it (ADR 0636 §3). +func (r *Reader) Has(seat string) bool { + r.mu.Lock() + defer r.mu.Unlock() + _, ok := r.defs[seat] + return ok +} + +// MayAct says whether a host may start, compose, grant or judge a module that claims, invokes or provides the given seats +// (ADR 0636 §1): once the seat list has been read, any; before, only a module whose seats are all bootstrapped +// ones (ADR 0636 §2), and at least one. It changes nothing a module already runs. +func (r *Reader) MayAct(seats ...string) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.spoken { + return true + } + if len(seats) == 0 { + return false + } + for _, s := range seats { + if !r.boot[s] { + return false + } + } + return true +} + func (r *Reader) seatsLocked() []catalogue.Seat { ds := make([]seatdef.Definition, 0, len(r.defs)) for _, d := range r.defs { @@ -190,80 +256,112 @@ func (r *Reader) seatsLocked() []catalogue.Seat { return catalogue.SeatsFromDefinitions(ds) } -// Reload asks the owner for every trunk definition and holds what it answers, a seat the owner holds replacing the -// bootstrap's. An answer that cannot be read is returned and changes nothing. +// Reload asks the seat for its trunk seats and digests and then for each definition it holds a different digest of, +// and drops the trunk seats it no longer lists; a bootstrapped seat the seat does not list stays and waits. An answer that +// cannot be read is returned and changes nothing; one definition that does not parse leaves the one held standing and is +// said in Status. func (r *Reader) Reload() error { - raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{}) + raw, err := r.cfg.Bus.Ask(Key(), map[string]any{}) if err != nil { - return r.failed(fmt.Errorf("seatlist: asking %s.%s: %w", r.cfg.Owner, Tool, err)) + return r.failed(fmt.Errorf("seatlist: asking %s: %w", Key(), err)) } var l Listing if err := json.Unmarshal(raw, &l); err != nil { - return r.failed(fmt.Errorf("seatlist: %s.%s answered what is not a listing: %w", r.cfg.Owner, Tool, err)) + return r.failed(fmt.Errorf("seatlist: %s answered what is not a listing: %w", Key(), err)) } - parsed := map[string]seatdef.Definition{} - for _, e := range l.Definitions { - if e.Source != SourceTrunk || e.Unparsed != "" || len(e.Definition) == 0 { + listed := map[string]bool{} + var first error + for _, e := range l.Seats { + if e.Seat == "" || (e.Source != "" && e.Source != SourceTrunk) { continue } - d, err := seatdef.Parse(e.Definition) - if err != nil { - return r.failed(fmt.Errorf("seatlist: the definition of %s does not parse: %w", e.Seat, err)) + listed[e.Seat] = true + r.mu.Lock() + same := !r.boot[e.Seat] && e.Digest != "" && r.digests[e.Seat] == e.Digest + r.mu.Unlock() + if same { + continue + } + if err := r.fetch(e.Seat); err != nil && first == nil { + first = err } - parsed[d.Seat] = d } r.mu.Lock() - r.spoken, r.revision, r.lastErr = true, l.Revision, "" - for seat := range r.defs { - if !r.boot[seat] { - delete(r.defs, seat) - } + r.spoken, r.revision = true, l.Revision + if first == nil { + r.lastErr = "" } - for seat, d := range parsed { - r.defs[seat] = d - delete(r.boot, seat) + for seat := range r.defs { + if !r.boot[seat] && !listed[seat] { + r.dropLocked(seat) + } } r.mu.Unlock() r.changed() + if first != nil { + return r.failed(first) + } return nil } -// heard takes the owner's event: a trunk definition moved, so that seat is asked again. An error is returned so the -// bus offers the event again. -func (r *Reader) heard(_ string, body json.RawMessage) error { +// fetch asks the seat for one definition and holds it, or drops it when the seat holds none. +func (r *Reader) fetch(seat string) error { + raw, err := r.cfg.Bus.Ask(Key(), map[string]any{"seat": seat}) + if err != nil { + return fmt.Errorf("seatlist: asking %s for %s: %w", Key(), seat, err) + } + var a Answer + if err := json.Unmarshal(raw, &a); err != nil { + return fmt.Errorf("seatlist: %s answered for %s what is not an answer: %w", Key(), seat, err) + } + r.mu.Lock() + defer r.mu.Unlock() + switch { + case !a.Found || a.Entry == nil || (a.Entry.Source != "" && a.Entry.Source != SourceTrunk): + r.dropLocked(seat) + case a.Entry.Unparsed != "" || len(a.Entry.Definition) == 0: + r.refused[seat] = a.Entry.Unparsed // the definition that stands stays: a trunk's that does not parse is never written over it + default: + d, err := seatdef.Parse(a.Entry.Definition) + if err != nil { + r.refused[seat] = err.Error() + return nil + } + r.defs[seat], r.digests[seat] = d, a.Entry.Digest + delete(r.boot, seat) + delete(r.refused, seat) + } + return nil +} + +// dropLocked forgets a seat: its definition is gone, so a new claim of it is refused (ADR 0636 §3). +func (r *Reader) dropLocked(seat string) { + delete(r.defs, seat) + delete(r.digests, seat) + delete(r.boot, seat) + delete(r.refused, seat) +} + +// heard takes one of the seat's events: a trunk definition moved, so that seat is asked again, or went, so it is dropped. +// An error is returned so the bus offers the event again. +func (r *Reader) heard(key string, body json.RawMessage) error { var c Changed if err := json.Unmarshal(body, &c); err != nil || c.Seat == "" { return nil } - if c.Source != SourceTrunk { + if c.Source != "" && c.Source != SourceTrunk { return nil } - raw, err := r.cfg.Bus.Ask(r.cfg.Owner+"."+Tool, map[string]any{"seat": c.Seat}) - if err != nil { + if strings.HasSuffix(key, "."+RemovedEvent) { + r.mu.Lock() + r.dropLocked(c.Seat) + r.mu.Unlock() + r.changed() + return nil + } + if err := r.fetch(c.Seat); err != nil { return r.failed(err) } - var a Answer - if err := json.Unmarshal(raw, &a); err != nil { - return err - } - r.mu.Lock() - switch { - case !a.Found || a.Entry == nil || a.Entry.Source != SourceTrunk: - if !r.boot[c.Seat] { - delete(r.defs, c.Seat) - } - case a.Entry.Unparsed != "" || len(a.Entry.Definition) == 0: - // the definition that stands stays: a trunk's that does not parse is never written over it - default: - d, err := seatdef.Parse(a.Entry.Definition) - if err != nil { - r.mu.Unlock() - return fmt.Errorf("seatlist: the definition of %s does not parse: %w", c.Seat, err) - } - r.defs[c.Seat] = d - delete(r.boot, c.Seat) - } - r.mu.Unlock() r.changed() return nil } @@ -280,3 +378,45 @@ func (r *Reader) changed() { r.cfg.OnChange(r.Seats()) } } + +// Tools are the reader's two runtime tools (ADR 0636 §7), for a host's bundle: read, which says what the reader holds +// (every definition, its source and digest) and what it waits for; and again, which makes it read the list now, and +// answers only to the callers the host's list names (the operator and the healers). The host names the tools and the list; +// again records nothing but the read it asked. +func Tools(r *Reader, read, again string, callers stdio.Callers) []stdio.Tool { + return []stdio.Tool{ + {Name: read, + Description: "The seat list this part holds (novox/hq ADR 0636): each seat's definition with its source (trunk or bootstrap) and " + + "the digest of the file it came from, the seats still held from the bootstrap alone, definitions refused because they " + + "do not parse, whether the seat " + Seat + " has answered and why not.", + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(map[string]any) (any, error) { + return map[string]any{"status": r.Status(), "health": r.Status().Health()}, nil + }}, + {Name: again, + Description: "Read the seat list again now, from the seat " + Seat + " (a hand act for a part that waits or holds a stale definition), " + + "and answer what it holds afterwards. Answered only to the operator and the healers.", + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(args map[string]any) (any, error) { + if err := Refusal(again, callers, stdio.CallerOf(args)); err != nil { + return nil, err + } + err := r.Reload() + st := r.Status() + out := map[string]any{"status": st, "health": st.Health()} + if err != nil { + out["error"] = err.Error() + } + return out, nil + }}, + } +} + +// Refusal is why caller may not make the reader read again, or nil: the caller's proof, never an argument, against the +// list of the operator and the healers. +func Refusal(tool string, callers stdio.Callers, caller string) error { + if callers.Allows(caller) { + return nil + } + return fmt.Errorf("%s is refused to %q: it answers only %s (novox/hq ADR 0636 §7)", tool, caller, strings.Join(callers, ", ")) +} diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index e22d060..f845b66 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -3,50 +3,57 @@ package seatlist import ( "encoding/json" "errors" + "fmt" "strings" "testing" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" ) -// fakeOwner is the definitions' owner: the tool answers from its table and the event is delivered by the test. +// fakeOwner is the seat mesh-catalog: its verb answers from its table and the events are delivered by the test. type fakeOwner struct { - table map[string]string // seat -> definition JSON, trunk - down bool - asked []string - handler func(string, json.RawMessage) error - pattern string + table map[string]string // seat -> definition JSON, trunk + down bool + asked []string // "key seat" + handlers map[string]func(string, json.RawMessage) error } func (f *fakeOwner) Ask(key string, body any) (json.RawMessage, error) { - f.asked = append(f.asked, key) + args, _ := body.(map[string]any) + seat, _ := args["seat"].(string) + f.asked = append(f.asked, key+" "+seat) if f.down { return nil, errors.New("no answer") } - args, _ := body.(map[string]any) - if seat, _ := args["seat"].(string); seat != "" { + if seat != "" { raw, ok := f.table[seat] if !ok { return json.Marshal(Answer{Seat: seat}) } - return json.Marshal(Answer{Seat: seat, Found: true, Entry: &Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)}}) + return json.Marshal(Answer{Seat: seat, Found: true, Entry: &Entry{Seat: seat, Source: SourceTrunk, Digest: digest(raw), Definition: json.RawMessage(raw)}}) } l := Listing{Revision: 7} for seat, raw := range f.table { - l.Definitions = append(l.Definitions, Entry{Seat: seat, Source: SourceTrunk, Definition: json.RawMessage(raw)}) + l.Seats = append(l.Seats, Entry{Seat: seat, Source: SourceTrunk, Digest: digest(raw)}) } return json.Marshal(l) } +func digest(raw string) string { return fmt.Sprintf("%d", len(raw)) } + func (f *fakeOwner) Subscribe(pattern string, h func(string, json.RawMessage) error) error { - f.pattern, f.handler = pattern, h + if f.handlers == nil { + f.handlers = map[string]func(string, json.RawMessage) error{} + } + f.handlers[pattern] = h return nil } -func (f *fakeOwner) say(seat, source string) error { +func (f *fakeOwner) say(event, seat, source string) error { b, _ := json.Marshal(Changed{Seat: seat, Source: source, Digest: "d"}) - return f.handler("mesh-owner."+ChangedEvent, b) + return f.handlers[Pattern(event)](Pattern(event), b) } func def(seat, scope, verb string) string { @@ -62,41 +69,51 @@ func names(r *Reader) string { return strings.Join(out, ",") } -// ADR 0635 §1, §3: the seat list comes from the owner on the bus, starting from the bootstrap, and follows its event. -func TestTheSeatListIsReadFromTheOwnerAndFollowsItsEvent(t *testing.T) { +// ADR 0636 §1, §3, §4: the seat list is read through the seat's verb, starting from the bootstrap, and follows its two events. +func TestTheSeatListIsReadFromTheSeatAndFollowsItsEvents(t *testing.T) { boot, err := seatdef.Parse([]byte(def("boot-seat", "mesh", "go"))) if err != nil { t.Fatal(err) } owner := &fakeOwner{down: true, table: map[string]string{ "node-b": def("node-b", "node", "put"), "mesh-a": def("mesh-a", "mesh", "get"), "boot-seat": def("boot-seat", "mesh", "other")}} - var told [][]catalogue.Seat - r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: []seatdef.Definition{boot}, - OnChange: func(s []catalogue.Seat) { told = append(told, s) }}) + var told int + r, err := Start(Config{Bus: owner, Bootstrap: []seatdef.Definition{boot}, OnChange: func([]catalogue.Seat) { told++ }}) if err != nil { t.Fatal(err) } - if owner.pattern != "*."+ChangedEvent { - t.Fatalf("subscribed to %q", owner.pattern) + if owner.handlers[Pattern(ChangedEvent)] == nil || owner.handlers[Pattern(RemovedEvent)] == nil || Pattern(ChangedEvent) != "mesh-catalog.seat-definition-changed" { + t.Fatalf("subscribed to %v", owner.handlers) + } + if owner.asked[0] != "seat:mesh-catalog.seat-definitions " { + t.Fatalf("first asked %q: the seat's verb, never a module", owner.asked[0]) } steps := []struct { name string act func() error want string err string + asks int // asks the step made }{ - {"the owner has not answered: the bootstrap stands", func() error { return nil }, "boot-seat", ""}, - {"an event while the owner is down is offered again", func() error { return owner.say("mesh-a", SourceTrunk) }, "boot-seat", "no answer"}, - {"the owner answers: its seats in reading order, mesh seats first, the owner's definition replacing the bootstrap's", - func() error { owner.down = false; return r.Reload() }, "boot-seat,mesh-a,node-b", ""}, + {"the seat has not answered: the bootstrap stands", func() error { return nil }, "boot-seat", "", 0}, + {"an event while the seat is down is offered again", func() error { return owner.say(ChangedEvent, "mesh-a", SourceTrunk) }, "boot-seat", "no answer", 1}, + {"the seat answers: its seats in reading order, its definition replacing the bootstrap's, each fetched once", + func() error { owner.down = false; return r.Reload() }, "boot-seat,mesh-a,node-b", "", 4}, + {"a read again fetches nothing whose digest it holds", func() error { return r.Reload() }, "boot-seat,mesh-a,node-b", "", 1}, {"a moved definition is asked again", func() error { owner.table["mesh-a"] = def("mesh-a", "mesh", "renamed") - return owner.say("mesh-a", SourceTrunk) - }, "boot-seat,mesh-a,node-b", ""}, - {"a seat the owner holds no more goes", func() error { delete(owner.table, "node-b"); return owner.say("node-b", SourceTrunk) }, "boot-seat,mesh-a", ""}, - {"a pull request's definition is not held", func() error { owner.asked = nil; return owner.say("mesh-z", "pull") }, "boot-seat,mesh-a", ""}, + return owner.say(ChangedEvent, "mesh-a", SourceTrunk) + }, "boot-seat,mesh-a,node-b", "", 1}, + {"a definition that does not parse leaves the one that stands", func() error { + owner.table["mesh-a"] = `{"format":1,"seat":"mesh-a","verbs":"no"}` + return owner.say(ChangedEvent, "mesh-a", SourceTrunk) + }, "boot-seat,mesh-a,node-b", "", 1}, + {"a seat the seat removes goes", func() error { delete(owner.table, "node-b"); return owner.say(RemovedEvent, "node-b", SourceTrunk) }, "boot-seat,mesh-a", "", 0}, + {"a pull request's definition is not held and not asked", func() error { return owner.say(ChangedEvent, "mesh-z", "pull") }, "boot-seat,mesh-a", "", 0}, + {"a listing that drops a trunk seat drops it", func() error { delete(owner.table, "mesh-a"); return r.Reload() }, "boot-seat", "", 1}, } for _, s := range steps { + before := len(owner.asked) err := s.act() if (err == nil) != (s.err == "") || (err != nil && !strings.Contains(err.Error(), s.err)) { t.Fatalf("%s: error %v, want %q", s.name, err, s.err) @@ -104,47 +121,63 @@ func TestTheSeatListIsReadFromTheOwnerAndFollowsItsEvent(t *testing.T) { if got := names(r); got != s.want { t.Fatalf("%s: seats %q, want %q", s.name, got, s.want) } + if got := len(owner.asked) - before; got != s.asks { + t.Fatalf("%s: asked %d times (%v), want %d", s.name, got, owner.asked[before:], s.asks) + } } - if len(owner.asked) != 0 { - t.Fatalf("a pull request's event asked the owner: %v", owner.asked) + if st := r.Status(); len(st.Refused) != 0 || !r.Has("boot-seat") || r.Has("mesh-a") { + t.Fatalf("status %+v, has mesh-a %v", st, r.Has("mesh-a")) } - if v := r.Seats()[1].Serves[0]; v.Name != "renamed" || v.Input["type"] != "object" { - t.Fatalf("the moved definition's verb is %+v, want renamed ", v) - } - if len(told) != 3 { - t.Fatalf("OnChange was told %d times", len(told)) + if told < 5 { + t.Fatalf("OnChange was told %d times", told) } } func TestStartRefusesWhatCannotRead(t *testing.T) { - for name, cfg := range map[string]Config{ - "no bus": {Owner: "m"}, - "an owner with a dot": {Owner: "m.tool", Bus: &fakeOwner{}}, - "no owner": {Bus: &fakeOwner{}}, - } { - if _, err := Start(cfg); err == nil { - t.Fatalf("%s: started", name) - } + if _, err := Start(Config{}); err == nil { + t.Fatal("started without a bus") } } -// ADR 0635 §3: before the owner is heard the reader holds the foundation's definitions and says it waits. -func TestTheBootstrapParsesAndIsHeldUntilTheOwnerAnswers(t *testing.T) { +// ADR 0636 §1, §2: before the seat is heard the reader holds the bootstrapped definitions, says it waits and lets a host act +// on the modules those definitions name only. +func TestTheBootstrapIsHeldAndNothingNewIsActedOnUntilTheSeatAnswers(t *testing.T) { boot := Bootstrapped() - if len(boot) == 0 { - t.Fatal("no bootstrapped definition") + held := map[string]bool{} + for _, d := range boot { + held[d.Seat] = true + } + for _, seat := range []string{"mesh-catalog", "mesh-conditions", "operator-channel", "node-engine", "mesh-broker", "mesh-config"} { + if !held[seat] { + t.Fatalf("the bootstrap lacks %s (ADR 0636 §2)", seat) + } } owner := &fakeOwner{down: true} - r, err := Start(Config{Owner: "mesh-owner", Bus: owner, Bootstrap: boot}) + r, err := Start(Config{Bus: owner, Bootstrap: boot}) if err != nil { t.Fatal(err) } - if got := len(r.Seats()); got != len(boot) { - t.Fatalf("the reader holds %d seats, the bootstrap has %d", got, len(boot)) + if got := len(r.Definitions()); got != len(boot) { + t.Fatalf("the reader holds %d definitions, the bootstrap has %d", got, len(boot)) } st := r.Status() - if st.Heard || len(st.Waiting) != len(boot) || st.LastError == "" { - t.Fatalf("status %+v: want the owner not heard, every bootstrapped seat waiting and the error said", st) + if st.Heard || len(st.Waiting) != len(boot) || st.LastError == "" || st.Health() == "" { + t.Fatalf("status %+v: want the seat not heard, every bootstrapped seat waiting, the error and the health said", st) + } + acts := []struct { + seats []string + want bool + }{ + {[]string{"node-engine"}, true}, + {[]string{"mesh-catalog", "mesh-config"}, true}, + {[]string{"node-engine", "node-files"}, false}, + {[]string{"node-files"}, false}, + {nil, false}, + } + for _, a := range acts { + if got := r.MayAct(a.seats...); got != a.want { + t.Fatalf("waiting: MayAct(%v) = %v, want %v", a.seats, got, a.want) + } } owner.down = false owner.table = map[string]string{"node-b": def("node-b", "node", "put")} @@ -152,6 +185,36 @@ func TestTheBootstrapParsesAndIsHeldUntilTheOwnerAnswers(t *testing.T) { t.Fatal(err) } if st := r.Status(); !st.Heard || len(st.Waiting) != len(boot) || st.LastError != "" || st.Revision != 7 { - t.Fatalf("status after the owner answered %+v: want heard, the bootstrap's seats the owner does not hold still waiting, no error, revision 7", st) + t.Fatalf("status after the seat answered %+v: want heard, the bootstrap's seats it does not hold still waiting, no error, revision 7", st) + } + if !r.MayAct("node-files") || !r.MayAct() { + t.Fatal("after the seat answered, a host acts on any module") + } +} + +// ADR 0636 §7: the two tools read what is held and read again now, the second to its callers only. +func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { + owner := &fakeOwner{down: true, table: map[string]string{"node-b": def("node-b", "node", "put")}} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + callers := stdio.Callers{"person.jo", "mesh-healers"} + tools := Tools(r, "seat_list", "seat_list_again", callers) + out, err := tools[0].Run(map[string]any{}) + if err != nil || out.(map[string]any)["health"] == "" || !strings.Contains(fmt.Sprint(out.(map[string]any)["status"]), "bootstrap") { + t.Fatalf("read: %v %v", out, err) + } + for caller, allowed := range map[string]bool{"person.jo": true, "ace.mesh-healers": true, "ace.mesh-other": false, "person.al": false, stdio.Unproven: false} { + if err := Refusal("seat_list_again", callers, caller); (err == nil) != allowed { + t.Fatalf("%s: %v, allowed %v", caller, err, allowed) + } + } + if _, err := tools[1].Run(map[string]any{}); err == nil || !strings.Contains(err.Error(), "refused") || len(owner.asked) != 1 { + t.Fatalf("a call without a proven caller read the list again: %v, asks %v", err, owner.asked) + } + owner.down = false + if err := r.Reload(); err != nil || !r.Status().Heard || len(r.Status().Held) != len(Bootstrapped())+1 { + t.Fatalf("again: %v %+v", err, r.Status()) } } diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index bf10265..4919957 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -48,3 +48,4 @@ internal/beside/beside.go: mesh-catalog overlay/overlay.go: the-private-network provisions/provisions.go: mesh-provisioner provisions/provisions.go: mesh-vault +seatlist/seatlist.go: mesh-catalog -- 2.54.0 From e8ae3ec8d3cf6a3eb93b0872c57c1ba2a2267cb4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:42:59 +0100 Subject: [PATCH 092/144] =?UTF-8?q?go/keys:=20the=20version=20of=20a=20kep?= =?UTF-8?q?t=20value=20and=20the=20key-part=20escaping=20that=20more=20tha?= =?UTF-8?q?n=20one=20domain=20shares=20(ADR=200634=20=C2=A74;=20issue=2019?= =?UTF-8?q?66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/keys/keys.go | 59 ++++++++++++++++++++++++++++++++++++++++ go/keys/keys_test.go | 29 ++++++++++++++++++++ go/packages_list_test.go | 2 +- 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 go/keys/keys.go create mode 100644 go/keys/keys_test.go diff --git a/go/keys/keys.go b/go/keys/keys.go new file mode 100644 index 0000000..489b37c --- /dev/null +++ b/go/keys/keys.go @@ -0,0 +1,59 @@ +// Package keys holds what the mesh's kept values of more than one domain share: the version a kept value carries and +// the escaping of a name into a key part (novox/hq ADR 0634 §1, §4; they were go/delivery's and are used by the +// Delivery, Placement and Identity and access domains alike). It names no seat and no module. +package keys + +import ( + "encoding/hex" + "fmt" + "regexp" + "strings" +) + +// Version is the version of every kept value that carries one and the only one its readers read. +const Version = 1 + +var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) + +// EscapeName is name as a key part: a character outside letters, digits, `-`, `/` and `_` is written `=` and its +// two lowercase hex digits per byte, and `=` itself is escaped, so any non-empty name keys and a name of allowed +// characters only keys as it is. UnescapeName is its inverse. +func EscapeName(name string) string { + var b strings.Builder + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '/', c == '_': + b.WriteByte(c) + default: + fmt.Fprintf(&b, "=%02x", c) + } + } + return b.String() +} + +// UnescapeName is the inverse of EscapeName; it refuses a part with a bare `=` or any character EscapeName leaves +// escaped. +func UnescapeName(part string) (string, error) { + var out []byte + for i := 0; i < len(part); i++ { + c := part[i] + if c != '=' { + if !nameOK.MatchString(string(c)) { + return "", fmt.Errorf("keys: %q holds %q, which a key part escapes", part, c) + } + out = append(out, c) + continue + } + if i+3 > len(part) { + return "", fmt.Errorf("keys: %q ends in an incomplete escape", part) + } + v, err := hex.DecodeString(part[i+1 : i+3]) + if err != nil || part[i+1:i+3] != strings.ToLower(part[i+1:i+3]) { + return "", fmt.Errorf("keys: %q holds a malformed escape %q", part, part[i:i+3]) + } + out = append(out, v[0]) + i += 2 + } + return string(out), nil +} diff --git a/go/keys/keys_test.go b/go/keys/keys_test.go new file mode 100644 index 0000000..25ed7f3 --- /dev/null +++ b/go/keys/keys_test.go @@ -0,0 +1,29 @@ +package keys + +import ( + "strings" + "testing" +) + +func TestNamesEscapeIntoKeyPartsAndBack(t *testing.T) { + for _, c := range []struct{ name, esc string }{ + {"fix/1.2", "fix/1=2e2"}, {"a.b", "a=2eb"}, {"a=b", "a=3db"}, {"a+b", "a=2bb"}, {"plain-name_1/x", "plain-name_1/x"}, + {"fix/ü", "fix/=c3=bc"}, {"x y", "x=20y"}, + } { + esc := EscapeName(c.name) + if esc != c.esc || strings.ContainsAny(esc, ".+ ") { + t.Errorf("escape of %q = %q, want %q", c.name, esc, c.esc) + } + if back, err := UnescapeName(esc); err != nil || back != c.name { + t.Errorf("unescape(%q) = %q, %v; want %q", esc, back, err, c.name) + } + } + for _, bad := range []string{"a.b", "a=", "a=2", "a=zz", "a=2E", "a b"} { + if got, err := UnescapeName(bad); err == nil { + t.Errorf("unescape(%q) = %q, accepted", bad, got) + } + } + if Version != 1 { + t.Errorf("Version = %d: a kept value's readers read the one version", Version) + } +} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index aa60388..66df9f7 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -24,7 +24,7 @@ import ( // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", - "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "kvlimit", "membership", + "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } -- 2.54.0 From 351f8abba7654c87c309345922bda23280fa5fd5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:44:38 +0100 Subject: [PATCH 093/144] =?UTF-8?q?views:=20the=20hand-act=20recorder=20an?= =?UTF-8?q?d=20the=20finding=20writer=20come=20from=20the=20caller=20as=20?= =?UTF-8?q?interfaces,=20no=20hand-acts=20or=20health=20import=20(ADR=2006?= =?UTF-8?q?35=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/views/emit.go | 17 ++---------- go/views/findings.go | 37 +++++++++++++++++-------- go/views/handacts.go | 22 +++++++++++++++ go/views/process_reader.go | 13 +++++---- go/views/reader.go | 57 ++++++++++++++++++-------------------- go/views/status.go | 11 ++++---- go/views/views_test.go | 54 +++++++++++++++++------------------- 7 files changed, 115 insertions(+), 96 deletions(-) create mode 100644 go/views/handacts.go diff --git a/go/views/emit.go b/go/views/emit.go index 2e16fa7..3a68d69 100644 --- a/go/views/emit.go +++ b/go/views/emit.go @@ -29,10 +29,7 @@ type Emitter struct { TooLarge func(state, key string, size int) // Findings writes and clears the owner's finding of a change not said past UnsaidAfter (UnsaidKind, its own state // `findings`); nil says it in the log only. - Findings interface { - Put(key string, value []byte) error - Delete(key string) error - } + Findings FindingWriter // UnsaidAfter is how long a change may stay not said before it is the owner's finding; zero is Bound. UnsaidAfter time.Duration // Wait pauses before a change not said is said again (its attempt so far); it answers false when ctx ended. Nil is @@ -180,11 +177,7 @@ func (e Emitter) raiseUnsaid(state string, u jetstream.KeyValueEntry, first time if e.Findings == nil { return false } - key, value, err := UnsaidFinding(e.Owner, state, u.Key(), u.Revision(), first, cause.Error(), e.now()) - if err == nil { - err = e.Findings.Put(key, value) - } - if err != nil { + if err := e.Findings.Put(UnsaidFinding(e.Owner, state, u.Key(), u.Revision(), first, cause.Error(), e.now())); err != nil { e.logf("views: the finding of %s's change not said could not be written: %v", state, err) return false } @@ -193,11 +186,7 @@ func (e Emitter) raiseUnsaid(state string, u jetstream.KeyValueEntry, first time // clearUnsaid clears the finding once the change is said; it answers whether it is cleared. func (e Emitter) clearUnsaid(state string) bool { - key, _, err := UnsaidFinding(e.Owner, state, "", 0, time.Time{}, "", e.now()) - if err == nil { - err = e.Findings.Delete(key) - } - if err != nil { + if err := e.Findings.Delete(UnsaidFinding(e.Owner, state, "", 0, time.Time{}, "", e.now())); err != nil { e.logf("views: the finding of %s's change not said could not be cleared: %v", state, err) return false } diff --git a/go/views/findings.go b/go/views/findings.go index 781186d..db3cfa6 100644 --- a/go/views/findings.go +++ b/go/views/findings.go @@ -4,8 +4,6 @@ import ( "fmt" "strings" "time" - - "git.novox.be/novox/mesh-sdk/go/health" ) // The findings a views part writes in its own state `findings`, which the condition keeper raises and clears @@ -17,9 +15,30 @@ const ( UnansweredKind = "first-view-unanswered" ) +// Finding is what a views part says is wrong with a state it owns or follows, in plain fields: Kind and ID name it (a module-scoped +// finding of the observer), Summary, Headline and Explanation say it. It carries no layout: the writer a part gives +// (FindingWriter) encodes it as the condition keeper reads it, which is the seat mesh-conditions' contract, not this +// package's (novox/hq ADR 0634 §4, ADR 0635 §4). +type Finding struct { + Observer string + Kind string + ID string + Summary string + Headline string + Explanation string + At time.Time +} + +// FindingWriter writes and clears a part's findings in its own state `findings`, where the condition keeper raises and +// clears conditions from them (novox/hq ADR 0564 §2): the part gives the one that encodes a Finding as that state's layout. +type FindingWriter interface { + Put(f Finding) error + Delete(f Finding) error +} + // UnsaidFinding is the owner's finding of a change of state it has not said on its views family since since: every // reader of the state stands still at the change before it until it is said, which clears this. -func UnsaidFinding(owner, state, key string, revision uint64, since time.Time, cause string, at time.Time) (string, []byte, error) { +func UnsaidFinding(owner, state, key string, revision uint64, since time.Time, cause string, at time.Time) Finding { id := owner + "." + state summary := fmt.Sprintf("%s has not said the change of %s.%s at %d on its views family since %s (%s): its readers stand still before it", owner, state, key, revision, since.UTC().Format(time.RFC3339), strings.TrimSpace(cause)) @@ -30,7 +49,7 @@ func UnsaidFinding(owner, state, key string, revision uint64, since time.Time, c } // TooLargeFinding is the owner's finding of state's key too large to say whole on its views family. -func TooLargeFinding(owner, state, key string, size int, at time.Time) (string, []byte, error) { +func TooLargeFinding(owner, state, key string, size int, at time.Time) Finding { id := owner + "." + state + "." + key summary := fmt.Sprintf("%s's %s.%s holds %d bytes, more than one views event carries (%d): every reader asks its first view again at each change of it", owner, state, key, size, MaxValueBytes) return finding(owner, TooLargeKind, id, summary, @@ -41,7 +60,7 @@ func TooLargeFinding(owner, state, key string, size int, at time.Time) (string, // UnansweredFinding is a reader's finding of its first view of s unanswered past its bound (ADR 0615 §6): it names the // owner and the tool. A first view answered at revision 0 with nothing in it is said too, with the operator's step. -func UnansweredFinding(reader string, s Status, at time.Time) (string, []byte, error) { +func UnansweredFinding(reader string, s Status, at time.Time) Finding { id := reader + "." + s.Owner + "." + s.State summary := fmt.Sprintf("%s has no first view of %s's %s: %s has not answered since %s (%s)", reader, s.Owner, s.State, s.Tool, s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error)) @@ -51,10 +70,6 @@ func UnansweredFinding(reader string, s Status, at time.Time) (string, []byte, e "Look at %s's runtime of its own: is it running and connected?", reader, s.Owner, s.State, s.Tool, s.Owner), at) } -func finding(observer, kind, id, summary, headline, explanation string, at time.Time) (string, []byte, error) { - key := health.FindingKey(health.ScopeModule, id, kind) - value, err := health.EncodeFinding(health.Finding{Key: key, Kind: kind, Subject: health.Subject{Scope: health.ScopeModule, ID: id}, - Severity: health.Warning, Summary: summary, Headline: headline, Explanation: explanation, - Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, Source: observer + "." + kind, Observer: observer, At: at.UTC()}) - return key, value, err +func finding(observer, kind, id, summary, headline, explanation string, at time.Time) Finding { + return Finding{Observer: observer, Kind: kind, ID: id, Summary: summary, Headline: headline, Explanation: explanation, At: at.UTC()} } diff --git a/go/views/handacts.go b/go/views/handacts.go new file mode 100644 index 0000000..3b6fdba --- /dev/null +++ b/go/views/handacts.go @@ -0,0 +1,22 @@ +package views + +import "errors" + +// HandAct is a hand act a part records before it does it: what is done, why, and the cause and verb that name it. +type HandAct struct { + What, Why, Cause, Verb string +} + +// HandActs records a hand act (novox/hq ADR 0565 clause 11) before the part does it; a part gives the one that records +// it through its hand-acts client. An act that is not taken returns an error that says nothing was recorded, +// and the part then does nothing. A nil HandActs records nothing, so a re-read by hand is refused. +type HandActs interface { + RecordHandAct(HandAct) error +} + +func recordHandAct(acts HandActs, a HandAct) error { + if acts == nil { + return errors.New("no way to record a hand act is given") + } + return acts.RecordHandAct(a) +} diff --git a/go/views/process_reader.go b/go/views/process_reader.go index a45fcb4..2c63d11 100644 --- a/go/views/process_reader.go +++ b/go/views/process_reader.go @@ -36,7 +36,7 @@ type ProcessReader struct { Call func(key string, body any) (json.RawMessage, error) // Findings is the reader's own state `findings`, where the condition keeper raises and clears the condition of a // first view unanswered past its bound (ADR 0615 §6); nil says it in the log only. - Findings func() jetstream.KeyValue + Findings func() FindingWriter Logf func(string, ...any) // Now, Bound and Wait are for tests: zero are the clock, Bound and five seconds. Now func() time.Time @@ -141,11 +141,12 @@ func (r *ProcessReader) finding(s Status, raised bool) { if r.Now != nil { at = r.Now() } - key, value, err := UnansweredFinding(r.Module, s, at) - if err == nil && raised { - _, err = kv.Put(context.Background(), key, value) - } else if err == nil { - err = kv.Delete(context.Background(), key) + f := UnansweredFinding(r.Module, s, at) + var err error + if raised { + err = kv.Put(f) + } else { + err = kv.Delete(f) } if err != nil { r.logf("views: the finding of the first view %s is not written: %v", s.Tool, err) diff --git a/go/views/reader.go b/go/views/reader.go index e98b67b..8a67ab6 100644 --- a/go/views/reader.go +++ b/go/views/reader.go @@ -10,7 +10,6 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/handacts" ) // Reader is a module's pictures of the owners' states it used to read (novox/hq ADR 0615 §1): it @@ -23,16 +22,13 @@ type Reader struct { Module string // Findings writes and clears this module's finding of an unanswered first view (its own state `findings`); nil // says it in the log only. - Findings interface { - Put(key string, value []byte) error - Delete(key string) error - } - Logf func(string, ...any) + Findings FindingWriter + Logf func(string, ...any) // Changed is told of each key a followed state changes, after the picture holds it (state is `.`). Changed func(state, key string, value json.RawMessage, deleted bool) - // Acts records a re-read by hand through the seat mesh-hand-acts before it is asked (the zero Recorder records nothing + // Acts records a re-read by hand before it is asked (nil records nothing // and the re-read is refused). - Acts handacts.Recorder + Acts HandActs // Now, Bound and Wait are for tests: zero are the clock, Bound and five seconds. Now func() time.Time Bound time.Duration @@ -94,10 +90,6 @@ func (r *Reader) Follow(owner, state, tool string) *Follower { return f } -func (r *Reader) findingKey(s Status) (string, []byte, error) { - return UnansweredFinding(r.Module, s, r.nowTime()) -} - func (r *Reader) nowTime() time.Time { if r.Now != nil { return r.Now().UTC() @@ -110,11 +102,7 @@ func (r *Reader) raise(s Status) { if r.Findings == nil { return } - key, value, err := r.findingKey(s) - if err == nil { - err = r.Findings.Put(key, value) - } - if err != nil { + if err := r.Findings.Put(UnansweredFinding(r.Module, s, r.nowTime())); err != nil { r.logf("the finding of the unanswered first view %s could not be written: %v", s.Tool, err) } } @@ -123,11 +111,7 @@ func (r *Reader) clear(s Status) { if r.Findings == nil { return } - key, _, err := r.findingKey(s) - if err == nil { - err = r.Findings.Delete(key) - } - if err != nil { + if err := r.Findings.Delete(UnansweredFinding(r.Module, s, r.nowTime())); err != nil { r.logf("the finding of the first view %s could not be cleared: %v", s.Tool, err) } } @@ -310,12 +294,12 @@ func (r *Reader) Tools(prefix string) []stdio.Tool { if len(fs) == 0 { return nil, fmt.Errorf("%q is not a state this module follows", want) } - // A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded is + // A hand act, recorded before it is done; one that cannot be recorded is // said and nothing is asked. why := "the operator asked" - if _, err := r.Acts.Record(handacts.Request{What: "asked a first view again by hand (" + r.Module + ")", Why: why, + if err := recordHandAct(r.Acts, HandAct{What: "asked a first view again by hand (" + r.Module + ")", Why: why, Cause: "first-view-again", Verb: "first-view-again"}); err != nil { - return nil, fmt.Errorf("the hand act was not recorded through mesh-hand-acts, so nothing was asked again: %w", err) + return nil, fmt.Errorf("the hand act was not recorded, so nothing was asked again: %w", err) } for _, f := range fs { if err := f.Again(context.Background(), why); err != nil { @@ -328,14 +312,27 @@ func (r *Reader) Tools(prefix string) []stdio.Tool { } // OwnFindings writes a reader's findings into its own state `findings` through its runtime, where the condition keeper -// raises and clears conditions from them: the Findings of a Reader whose module keeps the state `findings`. -type OwnFindings struct{} +// raises and clears conditions from them: the Findings of a Reader whose module keeps the state `findings`. Encode is +// the module's: it turns a Finding into the state's key and value as the seat mesh-conditions' contract lays them out. +type OwnFindings struct { + Encode func(Finding) (key string, value []byte, err error) +} // Put writes one finding. -func (OwnFindings) Put(key string, value []byte) error { - _, err := stdio.State("findings").Put(key, json.RawMessage(value)) +func (o OwnFindings) Put(f Finding) error { + key, value, err := o.Encode(f) + if err != nil { + return err + } + _, err = stdio.State("findings").Put(key, json.RawMessage(value)) return err } // Delete clears one finding. -func (OwnFindings) Delete(key string) error { return stdio.State("findings").Delete(key) } +func (o OwnFindings) Delete(f Finding) error { + key, _, err := o.Encode(f) + if err != nil { + return err + } + return stdio.State("findings").Delete(key) +} diff --git a/go/views/status.go b/go/views/status.go index e040a80..43bd66b 100644 --- a/go/views/status.go +++ b/go/views/status.go @@ -12,7 +12,6 @@ import ( "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/handacts" ) // StatusState is a reader's own state of its first views (design 25 § 12.2): at StatusKey each first view's Status, as @@ -94,9 +93,9 @@ func HearAgain(ctx context.Context, kv jetstream.KeyValue, followers []*Follower // StatusTools are a reader's read tool of its first views (read) and its re-read tool (again), in its tools bundle, // over its StatusState: read lists what the process last said of each; again writes a hand ask the process takes, // naming the caller the runtime proved, and is recorded in the status's by-hand count and why. again is a hand act: -// acts records it through the seat mesh-hand-acts before the ask is written, and one the seat does not take is said and -// nothing is asked (the zero Recorder records nothing, so again refuses). -func StatusTools(reader, read, again string, st stdio.KeptState, acts handacts.Recorder, now func() time.Time) []stdio.Tool { +// acts records it before the ask is written, and one that is not taken is said and nothing is asked (nil acts +// record nothing, so again refuses). +func StatusTools(reader, read, again string, st stdio.KeptState, acts HandActs, now func() time.Time) []stdio.Tool { return []stdio.Tool{ {Name: read, Description: fmt.Sprintf("%s's first views of the owners' states it follows (novox/hq ADR 0615): per owner and state, "+ @@ -139,9 +138,9 @@ func StatusTools(reader, read, again string, st stdio.KeptState, acts handacts.R return nil, errors.New("again names the owner, the state and why") } a := HandAsk{V: Version, Owner: owner, State: state, Why: strings.TrimSpace(why), By: stdio.CallerOf(args), At: now().UTC()} - if _, err := acts.Record(handacts.Request{What: fmt.Sprintf("asked %s's first view of %s's %s again by hand", reader, owner, state), + if err := recordHandAct(acts, HandAct{What: fmt.Sprintf("asked %s's first view of %s's %s again by hand", reader, owner, state), Why: a.Why, Cause: "first-view-again", Verb: again}); err != nil { - return nil, fmt.Errorf("the hand act was not recorded through mesh-hand-acts, so nothing was asked again: %w", err) + return nil, fmt.Errorf("the hand act was not recorded, so nothing was asked again: %w", err) } if _, err := st.Put(AgainKey(owner, state), a); err != nil { return nil, fmt.Errorf("the hand ask is not written: %w", err) diff --git a/go/views/views_test.go b/go/views/views_test.go index 880cea2..2aa94b5 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -13,7 +13,6 @@ import ( "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/handacts" ) // mapSource is a state held in memory, as a Source. @@ -252,10 +251,10 @@ func TestViews(t *testing.T) { t.Run("a module's reader answers Keys and Get from its pictures, refuses a state it does not follow or has no first view of, and writes and clears its finding", func(t *testing.T) { acted := 0 fs := &findingsMap{} - r := &Reader{Module: "reader", Findings: fs, Bound: time.Nanosecond, Wait: time.Millisecond, Acts: handacts.Recorder{Ask: func(string, any) (json.RawMessage, error) { + r := &Reader{Module: "reader", Findings: fs, Bound: time.Nanosecond, Wait: time.Millisecond, Acts: actsFunc(func(HandAct) error { acted++ - return json.RawMessage(`{"log":"mesh-hand-acts"}`), nil - }}} + return nil + })} f := r.Follow("o", "modules", "o.indexer_read_modules") down := 2 f.Ask = func(ctx context.Context, after string) (Answer, error) { @@ -354,22 +353,13 @@ func TestViews(t *testing.T) { } _ = w.Stop() }) - t.Run("a reader's again tool records its hand act through mesh-hand-acts before it writes the ask, and asks nothing when the seat does not take it", func(t *testing.T) { - var recorded []handacts.Request + t.Run("a reader's again tool records its hand act before it writes the ask, and asks nothing when the act is not taken", func(t *testing.T) { + var recorded []HandAct take := errors.New("no responders") - acts := handacts.Recorder{Ask: func(key string, body any) (json.RawMessage, error) { - if key != "seat:mesh-hand-acts.hand-act" { - return nil, fmt.Errorf("asked %s", key) - } - raw, _ := json.Marshal(body) - var q handacts.Request - _ = json.Unmarshal(raw, &q) + acts := actsFunc(func(q HandAct) error { recorded = append(recorded, q) - if take != nil { - return nil, take - } - return json.RawMessage(`{"log":"mesh-hand-acts"}`), nil - }} + return take + }) again := StatusTools("reader", "reader_first_views", "reader_first_view_again", stdio.State(StatusState), acts, now)[1] args := map[string]any{"owner": "o", "state": "modules", "why": "doubted"} if _, err := again.Run(args); err == nil || !strings.Contains(err.Error(), "nothing was asked again") || len(recorded) != 1 { @@ -450,15 +440,16 @@ func TestViews(t *testing.T) { t.Errorf("%d failures: %d findings written, %d cleared, %d left, said %v", c.fails, fs.puts, fs.deletes, len(fs.m), said) } if c.puts > 0 { - key, value, _ := UnsaidFinding("o", "s", "a", 1, made, "bus away", clock) - if !strings.Contains(key, UnsaidKind) || !strings.Contains(key, "o.s") || !strings.Contains(string(value), "o has not said the change of s.a at 1") { - t.Errorf("the finding is %s: %s", key, value) + f := UnsaidFinding("o", "s", "a", 1, made, "bus away", clock) + if f.Kind != UnsaidKind || f.ID != "o.s" || f.Observer != "o" || !strings.Contains(f.Summary, "o has not said the change of s.a at 1") { + t.Errorf("the finding is %+v", f) } } } // A finding a run before a restart raised is cleared by the first change said after it. - key, value, _ := UnsaidFinding("o", "s", "a", 1, made, "bus away", made) - fs := &findingsMap{m: map[string][]byte{key: value}} + left0 := UnsaidFinding("o", "s", "a", 1, made, "bus away", made) + key := left0.Kind + "/" + left0.ID + fs := &findingsMap{m: map[string]Finding{key: left0}} said, fails = nil, 0 em.Findings = fs updates = make(chan jetstream.KeyValueEntry, 1) @@ -484,22 +475,27 @@ func TestViews(t *testing.T) { }) } -// findingsMap is a module's own findings state in memory. +// findingsMap is a module's own findings state in memory, keyed `/`. type findingsMap struct { - m map[string][]byte + m map[string]Finding puts, deletes int } -func (f *findingsMap) Put(k string, v []byte) error { +func (f *findingsMap) Put(v Finding) error { if f.m == nil { - f.m = map[string][]byte{} + f.m = map[string]Finding{} } - f.m[k] = v + f.m[v.Kind+"/"+v.ID] = v f.puts++ return nil } -func (f *findingsMap) Delete(k string) error { delete(f.m, k); f.deletes++; return nil } +func (f *findingsMap) Delete(v Finding) error { delete(f.m, v.Kind+"/"+v.ID); f.deletes++; return nil } + +// actsFunc records a hand act through a test's function. +type actsFunc func(HandAct) error + +func (f actsFunc) RecordHandAct(a HandAct) error { return f(a) } // kvEntry is one change of a bucket as a watch hands it. type kvEntry struct { -- 2.54.0 From b7ad47ab2a60dd7bdc3938162bd3162e6f8288b1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:45:30 +0100 Subject: [PATCH 094/144] =?UTF-8?q?Seat=20list=20reader:=20an=20integratio?= =?UTF-8?q?n=20case=20over=20a=20real=20bus,=20from=20the=20bootstrap=20al?= =?UTF-8?q?one=20to=20the=20seat's=20answer=20and=20its=20events=20(ADR=20?= =?UTF-8?q?0636=20=C2=A78;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatlist/seatlist_integration_test.go | 107 +++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 go/seatlist/seatlist_integration_test.go diff --git a/go/seatlist/seatlist_integration_test.go b/go/seatlist/seatlist_integration_test.go new file mode 100644 index 0000000..ed2841a --- /dev/null +++ b/go/seatlist/seatlist_integration_test.go @@ -0,0 +1,107 @@ +//go:build integration + +package seatlist_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +// natsBus is a reader's bus over a real NATS connection: a seat verb is a request on the seat's tool subject, a seat's +// event a subscription on its event subject. +type natsBus struct{ c *nats.Conn } + +func (b natsBus) Ask(key string, body any) (json.RawMessage, error) { + seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + raw, _ := json.Marshal(body) + m, err := b.c.Request("mesh.seat."+seat+".tool."+verb, raw, time.Second) + if err != nil { + return nil, err + } + return m.Data, nil +} + +func (b natsBus) Subscribe(pattern string, handle func(string, json.RawMessage) error) error { + seat, event, _ := strings.Cut(pattern, ".") + _, err := b.c.Subscribe(seatdef.EventSubject(seat, event), func(m *nats.Msg) { + _ = handle(seat+"."+event, m.Data) + }) + return err +} + +// ADR 0636 §8: a reader started with only the bootstrapped definitions, and the seat not yet serving, acts on the bootstrapped +// modules alone and holds nothing new; when the seat answers it reads the whole list, and it follows the seat's changed and +// removed events, over a real bus. +func TestAReaderStartsFromTheBootstrapAndReadsTheSeatOnceItAnswers(t *testing.T) { + c, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer c.Close() + bus := natsBus{c} + changed := make(chan string, 8) + r, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(), + OnChange: func([]catalogue.Seat) { changed <- "changed" }}) + if err != nil { + t.Fatal(err) + } + if st := r.Status(); st.Heard || st.LastError == "" || !r.MayAct("node-engine") || r.MayAct("node-files") { + t.Fatalf("with the seat silent: %+v, may act on node-files: %v", st, r.MayAct("node-files")) + } + + table := map[string]string{"node-files": `{"format":1,"seat":"node-files","scope":"node","grain":"seat-list","decision":"ADR 1",` + + `"verbs":[{"name":"read","input":{"type":"object"}}]}`} + serve := func(m *nats.Msg) { + var q struct { + Seat string `json:"seat"` + } + _ = json.Unmarshal(m.Data, &q) + if q.Seat == "" { + out, _ := json.Marshal(seatlist.Listing{Revision: 3, Seats: []seatlist.Entry{{Seat: "node-files", Source: seatlist.SourceTrunk, Digest: "d1"}}}) + _ = m.Respond(out) + return + } + out, _ := json.Marshal(seatlist.Answer{Seat: q.Seat, Found: true, Revision: 3, + Entry: &seatlist.Entry{Seat: q.Seat, Source: seatlist.SourceTrunk, Digest: "d1", Definition: json.RawMessage(table[q.Seat])}}) + _ = m.Respond(out) + } + if _, err := c.Subscribe("mesh.seat."+seatlist.Seat+".tool."+seatlist.Verb, serve); err != nil { + t.Fatal(err) + } + // The seat says a definition changed: the silent reader reads the list at the event, not on a timer. + if err := c.Publish(seatdef.EventSubject(seatlist.Seat, seatlist.ChangedEvent), []byte(`{"seat":"node-files","source":"trunk","digest":"d1"}`)); err != nil { + t.Fatal(err) + } + until(t, changed, func() bool { return r.Has("node-files") }) + if err := r.Reload(); err != nil { + t.Fatal(err) + } + if st := r.Status(); !st.Heard || st.LastError != "" || !r.MayAct("node-files") || st.Revision != 3 { + t.Fatalf("after the seat answered: %+v", st) + } + if err := c.Publish(seatdef.EventSubject(seatlist.Seat, seatlist.RemovedEvent), []byte(`{"seat":"node-files"}`)); err != nil { + t.Fatal(err) + } + until(t, changed, func() bool { return !r.Has("node-files") }) +} + +// until waits for the reader's own notice of a change (OnChange), never a sleep, until ok holds. +func until(t *testing.T, changed <-chan string, ok func() bool) { + t.Helper() + for !ok() { + select { + case <-changed: + case <-time.After(5 * time.Second): + t.Fatal("the reader did not follow the seat's event") + } + } +} -- 2.54.0 From 8dcdc9b2a42c6b6bc5307af404066e75b73970be Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:46:01 +0100 Subject: [PATCH 095/144] =?UTF-8?q?go/motion:=20whether=20a=20module=20is?= =?UTF-8?q?=20being=20delivered=20on=20a=20node,=20over=20plain=20want=20a?= =?UTF-8?q?nd=20have=20values;=20read=20by=20the=20delivery=20view=20and?= =?UTF-8?q?=20the=20condition=20keeper=20(S-X1,=20ADR=200634=20=C2=A71;=20?= =?UTF-8?q?issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/motion/motion.go | 107 +++++++++++++++++++++++++++++++++++++++ go/motion/motion_test.go | 57 +++++++++++++++++++++ go/packages_list_test.go | 2 +- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 go/motion/motion.go create mode 100644 go/motion/motion_test.go diff --git a/go/motion/motion.go b/go/motion/motion.go new file mode 100644 index 0000000..22f22b8 --- /dev/null +++ b/go/motion/motion.go @@ -0,0 +1,107 @@ +// Package motion derives whether a module is being delivered on a node, from its want and its have (novox/hq ADR 0564 clause +// 13 and Limits 12, ADR 0540 §7; issue 1952 S7). The delivery view (Delivery) says it `moving`, `settled` or `stuck`, and +// the condition keeper (Health and repair) holds a module's own findings while it is delivered; two domains read the one +// derivation, so it is mesh-sdk's (ADR 0634 §1), over plain values: the caller fills Want and Have from the assigner's +// want and the node-engine's have as their contracts lay them out, so this package names no seat and imports no contract. +package motion + +import "time" + +// **Whether a module is being delivered on a node, read from its want and its have** (novox/hq ADR 0564 clause 13 and +// Limits 12, ADR 0540 §7; issue 1952 S7): one derivation, used by the delivery view, which says a module `moving`, +// `settled` or `stuck` (the messenger's hold hears those), and by the condition keeper, which holds a module's own +// findings while it is delivered there and raises what still stands when the delivery ends. Before, it lived in the view +// alone (mesh-delivery-view internal/view motionOf), and a second reader would have judged a delivery its own way. + +// Want is what the derivation reads of a module's want on a node: its fingerprint and composition, and whether it is removed. +type Want struct { + FP string + Composed string + Removed bool +} + +// Have is what it reads of the module's have on that node: the fingerprint and composition it runs, its phase and health +// (the node-engine's words) and the have's own detail. +type Have struct { + FP string + Composed string + Phase string + Health string + Detail string +} + +// The phases and healths of a have the derivation tells apart, as the node-engine writes them. +const ( + PhaseApplying = "applying" + PhaseRunning = "running" + PhaseFailed = "failed" + PhaseHeld = "held" + HealthOK = "ok" + HealthFailed = "failed" +) + +// The motion of a module on a node, as the view says it. +const ( + MotionMoving = "moving" + MotionSettled = "settled" + MotionStuck = "stuck" +) + +// The outcomes of a motion that settled or stuck. +const ( + // OutcomeRunning is a settled module: the have reached the want, running and healthy. + OutcomeRunning = "running" + // OutcomeFailed is a have at the wanted fingerprint whose apply or health check failed. + OutcomeFailed = "failed" + // OutcomeStopped is a module held, or a want the watchman says stopped moving past its bound. + OutcomeStopped = "stopped" +) + +// DefaultDeliveryBound is how long a module's delivery holds its own findings when its manifest names no bound (ADR 0564 +// clause 13 (d)): past it, what is held is raised, saying the delivery did not end. +const DefaultDeliveryBound = 15 * time.Minute + +// Motion is where one module's want stands on one node. +type Motion struct { + // State is MotionMoving, MotionSettled or MotionStuck; empty when there is no want to move to (none, removed, or + // naming no fingerprint). + State string + // Outcome and Words are a settled or stuck motion's: running; failed with the have's words; stopped with the have's + // words (held) or the watchman's (stopped moving). + Outcome string + Words string + // Delivering says the module is being delivered there (clause 13 (a)): its want names a fingerprint other than its + // have's, or its have's phase is applying. + Delivering bool + // Failed says the delivery ended failed (Limits 12): the node refused or failed its apply (the have's phase failed, + // at whichever fingerprint it kept), or the have at the wanted fingerprint is unhealthy. What a hold kept is raised + // at once. Apart from State, which says failed only at the wanted fingerprint, as the view always has. + Failed bool +} + +// Ended says the delivery ended, failed or settled healthy at the want: what a hold kept is judged now. +func (m Motion) Ended() bool { return m.Failed || (m.State == MotionSettled && !m.Delivering) } + +// MotionOf is the motion of a module on a node from its want (wanted: one was read), its have (has: one was read) and +// the watchman's words when it says the want stopped moving (stuck: it says so and the want has not moved since). +func MotionOf(w Want, wanted bool, h Have, has bool, stuckWords string, stuck bool) Motion { + if !wanted || w.Removed || w.FP == "" { + return Motion{} + } + m := Motion{Delivering: !has || h.FP != w.FP || h.Phase == PhaseApplying, + Failed: has && (h.Phase == PhaseFailed || (h.FP == w.FP && h.Health == HealthFailed))} + applied := has && h.FP == w.FP && (h.Composed == "" || h.Composed == w.Composed) + switch { + case applied && h.Phase == PhaseRunning && h.Health == HealthOK: + m.State, m.Outcome = MotionSettled, OutcomeRunning + case has && h.Phase == PhaseHeld: + m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, h.Detail + case has && h.FP == w.FP && (h.Health == HealthFailed || h.Phase == PhaseFailed): + m.State, m.Outcome, m.Words = MotionStuck, OutcomeFailed, h.Detail + case stuck: + m.State, m.Outcome, m.Words = MotionStuck, OutcomeStopped, stuckWords + default: + m.State = MotionMoving + } + return m +} diff --git a/go/motion/motion_test.go b/go/motion/motion_test.go new file mode 100644 index 0000000..42b4d1d --- /dev/null +++ b/go/motion/motion_test.go @@ -0,0 +1,57 @@ +package motion + +import "testing" + +// 1952 S7 (ADR 0564 clause 13 (a), (c) and Limits 12; ADR 0540 §7): one derivation of whether a module is being +// delivered on a node, read by the view (moving, settled, stuck, as its motionOf said them) and by the condition keeper +// (delivering, failed, ended). Wants and haves as the assigner and the node-engine write them (a refusal is written as phase failed at the prior fingerprint). +func TestMotionOf(t *testing.T) { + want := Want{FP: "fp-new", Composed: "sha256:new"} + at := func(fp, composed string, phase, health string, detail string) Have { + return Have{FP: fp, Composed: composed, Phase: phase, Health: health, Detail: detail} + } + for _, c := range []struct { + name string + w Want + wanted bool + h Have + has bool + stuck string + state string + outcome string + delivering bool + failed bool + ended bool + }{ + {name: "no want", h: at("fp-old", "", PhaseRunning, HealthOK, ""), has: true}, + {name: "a removed want", w: Want{Removed: true}, wanted: true}, + {name: "a want no node has yet", w: want, wanted: true, state: MotionMoving, delivering: true}, + {name: "the old fingerprint still runs", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true, + state: MotionMoving, delivering: true}, + {name: "applying the wanted fingerprint", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseApplying, "unknown", ""), has: true, + state: MotionMoving, delivering: true}, + {name: "checking at the want: no longer delivering, not yet settled", w: want, wanted: true, + h: at("fp-new", "sha256:new", "checking", "unknown", ""), has: true, state: MotionMoving}, + {name: "running healthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthOK, ""), has: true, + state: MotionSettled, outcome: OutcomeRunning, ended: true}, + {name: "running healthy at another composition", w: want, wanted: true, h: at("fp-new", "sha256:other", PhaseRunning, HealthOK, ""), has: true, + state: MotionMoving}, + {name: "unhealthy at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseRunning, HealthFailed, "exit 1"), has: true, + state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true}, + {name: "its apply failed at the want", w: want, wanted: true, h: at("fp-new", "sha256:new", PhaseFailed, "unknown", "no image"), has: true, + state: MotionStuck, outcome: OutcomeFailed, failed: true, ended: true}, + {name: "refused, the prior fingerprint kept", w: want, wanted: true, + h: at("fp-old", "sha256:old", PhaseFailed, HealthOK, "not composed: no grant"), has: true, + state: MotionMoving, delivering: true, failed: true, ended: true}, + {name: "held on the node", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseHeld, HealthOK, "held by a person"), has: true, + state: MotionStuck, outcome: OutcomeStopped, delivering: true}, + {name: "the watchman says it stopped moving", w: want, wanted: true, h: at("fp-old", "sha256:old", PhaseRunning, HealthOK, ""), has: true, + stuck: "ace to apply fp-new", state: MotionStuck, outcome: OutcomeStopped, delivering: true}, + } { + m := MotionOf(c.w, c.wanted, c.h, c.has, c.stuck, c.stuck != "") + if m.State != c.state || m.Outcome != c.outcome || m.Delivering != c.delivering || m.Failed != c.failed || m.Ended() != c.ended { + t.Errorf("%s: %+v (ended %v), want state %q outcome %q delivering %v failed %v ended %v", c.name, m, m.Ended(), + c.state, c.outcome, c.delivering, c.failed, c.ended) + } + } +} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 66df9f7..c160b28 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -24,7 +24,7 @@ import ( // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", - "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", + "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } -- 2.54.0 From a0480588ce9884a5c158811d48e2f11b6bd880e7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:10:45 +0100 Subject: [PATCH 096/144] =?UTF-8?q?seatdef:=20a=20definition=20names=20its?= =?UTF-8?q?=20glossary=20domain,=20one=20of=20the=20eleven=20headings=20(A?= =?UTF-8?q?DR=200636=20=C2=A75;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/seatdef/seatdef.go | 14 ++++++++ go/seatdef/seatdef_test.go | 32 ++++++++++--------- go/seatlist/bootstrap/mesh-broker.json | 1 + go/seatlist/bootstrap/mesh-catalog.json | 1 + go/seatlist/bootstrap/mesh-conditions.json | 1 + go/seatlist/bootstrap/mesh-config.json | 1 + .../bootstrap/mesh-delivery-checker.json | 1 + go/seatlist/bootstrap/mesh-hand-acts.json | 1 + go/seatlist/bootstrap/mesh-watchdogs.json | 1 + go/seatlist/bootstrap/node-backup.json | 1 + go/seatlist/bootstrap/node-engine.json | 1 + go/seatlist/bootstrap/operator-channel.json | 1 + go/seatlist/seatlist_integration_test.go | 2 +- go/seatlist/seatlist_test.go | 2 +- .../seats/container-image-registry.json | 1 + go/seatlist/seatlisttest/seats/git.json | 1 + .../seats/go-package-registry.json | 1 + .../seats/mesh-artifact-store.json | 1 + .../seats/mesh-artifact-sweep.json | 1 + .../seatlisttest/seats/mesh-broker.json | 1 + .../seatlisttest/seats/mesh-catalog.json | 1 + .../seatlisttest/seats/mesh-conditions.json | 1 + .../seatlisttest/seats/mesh-config.json | 1 + .../seats/mesh-delivery-checker.json | 1 + .../seatlisttest/seats/mesh-delivery.json | 1 + .../seatlisttest/seats/mesh-dns-resolver.json | 1 + .../seatlisttest/seats/mesh-enrolment.json | 1 + .../seatlisttest/seats/mesh-hand-acts.json | 1 + .../seatlisttest/seats/mesh-healers.json | 1 + .../seatlisttest/seats/mesh-placement.json | 1 + .../seatlisttest/seats/mesh-provisioner.json | 1 + .../seatlisttest/seats/mesh-self-check.json | 1 + .../seatlisttest/seats/mesh-store.json | 1 + .../seatlisttest/seats/mesh-vault.json | 1 + .../seatlisttest/seats/mesh-wants.json | 1 + .../seatlisttest/seats/mesh-watchdogs.json | 1 + .../seatlisttest/seats/node-backup.json | 1 + go/seatlist/seatlisttest/seats/node-bar.json | 1 + .../seatlisttest/seats/node-build-go.json | 1 + .../seatlisttest/seats/node-build-image.json | 1 + .../seatlisttest/seats/node-build-node.json | 1 + .../seatlisttest/seats/node-clipboard.json | 1 + .../seatlisttest/seats/node-compositor.json | 1 + .../seats/node-container-runtime.json | 1 + .../seats/node-display-server.json | 1 + .../seats/node-display-session.json | 1 + .../seatlisttest/seats/node-engine.json | 1 + .../seatlisttest/seats/node-environment.json | 1 + .../seatlisttest/seats/node-files.json | 1 + .../seatlisttest/seats/node-hostname.json | 1 + .../seatlisttest/seats/node-hotkeys.json | 1 + .../seats/node-intrusion-prevention.json | 1 + .../seatlisttest/seats/node-launcher.json | 1 + .../seatlisttest/seats/node-lock-screen.json | 1 + .../seats/node-login-manager.json | 1 + .../seatlisttest/seats/node-login-shell.json | 1 + .../seatlisttest/seats/node-message-bus.json | 1 + .../seatlisttest/seats/node-mounts.json | 1 + .../seatlisttest/seats/node-nfs-server.json | 1 + .../seatlisttest/seats/node-notifier.json | 1 + .../seats/node-package-manager.json | 1 + .../seats/node-packet-filter.json | 1 + .../seatlisttest/seats/node-power.json | 1 + .../seatlisttest/seats/node-provisioner.json | 1 + .../seatlisttest/seats/node-reads.json | 1 + .../seatlisttest/seats/node-resolver.json | 1 + .../seats/node-secret-service.json | 1 + .../seats/node-service-manager.json | 1 + .../seats/node-terminal-emulator.json | 1 + .../seatlisttest/seats/node-uplink.json | 1 + .../seats/npm-package-registry.json | 1 + .../seats/oci-artifact-registry.json | 1 + .../seatlisttest/seats/operator-channel.json | 1 + .../seats/the-private-network.json | 1 + 74 files changed, 103 insertions(+), 17 deletions(-) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 37626d4..1e4fc41 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -17,6 +17,7 @@ import ( "errors" "fmt" "regexp" + "slices" "sort" "strings" ) @@ -41,6 +42,10 @@ const ( ScopeNode = "node" ) +// Domains are the glossary's domains that own a seat, by their headings (hq 00-META/glossary.md, ADR 0244). +var Domains = []string{"Module", "Config", "Core", "Placement", "Provisioning", "Identity and access", "Delivery", + "Health and repair", "Data", "Connectivity", "Operator and conversation"} + // Definition is one seat's contract. type Definition struct { Format int `json:"format"` @@ -49,6 +54,9 @@ type Definition struct { Grain string `json:"grain"` // Decision is the record that made it a seat. Decision string `json:"decision"` + // Domain is the glossary domain the seat belongs to, by its heading (ADR 0636 §5): the indexer takes a seat of the + // mesh's own contracts only from that domain's contracts module. + Domain string `json:"domain"` // Description says the role in one paragraph, for a person reading the definition. Description string `json:"description,omitempty"` // HolderDeclares says the seat is not in the mesh's compiled seat list: its holder's manifest declares it as a @@ -398,6 +406,12 @@ func (d Definition) check() Problems { if d.Decision == "" { ps = append(ps, Problem{"decision", "missing"}) } + switch { + case d.Domain == "": + ps = append(ps, Problem{"domain", "missing"}) + case !slices.Contains(Domains, d.Domain): + ps = append(ps, Problem{"domain", fmt.Sprintf("%q is not a domain of the glossary (%s)", d.Domain, strings.Join(Domains, ", "))}) + } whole := d.Grain == Whole if !whole && (len(d.Includes) > 0 || d.Family != nil || len(d.HolderEvents) > 0 || len(d.States) > 0 || len(d.Provisions) > 0 || len(d.Defs) > 0) { diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index 43881f4..e9a9cad 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -9,9 +9,9 @@ import ( // TestADefinitionIsReadStrictlyNamingWhatIsWrong is the format's own table (design 26: a valid definition parses; // each missing required field is refused naming it; one over 256 KiB is refused), with the parts and fixtures beside. func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { - const listGrain = `{"format":1,"seat":"node-hostname","scope":"node","grain":"seat-list","decision":"ADR 1", + const listGrain = `{"format":1,"seat":"node-hostname","scope":"node","grain":"seat-list","domain":"Core","decision":"ADR 1", "verbs":[{"name":"add","input":{"type":"object"}}]}` - const whole = `{"format":1,"seat":"mesh-broker","scope":"mesh","grain":"whole","decision":"ADR 1", + const whole = `{"format":1,"seat":"mesh-broker","scope":"mesh","grain":"whole","domain":"Core","decision":"ADR 1", "verbs":[{"name":"list","input":{"type":"object"},"answer":{"type":"object"}}], "events":[{"name":"created","body":{"type":"object"}}],"family":{"name":"broker.events","max-age-days":7}}` cases := []struct { @@ -20,14 +20,16 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { }{ {"the seat list's grain parses", listGrain, ""}, {"a whole definition parses", whole, ""}, - {"no format", `{"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x"}`, "format: missing"}, - {"a later format", `{"format":2,"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x"}`, "format 2"}, - {"no seat", `{"format":1,"scope":"mesh","grain":"seat-list","decision":"x"}`, "seat: missing"}, - {"no scope", `{"format":1,"seat":"a-b","grain":"seat-list","decision":"x"}`, "scope: missing"}, - {"another scope", `{"format":1,"seat":"a-b","scope":"room","grain":"seat-list","decision":"x"}`, "scope"}, - {"no grain", `{"format":1,"seat":"a-b","scope":"mesh","decision":"x"}`, "grain: missing"}, + {"no format", `{"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Core","decision":"x"}`, "format: missing"}, + {"a later format", `{"format":2,"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Core","decision":"x"}`, "format 2"}, + {"no seat", `{"format":1,"scope":"mesh","grain":"seat-list","domain":"Core","decision":"x"}`, "seat: missing"}, + {"no scope", `{"format":1,"seat":"a-b","grain":"seat-list","domain":"Core","decision":"x"}`, "scope: missing"}, + {"another scope", `{"format":1,"seat":"a-b","scope":"room","grain":"seat-list","domain":"Core","decision":"x"}`, "scope"}, + {"no grain", `{"format":1,"seat":"a-b","scope":"mesh","domain":"Core","decision":"x"}`, "grain: missing"}, {"no decision", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list"}`, "decision: missing"}, - {"an unknown field", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x","sevres":[]}`, + {"no domain", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","decision":"x"}`, "domain: missing"}, + {"a domain the glossary lacks", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Weather","decision":"x"}`, "domain"}, + {"an unknown field", `{"format":1,"seat":"a-b","scope":"mesh","grain":"seat-list","domain":"Core","decision":"x","sevres":[]}`, "sevres: a field the format does not know"}, {"a whole verb with no answer", strings.Replace(whole, `,"answer":{"type":"object"}`, "", 1), "verbs[0].answer: missing"}, @@ -50,10 +52,10 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { {"a holder's event on a subject of no module", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ `"subject":"mesh.event.verdict"}],"family":`, 1), "holder-events[0].subject: not mesh.mod..event.verdict"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, - {"states at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, - `"decision":"ADR 1","states":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, - {"the old records field is gone", strings.Replace(whole, `"decision":"ADR 1",`, - `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "records: a field the format does not know"}, + {"states at the seat list's grain", strings.Replace(listGrain, `"domain":"Core","decision":"ADR 1",`, + `"domain":"Core","decision":"ADR 1","states":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, + {"the old records field is gone", strings.Replace(whole, `"domain":"Core","decision":"ADR 1",`, + `"domain":"Core","decision":"ADR 1","records":[{"name":"asks"}],`, 1), "records: a field the format does not know"}, {"a state with a key form and a value", withState(whole, `{"name":"asks","template":"state","writer":"caller", "history":3,"key-forms":[{"key":".","value":{"type":"object"}}]}`), ""}, {"a stream state with an age", withState(whole, `{"name":"placement","template":"events","max-age-days":7, @@ -98,7 +100,7 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { if err != nil { t.Fatal(err) } - d, err := Parse([]byte(strings.Replace(whole, `"decision":"ADR 1",`, `"decision":"ADR 1","includes":["registry"],`, 1))) + d, err := Parse([]byte(strings.Replace(whole, `"domain":"Core","decision":"ADR 1",`, `"domain":"Core","decision":"ADR 1","includes":["registry"],`, 1))) if err != nil { t.Fatal(err) } @@ -145,5 +147,5 @@ func withState(def, state string) string { if strings.Contains(def, `"states":[`) { return strings.Replace(def, `"states":[`, `"states":[`+state+`,`, 1) } - return strings.Replace(def, `"decision":"ADR 1",`, `"decision":"ADR 1","states":[`+state+`],`, 1) + return strings.Replace(def, `"domain":"Core","decision":"ADR 1",`, `"domain":"Core","decision":"ADR 1","states":[`+state+`],`, 1) } diff --git a/go/seatlist/bootstrap/mesh-broker.json b/go/seatlist/bootstrap/mesh-broker.json index f0ebf4a..6fc5d4e 100644 --- a/go/seatlist/bootstrap/mesh-broker.json +++ b/go/seatlist/bootstrap/mesh-broker.json @@ -3,6 +3,7 @@ "seat": "mesh-broker", "scope": "mesh", "grain": "whole", + "domain": "Core", "decision": "novox/hq ADR 0079", "delivers": "mesh-bus", "description": "The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone.", diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index f44644f..e78353a 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -3,6 +3,7 @@ "seat": "mesh-catalog", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0636", "verbs": [ { diff --git a/go/seatlist/bootstrap/mesh-conditions.json b/go/seatlist/bootstrap/mesh-conditions.json index ee5b697..76f464c 100644 --- a/go/seatlist/bootstrap/mesh-conditions.json +++ b/go/seatlist/bootstrap/mesh-conditions.json @@ -3,6 +3,7 @@ "seat": "mesh-conditions", "scope": "mesh", "grain": "whole", + "domain": "Health and repair", "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", "description": "What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.", "verbs": [ diff --git a/go/seatlist/bootstrap/mesh-config.json b/go/seatlist/bootstrap/mesh-config.json index 640ccbb..83c63f3 100644 --- a/go/seatlist/bootstrap/mesh-config.json +++ b/go/seatlist/bootstrap/mesh-config.json @@ -3,6 +3,7 @@ "seat": "mesh-config", "scope": "mesh", "grain": "whole", + "domain": "Config", "decision": "novox/hq ADR 0348, ADR 0565", "verbs": [ { diff --git a/go/seatlist/bootstrap/mesh-delivery-checker.json b/go/seatlist/bootstrap/mesh-delivery-checker.json index 7b56ab5..808b3e9 100644 --- a/go/seatlist/bootstrap/mesh-delivery-checker.json +++ b/go/seatlist/bootstrap/mesh-delivery-checker.json @@ -3,6 +3,7 @@ "seat": "mesh-delivery-checker", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0547", "verbs": [ { diff --git a/go/seatlist/bootstrap/mesh-hand-acts.json b/go/seatlist/bootstrap/mesh-hand-acts.json index e635d7a..a075cfc 100644 --- a/go/seatlist/bootstrap/mesh-hand-acts.json +++ b/go/seatlist/bootstrap/mesh-hand-acts.json @@ -3,6 +3,7 @@ "seat": "mesh-hand-acts", "scope": "mesh", "grain": "whole", + "domain": "Health and repair", "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", "description": "Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.", "verbs": [ diff --git a/go/seatlist/bootstrap/mesh-watchdogs.json b/go/seatlist/bootstrap/mesh-watchdogs.json index ecf09a3..35a4341 100644 --- a/go/seatlist/bootstrap/mesh-watchdogs.json +++ b/go/seatlist/bootstrap/mesh-watchdogs.json @@ -3,6 +3,7 @@ "seat": "mesh-watchdogs", "scope": "mesh", "grain": "whole", + "domain": "Health and repair", "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", "description": "The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters.", "verbs": [ diff --git a/go/seatlist/bootstrap/node-backup.json b/go/seatlist/bootstrap/node-backup.json index 465f68f..f5c59d2 100644 --- a/go/seatlist/bootstrap/node-backup.json +++ b/go/seatlist/bootstrap/node-backup.json @@ -3,6 +3,7 @@ "seat": "node-backup", "scope": "node", "grain": "seat-list", + "domain": "Data", "decision": "novox/hq ADR 0214", "verbs": [ { diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json index 0987936..20ed8a1 100644 --- a/go/seatlist/bootstrap/node-engine.json +++ b/go/seatlist/bootstrap/node-engine.json @@ -3,6 +3,7 @@ "seat": "node-engine", "scope": "node", "grain": "whole", + "domain": "Core", "decision": "novox/hq ADR 0563", "description": "The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.., and each event is published on mesh.seat.node-engine.event.., the node's name its last token.", "held-by-the-engine": true, diff --git a/go/seatlist/bootstrap/operator-channel.json b/go/seatlist/bootstrap/operator-channel.json index 9770349..0bd8c21 100644 --- a/go/seatlist/bootstrap/operator-channel.json +++ b/go/seatlist/bootstrap/operator-channel.json @@ -3,6 +3,7 @@ "seat": "operator-channel", "scope": "mesh", "grain": "whole", + "domain": "Operator and conversation", "decision": "novox/hq ADR 0259", "holder-declares": true, "description": "Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).", diff --git a/go/seatlist/seatlist_integration_test.go b/go/seatlist/seatlist_integration_test.go index ed2841a..236fe7a 100644 --- a/go/seatlist/seatlist_integration_test.go +++ b/go/seatlist/seatlist_integration_test.go @@ -58,7 +58,7 @@ func TestAReaderStartsFromTheBootstrapAndReadsTheSeatOnceItAnswers(t *testing.T) t.Fatalf("with the seat silent: %+v, may act on node-files: %v", st, r.MayAct("node-files")) } - table := map[string]string{"node-files": `{"format":1,"seat":"node-files","scope":"node","grain":"seat-list","decision":"ADR 1",` + + table := map[string]string{"node-files": `{"format":1,"seat":"node-files","scope":"node","grain":"seat-list","domain": "Core", "decision": "ADR 1",` + `"verbs":[{"name":"read","input":{"type":"object"}}]}`} serve := func(m *nats.Msg) { var q struct { diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index f845b66..0f87205 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -57,7 +57,7 @@ func (f *fakeOwner) say(event, seat, source string) error { } func def(seat, scope, verb string) string { - return `{"format":1,"seat":"` + seat + `","scope":"` + scope + `","grain":"seat-list","decision":"ADR 1",` + + return `{"format":1,"seat":"` + seat + `","scope":"` + scope + `","grain":"seat-list","domain": "Core", "decision": "ADR 1",` + `"verbs":[{"name":"` + verb + `","input":{"type":"object"}}]}` } diff --git a/go/seatlist/seatlisttest/seats/container-image-registry.json b/go/seatlist/seatlisttest/seats/container-image-registry.json index b1a595f..3de19d8 100644 --- a/go/seatlist/seatlisttest/seats/container-image-registry.json +++ b/go/seatlist/seatlisttest/seats/container-image-registry.json @@ -3,6 +3,7 @@ "seat": "container-image-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0605", "description": "The mesh's container images, in the one artifact store: the OCI distribution protocol's read side, an image's tags and mirroring an outside image.", "includes": [ diff --git a/go/seatlist/seatlisttest/seats/git.json b/go/seatlist/seatlisttest/seats/git.json index 836285a..b7c55a1 100644 --- a/go/seatlist/seatlisttest/seats/git.json +++ b/go/seatlist/seatlisttest/seats/git.json @@ -3,6 +3,7 @@ "seat": "git", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0111", "delivers": "git" } diff --git a/go/seatlist/seatlisttest/seats/go-package-registry.json b/go/seatlist/seatlisttest/seats/go-package-registry.json index edf3998..9d42636 100644 --- a/go/seatlist/seatlisttest/seats/go-package-registry.json +++ b/go/seatlist/seatlisttest/seats/go-package-registry.json @@ -3,6 +3,7 @@ "seat": "go-package-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0605", "description": "The mesh's Go modules: Go's module proxy protocol's read side, and publishing over the bus.", "delivers": "go-package-registry", diff --git a/go/seatlist/seatlisttest/seats/mesh-artifact-store.json b/go/seatlist/seatlisttest/seats/mesh-artifact-store.json index 84c8a57..d82ab33 100644 --- a/go/seatlist/seatlisttest/seats/mesh-artifact-store.json +++ b/go/seatlist/seatlisttest/seats/mesh-artifact-store.json @@ -3,5 +3,6 @@ "seat": "mesh-artifact-store", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0075" } diff --git a/go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json b/go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json index 602a5f8..2e5b673 100644 --- a/go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json +++ b/go/seatlist/seatlisttest/seats/mesh-artifact-sweep.json @@ -3,6 +3,7 @@ "seat": "mesh-artifact-sweep", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0571", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-broker.json b/go/seatlist/seatlisttest/seats/mesh-broker.json index f0ebf4a..6fc5d4e 100644 --- a/go/seatlist/seatlisttest/seats/mesh-broker.json +++ b/go/seatlist/seatlisttest/seats/mesh-broker.json @@ -3,6 +3,7 @@ "seat": "mesh-broker", "scope": "mesh", "grain": "whole", + "domain": "Core", "decision": "novox/hq ADR 0079", "delivers": "mesh-bus", "description": "The mesh's bus, and the bus state modules ask of it in requires (design 25 § 12). create, apply, recreate and drop are granted to the bus module's own credential alone.", diff --git a/go/seatlist/seatlisttest/seats/mesh-catalog.json b/go/seatlist/seatlisttest/seats/mesh-catalog.json index 40b6618..5f71e23 100644 --- a/go/seatlist/seatlisttest/seats/mesh-catalog.json +++ b/go/seatlist/seatlisttest/seats/mesh-catalog.json @@ -3,6 +3,7 @@ "seat": "mesh-catalog", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0604", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-conditions.json b/go/seatlist/seatlisttest/seats/mesh-conditions.json index 5513eba..b48b098 100644 --- a/go/seatlist/seatlisttest/seats/mesh-conditions.json +++ b/go/seatlist/seatlisttest/seats/mesh-conditions.json @@ -3,6 +3,7 @@ "seat": "mesh-conditions", "scope": "mesh", "grain": "seat-list", + "domain": "Health and repair", "decision": "novox/hq ADR 0564", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-config.json b/go/seatlist/seatlisttest/seats/mesh-config.json index d095825..0a5e0e1 100644 --- a/go/seatlist/seatlisttest/seats/mesh-config.json +++ b/go/seatlist/seatlisttest/seats/mesh-config.json @@ -3,6 +3,7 @@ "seat": "mesh-config", "scope": "mesh", "grain": "whole", + "domain": "Config", "decision": "novox/hq ADR 0348, ADR 0565", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json b/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json index 7b56ab5..808b3e9 100644 --- a/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json +++ b/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json @@ -3,6 +3,7 @@ "seat": "mesh-delivery-checker", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0547", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-delivery.json b/go/seatlist/seatlisttest/seats/mesh-delivery.json index bafd85c..43ba193 100644 --- a/go/seatlist/seatlisttest/seats/mesh-delivery.json +++ b/go/seatlist/seatlisttest/seats/mesh-delivery.json @@ -3,6 +3,7 @@ "seat": "mesh-delivery", "scope": "mesh", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0239", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-dns-resolver.json b/go/seatlist/seatlisttest/seats/mesh-dns-resolver.json index 14480ac..0868271 100644 --- a/go/seatlist/seatlisttest/seats/mesh-dns-resolver.json +++ b/go/seatlist/seatlisttest/seats/mesh-dns-resolver.json @@ -3,6 +3,7 @@ "seat": "mesh-dns-resolver", "scope": "mesh", "grain": "seat-list", + "domain": "Connectivity", "decision": "novox/hq ADR 0194, ADR 0223", "delivers": "wildcard-resolution", "replicated": true diff --git a/go/seatlist/seatlisttest/seats/mesh-enrolment.json b/go/seatlist/seatlisttest/seats/mesh-enrolment.json index b611558..474653a 100644 --- a/go/seatlist/seatlisttest/seats/mesh-enrolment.json +++ b/go/seatlist/seatlisttest/seats/mesh-enrolment.json @@ -3,6 +3,7 @@ "seat": "mesh-enrolment", "scope": "mesh", "grain": "seat-list", + "domain": "Identity and access", "decision": "novox/hq ADR 0587", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-hand-acts.json b/go/seatlist/seatlisttest/seats/mesh-hand-acts.json index a789dbb..9d760ec 100644 --- a/go/seatlist/seatlisttest/seats/mesh-hand-acts.json +++ b/go/seatlist/seatlisttest/seats/mesh-hand-acts.json @@ -3,6 +3,7 @@ "seat": "mesh-hand-acts", "scope": "mesh", "grain": "seat-list", + "domain": "Health and repair", "decision": "novox/hq ADR 0565", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-healers.json b/go/seatlist/seatlisttest/seats/mesh-healers.json index 0f463da..5b67239 100644 --- a/go/seatlist/seatlisttest/seats/mesh-healers.json +++ b/go/seatlist/seatlisttest/seats/mesh-healers.json @@ -3,6 +3,7 @@ "seat": "mesh-healers", "scope": "mesh", "grain": "seat-list", + "domain": "Health and repair", "decision": "novox/hq ADR 0564", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-placement.json b/go/seatlist/seatlisttest/seats/mesh-placement.json index cc3d44c..c38d9f4 100644 --- a/go/seatlist/seatlisttest/seats/mesh-placement.json +++ b/go/seatlist/seatlisttest/seats/mesh-placement.json @@ -3,6 +3,7 @@ "seat": "mesh-placement", "scope": "mesh", "grain": "seat-list", + "domain": "Placement", "decision": "novox/hq ADR 0567", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-provisioner.json b/go/seatlist/seatlisttest/seats/mesh-provisioner.json index fe16d81..719758d 100644 --- a/go/seatlist/seatlisttest/seats/mesh-provisioner.json +++ b/go/seatlist/seatlisttest/seats/mesh-provisioner.json @@ -3,6 +3,7 @@ "seat": "mesh-provisioner", "scope": "mesh", "grain": "seat-list", + "domain": "Provisioning", "decision": "novox/hq ADR 0349", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-self-check.json b/go/seatlist/seatlisttest/seats/mesh-self-check.json index 56ba074..1029b9c 100644 --- a/go/seatlist/seatlisttest/seats/mesh-self-check.json +++ b/go/seatlist/seatlisttest/seats/mesh-self-check.json @@ -3,6 +3,7 @@ "seat": "mesh-self-check", "scope": "mesh", "grain": "seat-list", + "domain": "Health and repair", "decision": "novox/hq ADR 0564", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-store.json b/go/seatlist/seatlisttest/seats/mesh-store.json index f02517a..fe1c4dd 100644 --- a/go/seatlist/seatlisttest/seats/mesh-store.json +++ b/go/seatlist/seatlisttest/seats/mesh-store.json @@ -3,6 +3,7 @@ "seat": "mesh-store", "scope": "mesh", "grain": "seat-list", + "domain": "Module", "decision": "novox/hq ADR 0079", "delivers": "postgres-database", "verbs": [ diff --git a/go/seatlist/seatlisttest/seats/mesh-vault.json b/go/seatlist/seatlisttest/seats/mesh-vault.json index 5a115a6..e83f02a 100644 --- a/go/seatlist/seatlisttest/seats/mesh-vault.json +++ b/go/seatlist/seatlisttest/seats/mesh-vault.json @@ -3,6 +3,7 @@ "seat": "mesh-vault", "scope": "mesh", "grain": "whole", + "domain": "Identity and access", "decision": "novox/hq ADR 0161", "delivers": "secret", "verbs": [ diff --git a/go/seatlist/seatlisttest/seats/mesh-wants.json b/go/seatlist/seatlisttest/seats/mesh-wants.json index 8b50c1a..3de06f7 100644 --- a/go/seatlist/seatlisttest/seats/mesh-wants.json +++ b/go/seatlist/seatlisttest/seats/mesh-wants.json @@ -3,6 +3,7 @@ "seat": "mesh-wants", "scope": "mesh", "grain": "seat-list", + "domain": "Placement", "decision": "novox/hq ADR 0565", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/mesh-watchdogs.json b/go/seatlist/seatlisttest/seats/mesh-watchdogs.json index 3ad9f3c..5d5a076 100644 --- a/go/seatlist/seatlisttest/seats/mesh-watchdogs.json +++ b/go/seatlist/seatlisttest/seats/mesh-watchdogs.json @@ -3,6 +3,7 @@ "seat": "mesh-watchdogs", "scope": "mesh", "grain": "seat-list", + "domain": "Health and repair", "decision": "novox/hq ADR 0564", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-backup.json b/go/seatlist/seatlisttest/seats/node-backup.json index 465f68f..f5c59d2 100644 --- a/go/seatlist/seatlisttest/seats/node-backup.json +++ b/go/seatlist/seatlisttest/seats/node-backup.json @@ -3,6 +3,7 @@ "seat": "node-backup", "scope": "node", "grain": "seat-list", + "domain": "Data", "decision": "novox/hq ADR 0214", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-bar.json b/go/seatlist/seatlisttest/seats/node-bar.json index dc73191..92249d1 100644 --- a/go/seatlist/seatlisttest/seats/node-bar.json +++ b/go/seatlist/seatlisttest/seats/node-bar.json @@ -3,6 +3,7 @@ "seat": "node-bar", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "receives": [ { diff --git a/go/seatlist/seatlisttest/seats/node-build-go.json b/go/seatlist/seatlisttest/seats/node-build-go.json index 5beead8..ccb5664 100644 --- a/go/seatlist/seatlisttest/seats/node-build-go.json +++ b/go/seatlist/seatlisttest/seats/node-build-go.json @@ -3,6 +3,7 @@ "seat": "node-build-go", "scope": "node", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, diff --git a/go/seatlist/seatlisttest/seats/node-build-image.json b/go/seatlist/seatlisttest/seats/node-build-image.json index 96065eb..b275f85 100644 --- a/go/seatlist/seatlisttest/seats/node-build-image.json +++ b/go/seatlist/seatlisttest/seats/node-build-image.json @@ -3,6 +3,7 @@ "seat": "node-build-image", "scope": "node", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, diff --git a/go/seatlist/seatlisttest/seats/node-build-node.json b/go/seatlist/seatlisttest/seats/node-build-node.json index d2b8fb7..aa7f182 100644 --- a/go/seatlist/seatlisttest/seats/node-build-node.json +++ b/go/seatlist/seatlisttest/seats/node-build-node.json @@ -3,6 +3,7 @@ "seat": "node-build-node", "scope": "node", "grain": "seat-list", + "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, diff --git a/go/seatlist/seatlisttest/seats/node-clipboard.json b/go/seatlist/seatlisttest/seats/node-clipboard.json index 0f21e51..88dde07 100644 --- a/go/seatlist/seatlisttest/seats/node-clipboard.json +++ b/go/seatlist/seatlisttest/seats/node-clipboard.json @@ -3,6 +3,7 @@ "seat": "node-clipboard", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-compositor.json b/go/seatlist/seatlisttest/seats/node-compositor.json index 4ce95ee..f126432 100644 --- a/go/seatlist/seatlisttest/seats/node-compositor.json +++ b/go/seatlist/seatlisttest/seats/node-compositor.json @@ -3,5 +3,6 @@ "seat": "node-compositor", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208" } diff --git a/go/seatlist/seatlisttest/seats/node-container-runtime.json b/go/seatlist/seatlisttest/seats/node-container-runtime.json index 4a68219..1b1bf02 100644 --- a/go/seatlist/seatlisttest/seats/node-container-runtime.json +++ b/go/seatlist/seatlisttest/seats/node-container-runtime.json @@ -3,5 +3,6 @@ "seat": "node-container-runtime", "scope": "node", "grain": "seat-list", + "domain": "Placement", "decision": "novox/hq ADR 0166, ADR 0207" } diff --git a/go/seatlist/seatlisttest/seats/node-display-server.json b/go/seatlist/seatlisttest/seats/node-display-server.json index 8523e84..20c587d 100644 --- a/go/seatlist/seatlisttest/seats/node-display-server.json +++ b/go/seatlist/seatlisttest/seats/node-display-server.json @@ -3,6 +3,7 @@ "seat": "node-display-server", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-display-session.json b/go/seatlist/seatlisttest/seats/node-display-session.json index 6edac04..a815db1 100644 --- a/go/seatlist/seatlisttest/seats/node-display-session.json +++ b/go/seatlist/seatlisttest/seats/node-display-session.json @@ -3,6 +3,7 @@ "seat": "node-display-session", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-engine.json b/go/seatlist/seatlisttest/seats/node-engine.json index dfcaeb8..d09c320 100644 --- a/go/seatlist/seatlisttest/seats/node-engine.json +++ b/go/seatlist/seatlisttest/seats/node-engine.json @@ -3,6 +3,7 @@ "seat": "node-engine", "scope": "node", "grain": "whole", + "domain": "Core", "decision": "novox/hq ADR 0563", "description": "The node seat every node's engine holds (novox/hq ADR 0563, ADR 0618 §3, §4): its hand acts on the operator's warrant, its read-only judgements, its compositions and what it reads of a manifest. Each verb is answered on this node's subject, mesh.seat.node-engine.tool.., and each event is published on mesh.seat.node-engine.event.., the node's name its last token.", "held-by-the-engine": true, diff --git a/go/seatlist/seatlisttest/seats/node-environment.json b/go/seatlist/seatlisttest/seats/node-environment.json index 064beb0..75bb5d3 100644 --- a/go/seatlist/seatlisttest/seats/node-environment.json +++ b/go/seatlist/seatlisttest/seats/node-environment.json @@ -3,5 +3,6 @@ "seat": "node-environment", "scope": "node", "grain": "seat-list", + "domain": "Placement", "decision": "novox/hq ADR 0203" } diff --git a/go/seatlist/seatlisttest/seats/node-files.json b/go/seatlist/seatlisttest/seats/node-files.json index 7ad768f..6a43cf4 100644 --- a/go/seatlist/seatlisttest/seats/node-files.json +++ b/go/seatlist/seatlisttest/seats/node-files.json @@ -3,6 +3,7 @@ "seat": "node-files", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0394", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-hostname.json b/go/seatlist/seatlisttest/seats/node-hostname.json index 0f646af..327c4d5 100644 --- a/go/seatlist/seatlisttest/seats/node-hostname.json +++ b/go/seatlist/seatlisttest/seats/node-hostname.json @@ -3,6 +3,7 @@ "seat": "node-hostname", "scope": "node", "grain": "seat-list", + "domain": "Connectivity", "decision": "novox/hq ADR 0199, ADR 0223", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-hotkeys.json b/go/seatlist/seatlisttest/seats/node-hotkeys.json index cedd627..aec1269 100644 --- a/go/seatlist/seatlisttest/seats/node-hotkeys.json +++ b/go/seatlist/seatlisttest/seats/node-hotkeys.json @@ -3,6 +3,7 @@ "seat": "node-hotkeys", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0212", "receives": [ { diff --git a/go/seatlist/seatlisttest/seats/node-intrusion-prevention.json b/go/seatlist/seatlisttest/seats/node-intrusion-prevention.json index a987a70..b2bc355 100644 --- a/go/seatlist/seatlisttest/seats/node-intrusion-prevention.json +++ b/go/seatlist/seatlisttest/seats/node-intrusion-prevention.json @@ -3,6 +3,7 @@ "seat": "node-intrusion-prevention", "scope": "node", "grain": "seat-list", + "domain": "Connectivity", "decision": "novox/hq ADR 0121", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-launcher.json b/go/seatlist/seatlisttest/seats/node-launcher.json index 3eab2eb..977e537 100644 --- a/go/seatlist/seatlisttest/seats/node-launcher.json +++ b/go/seatlist/seatlisttest/seats/node-launcher.json @@ -3,6 +3,7 @@ "seat": "node-launcher", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-lock-screen.json b/go/seatlist/seatlisttest/seats/node-lock-screen.json index 4cd19ed..38e5340 100644 --- a/go/seatlist/seatlisttest/seats/node-lock-screen.json +++ b/go/seatlist/seatlisttest/seats/node-lock-screen.json @@ -3,6 +3,7 @@ "seat": "node-lock-screen", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-login-manager.json b/go/seatlist/seatlisttest/seats/node-login-manager.json index e0a70ba..2b3d4fa 100644 --- a/go/seatlist/seatlisttest/seats/node-login-manager.json +++ b/go/seatlist/seatlisttest/seats/node-login-manager.json @@ -3,6 +3,7 @@ "seat": "node-login-manager", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-login-shell.json b/go/seatlist/seatlisttest/seats/node-login-shell.json index c868e5b..9d6295a 100644 --- a/go/seatlist/seatlisttest/seats/node-login-shell.json +++ b/go/seatlist/seatlisttest/seats/node-login-shell.json @@ -3,6 +3,7 @@ "seat": "node-login-shell", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0204", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-message-bus.json b/go/seatlist/seatlisttest/seats/node-message-bus.json index 83c6744..4c19846 100644 --- a/go/seatlist/seatlisttest/seats/node-message-bus.json +++ b/go/seatlist/seatlisttest/seats/node-message-bus.json @@ -3,5 +3,6 @@ "seat": "node-message-bus", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0215" } diff --git a/go/seatlist/seatlisttest/seats/node-mounts.json b/go/seatlist/seatlisttest/seats/node-mounts.json index 43f1655..eadb518 100644 --- a/go/seatlist/seatlisttest/seats/node-mounts.json +++ b/go/seatlist/seatlisttest/seats/node-mounts.json @@ -3,6 +3,7 @@ "seat": "node-mounts", "scope": "node", "grain": "seat-list", + "domain": "Provisioning", "decision": "novox/hq ADR 0263", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-nfs-server.json b/go/seatlist/seatlisttest/seats/node-nfs-server.json index 4cedbde..664dd3d 100644 --- a/go/seatlist/seatlisttest/seats/node-nfs-server.json +++ b/go/seatlist/seatlisttest/seats/node-nfs-server.json @@ -3,6 +3,7 @@ "seat": "node-nfs-server", "scope": "node", "grain": "seat-list", + "domain": "Provisioning", "decision": "novox/hq ADR 0263", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-notifier.json b/go/seatlist/seatlisttest/seats/node-notifier.json index f391310..565d898 100644 --- a/go/seatlist/seatlisttest/seats/node-notifier.json +++ b/go/seatlist/seatlisttest/seats/node-notifier.json @@ -3,6 +3,7 @@ "seat": "node-notifier", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-package-manager.json b/go/seatlist/seatlisttest/seats/node-package-manager.json index c2b32b0..034e878 100644 --- a/go/seatlist/seatlisttest/seats/node-package-manager.json +++ b/go/seatlist/seatlisttest/seats/node-package-manager.json @@ -3,5 +3,6 @@ "seat": "node-package-manager", "scope": "node", "grain": "seat-list", + "domain": "Placement", "decision": "novox/hq ADR 0207" } diff --git a/go/seatlist/seatlisttest/seats/node-packet-filter.json b/go/seatlist/seatlisttest/seats/node-packet-filter.json index fb69e1c..005b50c 100644 --- a/go/seatlist/seatlisttest/seats/node-packet-filter.json +++ b/go/seatlist/seatlisttest/seats/node-packet-filter.json @@ -3,6 +3,7 @@ "seat": "node-packet-filter", "scope": "node", "grain": "seat-list", + "domain": "Connectivity", "decision": "novox/hq ADR 0121", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-power.json b/go/seatlist/seatlisttest/seats/node-power.json index 762a79f..13679b4 100644 --- a/go/seatlist/seatlisttest/seats/node-power.json +++ b/go/seatlist/seatlisttest/seats/node-power.json @@ -3,5 +3,6 @@ "seat": "node-power", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0211" } diff --git a/go/seatlist/seatlisttest/seats/node-provisioner.json b/go/seatlist/seatlisttest/seats/node-provisioner.json index 9752f8b..3510f6f 100644 --- a/go/seatlist/seatlisttest/seats/node-provisioner.json +++ b/go/seatlist/seatlisttest/seats/node-provisioner.json @@ -3,5 +3,6 @@ "seat": "node-provisioner", "scope": "node", "grain": "seat-list", + "domain": "Provisioning", "decision": "novox/hq ADR 0349" } diff --git a/go/seatlist/seatlisttest/seats/node-reads.json b/go/seatlist/seatlisttest/seats/node-reads.json index 938a71a..5dae465 100644 --- a/go/seatlist/seatlisttest/seats/node-reads.json +++ b/go/seatlist/seatlisttest/seats/node-reads.json @@ -3,6 +3,7 @@ "seat": "node-reads", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0310", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-resolver.json b/go/seatlist/seatlisttest/seats/node-resolver.json index 8689bcd..be8a91f 100644 --- a/go/seatlist/seatlisttest/seats/node-resolver.json +++ b/go/seatlist/seatlisttest/seats/node-resolver.json @@ -3,6 +3,7 @@ "seat": "node-resolver", "scope": "node", "grain": "seat-list", + "domain": "Connectivity", "decision": "novox/hq ADR 0247", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-secret-service.json b/go/seatlist/seatlisttest/seats/node-secret-service.json index fd5b84b..9dc3e27 100644 --- a/go/seatlist/seatlisttest/seats/node-secret-service.json +++ b/go/seatlist/seatlisttest/seats/node-secret-service.json @@ -3,5 +3,6 @@ "seat": "node-secret-service", "scope": "node", "grain": "seat-list", + "domain": "Identity and access", "decision": "novox/hq ADR 0208" } diff --git a/go/seatlist/seatlisttest/seats/node-service-manager.json b/go/seatlist/seatlisttest/seats/node-service-manager.json index 9270cc2..695bc00 100644 --- a/go/seatlist/seatlisttest/seats/node-service-manager.json +++ b/go/seatlist/seatlisttest/seats/node-service-manager.json @@ -3,6 +3,7 @@ "seat": "node-service-manager", "scope": "node", "grain": "seat-list", + "domain": "Placement", "decision": "novox/hq ADR 0177", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-terminal-emulator.json b/go/seatlist/seatlisttest/seats/node-terminal-emulator.json index 732a5ba..dd856ee 100644 --- a/go/seatlist/seatlisttest/seats/node-terminal-emulator.json +++ b/go/seatlist/seatlisttest/seats/node-terminal-emulator.json @@ -3,6 +3,7 @@ "seat": "node-terminal-emulator", "scope": "node", "grain": "seat-list", + "domain": "Core", "decision": "novox/hq ADR 0208", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/node-uplink.json b/go/seatlist/seatlisttest/seats/node-uplink.json index 0ecfbf1..cd59ee5 100644 --- a/go/seatlist/seatlisttest/seats/node-uplink.json +++ b/go/seatlist/seatlisttest/seats/node-uplink.json @@ -3,6 +3,7 @@ "seat": "node-uplink", "scope": "node", "grain": "seat-list", + "domain": "Connectivity", "decision": "novox/hq ADR 0117", "verbs": [ { diff --git a/go/seatlist/seatlisttest/seats/npm-package-registry.json b/go/seatlist/seatlisttest/seats/npm-package-registry.json index 61f77c3..ae2ab3d 100644 --- a/go/seatlist/seatlisttest/seats/npm-package-registry.json +++ b/go/seatlist/seatlisttest/seats/npm-package-registry.json @@ -3,6 +3,7 @@ "seat": "npm-package-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0109", "description": "The mesh's npm packages: the npm registry protocol's read side, and publishing over the bus.", "delivers": "npm-package-registry", diff --git a/go/seatlist/seatlisttest/seats/oci-artifact-registry.json b/go/seatlist/seatlisttest/seats/oci-artifact-registry.json index 9d37fce..a9d969c 100644 --- a/go/seatlist/seatlisttest/seats/oci-artifact-registry.json +++ b/go/seatlist/seatlisttest/seats/oci-artifact-registry.json @@ -3,6 +3,7 @@ "seat": "oci-artifact-registry", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0605", "description": "The mesh's bundles and archives, by digest, in the one artifact store: the OCI distribution protocol's read side.", "includes": [ diff --git a/go/seatlist/seatlisttest/seats/operator-channel.json b/go/seatlist/seatlisttest/seats/operator-channel.json index 9770349..0bd8c21 100644 --- a/go/seatlist/seatlisttest/seats/operator-channel.json +++ b/go/seatlist/seatlisttest/seats/operator-channel.json @@ -3,6 +3,7 @@ "seat": "operator-channel", "scope": "mesh", "grain": "whole", + "domain": "Operator and conversation", "decision": "novox/hq ADR 0259", "holder-declares": true, "description": "Asking a person and answering on a channel (ADR 0259): held by the router, the messenger module, whose manifest declares the seat; written from that seat entry (design 26).", diff --git a/go/seatlist/seatlisttest/seats/the-private-network.json b/go/seatlist/seatlisttest/seats/the-private-network.json index 858f02e..6cd6500 100644 --- a/go/seatlist/seatlisttest/seats/the-private-network.json +++ b/go/seatlist/seatlisttest/seats/the-private-network.json @@ -3,5 +3,6 @@ "seat": "the-private-network", "scope": "node", "grain": "seat-list", + "domain": "Connectivity", "decision": "novox/hq ADR 0110" } -- 2.54.0 From e0d2298f5818cfd154b0c69526be7dcf9309d828 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:31:27 +0100 Subject: [PATCH 097/144] =?UTF-8?q?seatlist:=20the=20again=20tool=20answer?= =?UTF-8?q?s=20a=20VerbCallers-style=20list,=20so=20the=20operator's=20ses?= =?UTF-8?q?sions=20can=20be=20named=20(ADR=200636=20=C2=A77;=20issue=20196?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/seatlist.go | 15 ++++++++------- go/seatlist/seatlist_test.go | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index e18878e..70835ea 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -383,7 +383,7 @@ func (r *Reader) changed() { // (every definition, its source and digest) and what it waits for; and again, which makes it read the list now, and // answers only to the callers the host's list names (the operator and the healers). The host names the tools and the list; // again records nothing but the read it asked. -func Tools(r *Reader, read, again string, callers stdio.Callers) []stdio.Tool { +func Tools(r *Reader, read, again string, callers []string) []stdio.Tool { return []stdio.Tool{ {Name: read, Description: "The seat list this part holds (novox/hq ADR 0636): each seat's definition with its source (trunk or bootstrap) and " + @@ -412,11 +412,12 @@ func Tools(r *Reader, read, again string, callers stdio.Callers) []stdio.Tool { } } -// Refusal is why caller may not make the reader read again, or nil: the caller's proof, never an argument, against the -// list of the operator and the healers. -func Refusal(tool string, callers stdio.Callers, caller string) error { - if callers.Allows(caller) { - return nil +// Refusal is why caller may not make the reader read again, or nil: the caller's proof, never an argument, against +// callers, the entries of a verb's list as VerbCallers reads them, so stdio.AnyPerson names every operator's session +// the runtime proved (person.) and a module name its instances (ADR 0636 §7). +func Refusal(tool string, callers []string, caller string) error { + if err := (stdio.VerbCallers{tool: callers}).Allowed(tool, caller); err != nil { + return fmt.Errorf("%s is refused to %q: it answers only %s (novox/hq ADR 0636 §7)", tool, caller, strings.Join(callers, ", ")) } - return fmt.Errorf("%s is refused to %q: it answers only %s (novox/hq ADR 0636 §7)", tool, caller, strings.Join(callers, ", ")) + return nil } diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index 0f87205..673c002 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -199,13 +199,13 @@ func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { if err != nil { t.Fatal(err) } - callers := stdio.Callers{"person.jo", "mesh-healers"} + callers := []string{stdio.AnyPerson, "mesh-healers"} tools := Tools(r, "seat_list", "seat_list_again", callers) out, err := tools[0].Run(map[string]any{}) if err != nil || out.(map[string]any)["health"] == "" || !strings.Contains(fmt.Sprint(out.(map[string]any)["status"]), "bootstrap") { t.Fatalf("read: %v %v", out, err) } - for caller, allowed := range map[string]bool{"person.jo": true, "ace.mesh-healers": true, "ace.mesh-other": false, "person.al": false, stdio.Unproven: false} { + for caller, allowed := range map[string]bool{"person.jo": true, "ace.mesh-healers": true, "ace.mesh-other": false, "person.al": true, stdio.Unproven: false} { if err := Refusal("seat_list_again", callers, caller); (err == nil) != allowed { t.Fatalf("%s: %v, allowed %v", caller, err, allowed) } -- 2.54.0 From b81622842bf83e9bf6e3e5a10817d9022fde6ddf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:32:19 +0100 Subject: [PATCH 098/144] seatlist: an event heard before the seat ever answered reads the whole list (issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/seatlist.go | 18 +++++++++++++++++ go/seatlist/seatlist_test.go | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index 70835ea..105de8b 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -345,6 +345,16 @@ func (r *Reader) dropLocked(seat string) { // heard takes one of the seat's events: a trunk definition moved, so that seat is asked again, or went, so it is dropped. // An error is returned so the bus offers the event again. func (r *Reader) heard(key string, body json.RawMessage) error { + r.mu.Lock() + unread := !r.spoken + r.mu.Unlock() + if unread { + // the seat was silent when the reader started and speaks now: read the whole list, not only the seat named + if err := r.Reload(); err != nil { + return err + } + return nil + } var c Changed if err := json.Unmarshal(body, &c); err != nil || c.Seat == "" { return nil @@ -352,6 +362,14 @@ func (r *Reader) heard(key string, body json.RawMessage) error { if c.Source != "" && c.Source != SourceTrunk { return nil } + r.mu.Lock() + spoken := r.spoken + r.mu.Unlock() + if !spoken { + // The seat was silent when this reader started: its event says it speaks now, so the whole list is read, not this + // seat alone, and the reader is heard (MayAct) from here on. + return r.Reload() + } if strings.HasSuffix(key, "."+RemovedEvent) { r.mu.Lock() r.dropLocked(c.Seat) diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index 673c002..079ad98 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -218,3 +218,42 @@ func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { t.Fatalf("again: %v %+v", err, r.Status()) } } + +// A reader started while the seat was silent reads the whole list at the first event the seat sends, and then acts on any module. +func TestAReaderStartedWhileTheSeatWasSilentReadsTheWholeListAtItsFirstEvent(t *testing.T) { + owner := &fakeOwner{down: true, table: map[string]string{"node-b": def("node-b", "node", "put"), "node-c": def("node-c", "node", "put")}} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + if r.Status().Heard || r.MayAct("node-b") { + t.Fatal("the silent seat was heard") + } + owner.down = false + if err := owner.say(ChangedEvent, "node-b", SourceTrunk); err != nil { + t.Fatal(err) + } + if !r.Status().Heard || !r.MayAct("node-b") || !r.Has("node-c") { + t.Fatalf("the event did not read the whole list: %+v", r.Status()) + } +} + +// A reader started while the seat was silent is heard at the seat's changed event: the whole list is read then (ADR 0636 §1). +func TestAReaderStartedWhileTheSeatWasSilentIsHeardAtItsChangedEvent(t *testing.T) { + owner := &fakeOwner{down: true} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + if r.Status().Heard || r.MayAct("node-files") { + t.Fatal("the silent seat must leave the reader unheard") + } + owner.down = false + owner.table = map[string]string{"node-a": def("node-a", "node", "put"), "node-b": def("node-b", "node", "put")} + if err := owner.say(ChangedEvent, "node-a", SourceTrunk); err != nil { + t.Fatal(err) + } + if st := r.Status(); !st.Heard || st.LastError != "" || !r.MayAct("node-files") || !r.Has("node-a") || !r.Has("node-b") { + t.Fatalf("after the changed event: %+v, node-b held %v: want heard and the whole list read", st, r.Has("node-b")) + } +} -- 2.54.0 From 9fb2c421388071532b1e78099f9a884df98a13bc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:32:25 +0100 Subject: [PATCH 099/144] =?UTF-8?q?seatlist:=20a=20reader=20started=20whil?= =?UTF-8?q?e=20the=20seat=20was=20silent=20is=20heard=20at=20the=20seat's?= =?UTF-8?q?=20changed=20event,=20which=20reads=20the=20whole=20list=20(ADR?= =?UTF-8?q?=200636=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/seatlist/bootstrap/mesh-catalog.json | 808 +++++++++++++++++- .../bootstrap/mesh-delivery-checker.json | 391 ++++++++- 2 files changed, 1181 insertions(+), 18 deletions(-) diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index e78353a..92a8307 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -2,24 +2,9 @@ "format": 1, "seat": "mesh-catalog", "scope": "mesh", - "grain": "seat-list", - "domain": "Delivery", - "decision": "novox/hq ADR 0636", + "grain": "whole", + "decision": "novox/hq ADR 0604", "verbs": [ - { - "name": "seat-definitions", - "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", - "optional": true, - "input": { - "properties": { - "seat": { - "description": "the seat's name; none lists every trunk seat", - "type": "string" - } - }, - "type": "object" - } - }, { "name": "modules", "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", @@ -27,6 +12,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what modules answers, as its description says" } }, { @@ -44,6 +33,10 @@ "module" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what module answers, as its description says" } }, { @@ -61,6 +54,10 @@ "provision" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what provides answers, as its description says" } }, { @@ -78,7 +75,784 @@ "module" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what dependents answers, as its description says" } } + ], + "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).", + "family": { + "name": "mesh-catalog.events", + "max-age-days": 7 + }, + "events": [ + { + "name": "seat-definition-changed", + "body": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + }, + { + "name": "seat-definition-removed", + "body": { + "type": "object", + "properties": { + "seat": { + "type": "string" + } + }, + "required": [ + "seat" + ] + } + } + ], + "states": [ + { + "name": "manifests", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string" + }, + "manifest": { + "type": "string", + "description": "the manifest, UTF-8 JSON text" + } + }, + "required": [ + "module", + "manifest" + ] + }, + "description": "a manifest by its digest, written once" + } + ], + "description": "every manifest the indexer judged", + "history": 1 + }, + { + "name": "modules", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "fp": { + "type": "string", + "description": "the old-form fingerprint" + }, + "none": { + "type": "string" + }, + "fp2": { + "type": "string", + "description": "src2: fingerprint" + }, + "none2": { + "type": "string", + "description": "why it has none" + }, + "manifest": { + "type": "string", + "description": "manifest digest" + }, + "inputs2": { + "type": "object", + "properties": { + "tree": { + "type": "string" + }, + "bases": { + "type": "array", + "items": { + "type": "string" + } + }, + "builder": { + "type": "string" + }, + "contexts": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + }, + "imports": { + "type": "array", + "items": { + "type": "string" + } + }, + "import-form": { + "type": "string" + } + }, + "required": [ + "tree" + ] + }, + "src": { + "type": "integer", + "description": "the source revision" + }, + "fp-src": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "path": { + "type": "string" + }, + "toolchains": { + "type": "object", + "additionalProperties": { + "description": "toolchain used" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "verdict": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + }, + "unknown": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repository", + "commit" + ], + "description": "one module as the trunk holds it" + }, + "description": "a module the trunk holds" + } + ], + "description": "the module catalog", + "history": 20 + }, + { + "name": "candidates", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "base": { + "type": "integer", + "description": "the source revision it is read on" + }, + "heads": { + "type": "string" + }, + "pulls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "fps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "fp2": { + "type": "string" + }, + "none2": { + "type": "string" + }, + "manifest": { + "type": "string" + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "required": [ + "repository", + "number", + "head", + "modules" + ] + } + }, + "fpset": { + "type": "string" + } + }, + "required": [ + "group", + "base", + "heads", + "pulls" + ], + "description": "the modules a group of open pull requests would change" + }, + "description": "a group's candidate" + } + ], + "description": "the candidates of open groups", + "history": 10 + }, + { + "name": "verdicts", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string" + }, + "state": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + }, + "unknown": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "judge": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "module", + "state" + ], + "description": "a manifest's verdict, by the judge named" + }, + "description": "a manifest's verdict by its digest" + } + ], + "description": "the manifest verdicts", + "history": 5 + }, + { + "name": "predefined", + "template": "state", + "key-forms": [ + { + "key": "list", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "commit": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "list": { + "description": "the foundation's predefined list as kept" + } + }, + "required": [ + "commit", + "list" + ] + }, + "description": "the one key" + } + ], + "description": "the kept predefined list", + "history": 5 + }, + { + "name": "config-candidates", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "pull": { + "type": "string" + }, + "head": { + "type": "string" + }, + "declares": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "default": { + "description": "the default" + }, + "why": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "description": "the allowed values" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "trusted": { + "type": "boolean" + }, + "predefined": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "runtime": { + "type": "boolean" + } + }, + "required": [ + "kind", + "default", + "why" + ] + } + }, + "description": "module to key to declaration" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "pull", + "head", + "declares" + ] + }, + "description": "the configuration a pull request declares" + } + ], + "description": "the configuration declared by open pull requests", + "history": 5 + }, + { + "name": "packages", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + }, + "description": "a mesh Go module as its latest trunk merge published it" + }, + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "name": { + "type": "string" + }, + "package": { + "type": "string" + }, + "series": { + "type": "string" + }, + "seat": { + "type": "string" + }, + "go-module": { + "type": "string" + }, + "newest": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "contents": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + } + }, + "required": [ + "version" + ] + }, + "numbered": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "contents": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + } + }, + "required": [ + "version" + ] + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "name", + "series" + ] + }, + "description": "a published package series, numbered and newest" + } + ], + "description": "the published packages", + "history": 5 + } ] } diff --git a/go/seatlist/bootstrap/mesh-delivery-checker.json b/go/seatlist/bootstrap/mesh-delivery-checker.json index 808b3e9..2aaba6b 100644 --- a/go/seatlist/bootstrap/mesh-delivery-checker.json +++ b/go/seatlist/bootstrap/mesh-delivery-checker.json @@ -3,7 +3,6 @@ "seat": "mesh-delivery-checker", "scope": "mesh", "grain": "whole", - "domain": "Delivery", "decision": "novox/hq ADR 0547", "verbs": [ { @@ -242,5 +241,395 @@ "subject": "mesh.stream..event.preview.written", "family": "checks" } + ], + "states": [ + { + "name": "verdicts", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "string", + "description": "12 hex digits" + }, + "trunk": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "repository to the trunk head merged onto" + }, + "state": { + "type": "string", + "description": "running, pass or fail" + }, + "failed": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "machine": { + "type": "string" + }, + "words": { + "type": "string" + } + } + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "machine": { + "type": "string" + }, + "words": { + "type": "string" + } + } + } + }, + "levels": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "run": { + "type": "integer" + }, + "kept": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + }, + "required": [ + "run", + "kept" + ] + }, + "description": "by level, unit or integration" + }, + "took": { + "type": "object", + "properties": { + "queue": { + "type": "integer" + }, + "clone": { + "type": "integer" + }, + "unit": { + "type": "integer" + }, + "lint": { + "type": "integer" + }, + "integration-setup": { + "type": "integer" + }, + "integration-run": { + "type": "integer" + }, + "composition": { + "type": "integer" + } + }, + "description": "milliseconds" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "sdk": { + "type": "string" + }, + "composition": { + "type": "object", + "properties": { + "state": { + "type": "string", + "description": "not-run, pass or fail" + }, + "words": { + "type": "string" + }, + "sdk": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "fpset": { + "type": "string" + } + }, + "required": [ + "state" + ] + }, + "over-bound": { + "type": "array", + "items": { + "type": "string" + } + }, + "beside": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "carried-from": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "artifact": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "module", + "artifact", + "from", + "to" + ] + } + } + }, + "required": [ + "group", + "heads", + "state" + ], + "description": "one check of a group at one set of heads" + }, + "description": "one check of a group; the group is an escaped branch name, heads 12 hex digits" + } + ], + "description": "the checker's verdict of each group at each set of heads", + "history": 5 + }, + { + "name": "kept", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "state": { + "type": "string", + "description": "pass: a failure is never kept" + }, + "repository": { + "type": "string" + }, + "package": { + "type": "string" + }, + "level": { + "type": "string" + }, + "tests": { + "type": "integer" + }, + "took": { + "type": "integer" + }, + "toolchain": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "state", + "repository", + "package", + "level" + ], + "description": "one passing level of one package, kept so the next check does not run it again" + }, + "description": "a passing level of one package by its content digest" + } + ], + "description": "the kept passing levels", + "history": 1 + }, + { + "name": "owed", + "template": "state", + "key-forms": [ + { + "key": "...", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "act": { + "type": "string", + "description": "status=, view, note or comment." + }, + "context": { + "type": "string" + }, + "target": { + "type": "string" + }, + "state": { + "type": "string" + }, + "text": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "act" + ], + "description": "one act owed to the forge, written by its owner and done by the forge bridge" + }, + "description": "the status mesh/check the checker owes the forge for a head (its act is status=mesh/check)" + } + ], + "description": "the status the checker owes the forge", + "history": 5 + }, + { + "name": "previews", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "repository": { + "type": "string" + }, + "head": { + "type": "string" + }, + "trunk": { + "type": "string" + }, + "merge": { + "type": "string", + "description": "the merge commit" + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "files in conflict" + }, + "modules": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "tree": { + "type": "string" + }, + "manifest": { + "type": "string", + "description": "manifest digest" + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tree", + "manifest" + ] + }, + "description": "by directory" + } + }, + "required": [ + "repository", + "head", + "trunk" + ], + "description": "the merge of a head onto a trunk commit, read before a pull request is checked" + }, + "description": "the merge of a head onto a trunk commit" + } + ], + "description": "the previews of a head merged onto a trunk", + "history": 5 + } ] } -- 2.54.0 From 99e6badabfb9c3af78d1d8da3a5cac0e4dafa287 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:32:38 +0100 Subject: [PATCH 100/144] =?UTF-8?q?seatlist=20bootstrap:=20mesh-catalog=20?= =?UTF-8?q?(with=20seat-definitions=20and=20its=20two=20events)=20and=20me?= =?UTF-8?q?sh-delivery-checker=20as=20the=20delivery=20contracts=20define?= =?UTF-8?q?=20them,=20plus=20the=20domain=20they=20lack=20(ADR=200636=20?= =?UTF-8?q?=C2=A72;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/bootstrap/mesh-catalog.json | 1 + go/seatlist/bootstrap/mesh-delivery-checker.json | 1 + 2 files changed, 2 insertions(+) diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index 92a8307..7fd70d0 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -3,6 +3,7 @@ "seat": "mesh-catalog", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0604", "verbs": [ { diff --git a/go/seatlist/bootstrap/mesh-delivery-checker.json b/go/seatlist/bootstrap/mesh-delivery-checker.json index 2aaba6b..0a9220d 100644 --- a/go/seatlist/bootstrap/mesh-delivery-checker.json +++ b/go/seatlist/bootstrap/mesh-delivery-checker.json @@ -3,6 +3,7 @@ "seat": "mesh-delivery-checker", "scope": "mesh", "grain": "whole", + "domain": "Delivery", "decision": "novox/hq ADR 0547", "verbs": [ { -- 2.54.0 From 2b9cdb57717e8f416b9788ff68a0a9a157f69094 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:35:19 +0100 Subject: [PATCH 101/144] =?UTF-8?q?seatlist:=20Start=20returns=20at=20once?= =?UTF-8?q?=20and=20reads=20the=20seat=20on=20a=20goroutine;=20Ready=20say?= =?UTF-8?q?s=20when=20the=20first=20read=20ended,=20Status=20says=20it=20i?= =?UTF-8?q?s=20reading=20(ADR=200636=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/bootstrap/node-engine.json | 286 +++++++++++++++++++++++ go/seatlist/seatlist.go | 38 ++- go/seatlist/seatlist_integration_test.go | 1 + go/seatlist/seatlist_test.go | 30 ++- 4 files changed, 346 insertions(+), 9 deletions(-) diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json index 20ed8a1..541d242 100644 --- a/go/seatlist/bootstrap/node-engine.json +++ b/go/seatlist/bootstrap/node-engine.json @@ -674,6 +674,30 @@ ], "description": "what this machine's engine reads of a manifest (ADR 0618 §3)" } + }, + { + "name": "seat-list", + "description": "The seat list this engine holds and what it waits for (novox/hq ADR 0636 §7): each seat's source (trunk or bootstrap) and digest, the seats held from the bootstrap alone, the definitions refused, the last error and whether the seat mesh-catalog has answered. Read-only.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } + }, + { + "name": "seat-list-again", + "description": "Make this engine read the seat list from the seat mesh-catalog now (novox/hq ADR 0636 §7), the recovery of a list unread; it answers what it holds afterwards, and the error when the read failed. A read that changed nothing still ends the engine's wait.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } } ], "events": [ @@ -1069,6 +1093,109 @@ } ], "description": "each node's health as its engine reports it" + }, + { + "name": "findings", + "template": "state", + "writer": "holder", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/FindingSubject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/FindingAction" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/FindingEvidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "the findings this node's engine raises about itself, one key per finding, in the layout of the seat mesh-conditions (its findings state): a key written raises the condition of the same key, a key deleted clears it (novox/hq ADR 0564 §2, ADR 0636 §1); today `machine..seat-list-unread`" } ], "defs": { @@ -1412,6 +1539,165 @@ "said" ], "description": "the answer of a read-only judgement" + }, + "SeatListHeld": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "trunk", + "bootstrap" + ] + }, + "digest": { + "type": "string", + "description": "the digest of the definition held; empty for a bootstrapped one" + } + }, + "required": [ + "seat", + "source" + ] + }, + "SeatList": { + "type": "object", + "description": "what an engine holds of the seat list (novox/hq ADR 0636 §7)", + "properties": { + "status": { + "type": "object", + "properties": { + "heard": { + "type": "boolean", + "description": "the seat mesh-catalog has answered" + }, + "held": { + "type": "array", + "items": { + "$ref": "#/defs/SeatListHeld" + } + }, + "waiting": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the seats held from the bootstrap alone" + }, + "refused": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "a trunk definition that does not parse, by seat: the one held stands" + }, + "last-error": { + "type": "string" + }, + "revision": { + "type": "integer" + } + }, + "required": [ + "heard", + "held" + ] + }, + "health": { + "type": "string", + "description": "the one line the engine says of it; empty when nothing waits" + }, + "error": { + "type": "string", + "description": "why the read failed, for seat-list-again" + } + }, + "required": [ + "status", + "health" + ] + }, + "FindingSubject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "FindingAction": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "FindingEvidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] } } } diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index 105de8b..9d0dd06 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -112,6 +112,7 @@ type Reader struct { refused map[string]string // seats whose trunk definition cannot be parsed, with why: the one that stands stays // what the last ask of the seat came to (Status) spoken bool + ready chan struct{} // closed once the first read of the seat has come to an end, answered or not revision uint64 lastErr string } @@ -128,7 +129,9 @@ type Held struct { // are the seats whose trunk definition did not parse (the one held stands), LastError is why the last ask failed, and // Revision is the seat's state revision of the last listing. type Status struct { - Heard bool `json:"heard"` + Heard bool `json:"heard"` + // Reading says the first read of the seat has not come to an end yet. + Reading bool `json:"reading,omitempty"` Held []Held `json:"held"` Waiting []string `json:"waiting,omitempty"` Refused map[string]string `json:"refused,omitempty"` @@ -140,7 +143,7 @@ type Status struct { func (r *Reader) Status() Status { r.mu.Lock() defer r.mu.Unlock() - st := Status{Heard: r.spoken, LastError: r.lastErr, Revision: r.revision, Held: []Held{}} + st := Status{Heard: r.spoken, Reading: !r.isReady(), LastError: r.lastErr, Revision: r.revision, Held: []Held{}} for seat := range r.defs { h := Held{Seat: seat, Source: SourceTrunk, Digest: r.digests[seat]} if r.boot[seat] { @@ -163,6 +166,8 @@ func (r *Reader) Status() Status { // Health is Status as the one line a module's health says: empty when the seat has been heard and nothing waits. func (st Status) Health() string { switch { + case st.Reading: + return fmt.Sprintf("the seat list is held from the bootstrap (%d seats) and nothing new is acted on: the first read of the seat %s has not ended", len(st.Held), Seat) case !st.Heard: return fmt.Sprintf("the seat list is held from the bootstrap (%d seats) and nothing new is acted on: the seat %s has not answered%s", len(st.Held), Seat, withError(st.LastError)) @@ -179,15 +184,16 @@ func withError(e string) string { return " (" + e + ")" } -// Start returns a reader holding the bootstrap, asks the seat for every definition and follows its two events. A first -// ask the seat did not answer is no failure: the reader holds the bootstrap, acts on nothing new (MayAct) and reads again -// on the seat's next event or on Reload. Start returns the subscription's error alone, which the host may ask again by -// calling Start again on a new reader. +// Start returns at once a reader holding the bootstrap, following the seat's two events, and reads the seat for every +// definition on a goroutine of its own, so a slow seat holds nobody's start (ADR 0636 §1). Until that read has come to an +// end the reader acts on nothing new (MayAct) and Status says it is reading; Ready is closed then. A first read the seat +// did not answer is no failure: the reader holds the bootstrap and reads again on the seat's next event or on Reload. +// Start returns the subscription's error alone, which the host may ask again by calling Start again on a new reader. func Start(cfg Config) (*Reader, error) { if cfg.Bus == nil { return nil, errors.New("seatlist: no bus to read the seat list through") } - r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, digests: map[string]string{}, boot: map[string]bool{}, refused: map[string]string{}} + r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, digests: map[string]string{}, boot: map[string]bool{}, refused: map[string]string{}, ready: make(chan struct{})} for _, d := range cfg.Bootstrap { r.defs[d.Seat] = d r.boot[d.Seat] = true @@ -197,10 +203,26 @@ func Start(cfg Config) (*Reader, error) { return r, fmt.Errorf("seatlist: following %s: %w", ev, err) } } - _ = r.Reload() + go func() { + _ = r.Reload() + close(r.ready) + }() return r, nil } +// Ready is closed once the first read of the seat, which Start began, has come to an end, whether the seat answered or +// not: a test or a host that needs the reader's first state waits on it. +func (r *Reader) Ready() <-chan struct{} { return r.ready } + +func (r *Reader) isReady() bool { + select { + case <-r.ready: + return true + default: + return false + } +} + // Seats are the held seats in reading order, as catalogue takes them. func (r *Reader) Seats() []catalogue.Seat { r.mu.Lock() diff --git a/go/seatlist/seatlist_integration_test.go b/go/seatlist/seatlist_integration_test.go index 236fe7a..7c50833 100644 --- a/go/seatlist/seatlist_integration_test.go +++ b/go/seatlist/seatlist_integration_test.go @@ -54,6 +54,7 @@ func TestAReaderStartsFromTheBootstrapAndReadsTheSeatOnceItAnswers(t *testing.T) if err != nil { t.Fatal(err) } + <-r.Ready() if st := r.Status(); st.Heard || st.LastError == "" || !r.MayAct("node-engine") || r.MayAct("node-files") { t.Fatalf("with the seat silent: %+v, may act on node-files: %v", st, r.MayAct("node-files")) } diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index 079ad98..c01ecc4 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -16,11 +16,15 @@ import ( type fakeOwner struct { table map[string]string // seat -> definition JSON, trunk down bool - asked []string // "key seat" + gate chan struct{} // when set, an ask waits for it to be closed + asked []string // "key seat" handlers map[string]func(string, json.RawMessage) error } func (f *fakeOwner) Ask(key string, body any) (json.RawMessage, error) { + if f.gate != nil { + <-f.gate + } args, _ := body.(map[string]any) seat, _ := args["seat"].(string) f.asked = append(f.asked, key+" "+seat) @@ -82,6 +86,7 @@ func TestTheSeatListIsReadFromTheSeatAndFollowsItsEvents(t *testing.T) { if err != nil { t.Fatal(err) } + <-r.Ready() if owner.handlers[Pattern(ChangedEvent)] == nil || owner.handlers[Pattern(RemovedEvent)] == nil || Pattern(ChangedEvent) != "mesh-catalog.seat-definition-changed" { t.Fatalf("subscribed to %v", owner.handlers) } @@ -157,6 +162,7 @@ func TestTheBootstrapIsHeldAndNothingNewIsActedOnUntilTheSeatAnswers(t *testing. if err != nil { t.Fatal(err) } + <-r.Ready() if got := len(r.Definitions()); got != len(boot) { t.Fatalf("the reader holds %d definitions, the bootstrap has %d", got, len(boot)) } @@ -199,6 +205,7 @@ func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { if err != nil { t.Fatal(err) } + <-r.Ready() callers := []string{stdio.AnyPerson, "mesh-healers"} tools := Tools(r, "seat_list", "seat_list_again", callers) out, err := tools[0].Run(map[string]any{}) @@ -226,6 +233,7 @@ func TestAReaderStartedWhileTheSeatWasSilentReadsTheWholeListAtItsFirstEvent(t * if err != nil { t.Fatal(err) } + <-r.Ready() if r.Status().Heard || r.MayAct("node-b") { t.Fatal("the silent seat was heard") } @@ -245,6 +253,7 @@ func TestAReaderStartedWhileTheSeatWasSilentIsHeardAtItsChangedEvent(t *testing. if err != nil { t.Fatal(err) } + <-r.Ready() if r.Status().Heard || r.MayAct("node-files") { t.Fatal("the silent seat must leave the reader unheard") } @@ -257,3 +266,22 @@ func TestAReaderStartedWhileTheSeatWasSilentIsHeardAtItsChangedEvent(t *testing. t.Fatalf("after the changed event: %+v, node-b held %v: want heard and the whole list read", st, r.Has("node-b")) } } + +// ADR 0636 §1: Start returns at once whatever the seat does; until the first read has ended the reader acts on nothing new +// and says it is reading. +func TestStartReturnsAtOnceAndTheReaderSaysItIsReadingUntilTheSeatAnswers(t *testing.T) { + owner := &fakeOwner{gate: make(chan struct{}), table: map[string]string{"node-b": def("node-b", "node", "put")}} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + st := r.Status() + if !st.Reading || st.Heard || !strings.Contains(st.Health(), "first read") || r.MayAct("node-b") || !r.MayAct("node-engine") { + t.Fatalf("while the seat is slow: %+v", st) + } + close(owner.gate) + <-r.Ready() + if st := r.Status(); st.Reading || !st.Heard || !r.MayAct("node-b") { + t.Fatalf("after the read: %+v", st) + } +} -- 2.54.0 From 129dc8cffbb39e89238fa8420790e9c7f7176088 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:35:20 +0100 Subject: [PATCH 102/144] =?UTF-8?q?seatlist=20bootstrap:=20node-engine=20w?= =?UTF-8?q?ith=20its=20verbs=20seat-list=20and=20seat-list-again=20and=20i?= =?UTF-8?q?ts=20state=20findings,=20mesh-catalog=20as=20B2's=20definition?= =?UTF-8?q?=20now=20holds=20it;=20every=20bootstrap=20file=20equals=20its?= =?UTF-8?q?=20contracts=20file=20(ADR=200636=20=C2=A72,=20=C2=A77;=20issue?= =?UTF-8?q?=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/seatlist/bootstrap/mesh-catalog.json | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index 7fd70d0..d036149 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -81,6 +81,86 @@ "type": "object", "description": "what dependents answers, as its description says" } + }, + { + "name": "seat-definitions", + "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "optional": true, + "input": { + "properties": { + "seat": { + "description": "the seat's name; none lists every trunk seat", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "with no seat: the revision of the list and every trunk seat with its digest; with a seat: whether it was found and its entry, the definition resolved", + "properties": { + "revision": { + "type": "integer" + }, + "seats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + }, + "seat": { + "type": "string" + }, + "found": { + "type": "boolean" + }, + "entry": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + }, + "definition": { + "type": "object", + "description": "the definition, resolved with the parts it includes" + }, + "unparsed": { + "type": "string", + "description": "why the definition could not be read, when it could not" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + } + } } ], "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).", -- 2.54.0 From 02fe0a015b080911d2c97535c80eb58f9f2b724e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:36:41 +0100 Subject: [PATCH 103/144] =?UTF-8?q?views:=20an=20owner's=20changes=20are?= =?UTF-8?q?=20ordinary=20events=20on=20the=20shared=20events=20stream,=20h?= =?UTF-8?q?eard=20on=20the=20reader's=20one=20durable=20consumer;=20Follow?= =?UTF-8?q?=20takes=20no=20stream,=20Other=20takes=20the=20rest=20(ADR=200?= =?UTF-8?q?635=20=C2=A75,=20D-25;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/views/emit.go | 4 +- go/views/process_reader.go | 93 ++++++++++++--------- go/views/process_reader_integration_test.go | 80 ++++++++++++++++++ go/views/views.go | 22 +++-- go/views/views_test.go | 15 ++-- go/views/viewstest/owner.go | 32 +++---- 6 files changed, 178 insertions(+), 68 deletions(-) create mode 100644 go/views/process_reader_integration_test.go diff --git a/go/views/emit.go b/go/views/emit.go index 3a68d69..8119ba3 100644 --- a/go/views/emit.go +++ b/go/views/emit.go @@ -18,9 +18,9 @@ import ( type Emitter struct { // Owner is the owner module, said in every event. Owner string - // Subject is where an event of the given local name lands on the owner's own stream (process.Account.StreamSubject). + // Subject is where an event of the given local name lands: the owner's ordinary event (process.Account.EventSubject). Subject func(event string) string - // Publish sends one message to the owner's stream, its id the bus's dedup id. + // Publish sends one message to the shared events stream, its id the bus's dedup id (JetStreamPublish). Publish func(ctx context.Context, msg *nats.Msg, id string) error // Marks is the owner's state MarksState; nil says nothing is marked, and every start says from the bucket's end. Marks jetstream.KeyValue diff --git a/go/views/process_reader.go b/go/views/process_reader.go index 2c63d11..1ab7611 100644 --- a/go/views/process_reader.go +++ b/go/views/process_reader.go @@ -21,7 +21,6 @@ import ( "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" ) @@ -43,10 +42,14 @@ type ProcessReader struct { Bound time.Duration Wait time.Duration - mu sync.Mutex - pics map[string]*Picture // by bucket name _ - order []string - streams map[string]string // each owner's views stream, by owner + // Other is told of each message of the module's durable consumer on the shared events stream that is not an owner's + // views event, so a module that hears other events through the same consumer (a module has one) handles them there. + // Nil says them in the log, acknowledged: the module hears views events alone. + Other func(jetstream.Msg) + + mu sync.Mutex + pics map[string]*Picture // by bucket name _ + order []string } func (r *ProcessReader) logf(format string, args ...any) { @@ -55,23 +58,19 @@ func (r *ProcessReader) logf(format string, args ...any) { } } -// Follow adds the owner's state to follow, as the owner declares them (design 25 § 12.2): tool is its ordinary read tool -// of state as the reader's `invokes` names it (`.`, from the owner's manifest's tools), stream its views -// family as the owner's manifest's `streams` names it (its bus name is catalogue.StreamBusName's). views names no owner: -// the caller gives both. An owner has one views stream; a second name for it is refused. Call it before Start. -func (r *ProcessReader) Follow(owner, state, tool, stream string) (*Picture, error) { +// Follow adds the owner's state to follow, as the owner declares it (design 25 § 12.2): tool is its ordinary read tool +// of state as the reader's `invokes` names it (`.`, from the owner's manifest's tools). The owner's changes +// are ordinary events (`.views.`), which the reader's manifest `consumes` one entry per followed state. +// views names no owner: the caller gives it. Call it before Start. +func (r *ProcessReader) Follow(owner, state, tool string) (*Picture, error) { r.mu.Lock() defer r.mu.Unlock() - if owner == "" || state == "" || !strings.HasPrefix(tool, owner+".") || stream == "" { - return nil, fmt.Errorf("%s follows %s's %s by its read tool . and its views stream, not %q and %q", r.Module, owner, state, tool, stream) - } - if have, ok := r.streams[owner]; ok && have != stream { - return nil, fmt.Errorf("%s's views stream is %s, not %s", owner, have, stream) + if owner == "" || state == "" || !strings.HasPrefix(tool, owner+".") { + return nil, fmt.Errorf("%s follows %s's %s by its read tool ., not %q", r.Module, owner, state, tool) } if r.pics == nil { - r.pics, r.streams = map[string]*Picture{}, map[string]string{} + r.pics = map[string]*Picture{} } - r.streams[owner] = stream bucket := owner + "_" + state if p := r.pics[bucket]; p != nil { return p, nil @@ -153,16 +152,35 @@ func (r *ProcessReader) finding(s Status, raised bool) { } } -// Hear is the handler of the owner's views family on the reader's durable consumer (cred.EventConsumer on the owner's -// views stream): each event to the follower of its state. -func (r *ProcessReader) Hear(ctx context.Context, owner string) func(jetstream.Msg) { - fs := map[string]*Follower{} +// Hear is the handler of the module's durable consumer on the shared events stream (cred.EventConsumer on +// process.EventsStream): an owner's views event (`mesh.mod..event.views.`) goes to the follower of that +// owner's state, any other message to Other. +func (r *ProcessReader) Hear(ctx context.Context) func(jetstream.Msg) { + fs := map[string]map[string]*Follower{} for _, f := range r.Followers() { - if f.Owner == owner { - fs[f.State] = f + if fs[f.Owner] == nil { + fs[f.Owner] = map[string]*Follower{} } + fs[f.Owner][f.State] = f + } + views := map[string]func(jetstream.Msg){} + for owner, m := range fs { + views[owner] = Hear(ctx, m, r.logf) + } + return func(m jetstream.Msg) { + if owner, ok := OwnerOfEvent(m.Subject()); ok { + if h := views[owner]; h != nil { + h(m) + return + } + } + if r.Other != nil { + r.Other(m) + return + } + r.logf("views: %s is no views event of an owner followed, acknowledged", m.Subject()) + _ = m.Ack() } - return Hear(ctx, fs, r.logf) } // Owners are the owners followed, sorted. @@ -510,23 +528,18 @@ func (e entry) Created() time.Time { return e.at } func (e entry) Delta() uint64 { return 0 } func (e entry) Operation() jetstream.KeyValueOp { return e.op } -// Start hears each owner's views family on the process's durable consumer of it (its manifest's `consumes` -// `.views.`), keeps each status in the process's state `first-views` and hears the hand asks the tools -// bundle's `_first_view_again` writes there (StatusTools), then asks every first view (Ask). A consumer -// the bus does not have yet is said: the picture is then the first view's alone, asked again on a gap or by hand. +// Start hears the owners' views events on the process's durable consumer of the shared events stream (its manifest's +// `consumes` `.views.`), keeps each status in the process's state `first-views` and hears the hand asks the +// tools bundle's `_first_view_again` writes there (StatusTools), then asks every first view (Ask). A consumer the +// bus does not have yet is said: the picture is then the first view's alone, asked again on a gap or by hand. func (r *ProcessReader) Start(ctx context.Context, js jetstream.JetStream, cred process.Account, status *process.AwaitedBucket) { - for _, owner := range r.Owners() { - r.mu.Lock() - stream := catalogue.StreamBusName(r.streams[owner]) - r.mu.Unlock() - cons, err := cred.EventConsumer(ctx, js, stream) - if err == nil { - _, err = process.HearEvents(cons, r.Hear(ctx, owner), r.logf) - } - if err != nil { - r.logf("the consumer %s on %s is not heard (%v): %s's states are the first view's alone, asked again by hand, until the %s starts again", - cred.EventConsumerName(), stream, err, owner, r.Module) - } + cons, err := cred.EventConsumer(ctx, js, process.EventsStream) + if err == nil { + _, err = process.HearEvents(cons, r.Hear(ctx), r.logf) + } + if err != nil { + r.logf("the consumer %s on %s is not heard (%v): the states followed are the first views' alone, asked again by hand, until the %s starts again", + cred.EventConsumerName(), process.EventsStream, err, r.Module) } var said func(Status) if status != nil { diff --git a/go/views/process_reader_integration_test.go b/go/views/process_reader_integration_test.go new file mode 100644 index 0000000..ab740da --- /dev/null +++ b/go/views/process_reader_integration_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package views_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/testbus" + "git.novox.be/novox/mesh-sdk/go/views" + "git.novox.be/novox/mesh-sdk/go/views/viewstest" +) + +// An owner's change is an ordinary event on the shared events stream (novox/hq ADR 0635 §5): a reader that follows the +// owner's state by its read tool hears it on the module's durable consumer of that stream, with no stream of the owner's, +// and a message that is no views event goes to Other. +func TestAReaderHearsAnOwnersChangesAsOrdinaryEvents(t *testing.T) { + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cred := process.Account{Node: "n1", Module: "reader"} + kv := viewstest.Serve(t, ctx, js, nc, "o", "modules", "o.o_read_modules", cred.EventConsumerName()) + if _, err := kv.Put(ctx, "a", []byte(`{"n":1}`)); err != nil { + t.Fatal(err) + } + other := make(chan string, 1) + r := &views.ProcessReader{Module: "reader", Other: func(m jetstream.Msg) { other <- m.Subject(); _ = m.Ack() }, Logf: t.Logf, + Call: func(tool string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + m, err := nc.Request("mesh.mod.o.call.o_read_modules.n0", raw, 5*time.Second) + if err != nil { + return nil, err + } + return m.Data, nil + }} + if _, err := r.Follow("o", "modules", "o.o_read_modules"); err != nil { + t.Fatal(err) + } + r.Start(ctx, js, cred, nil) + <-r.Ready() + pic, err := r.KeyValue(ctx, "o_modules") + if err != nil { + t.Fatal(err) + } + w, err := pic.Watch(ctx, "b") + if err != nil { + t.Fatal(err) + } + if _, err := kv.Put(ctx, "b", []byte(`{"n":2}`)); err != nil { + t.Fatal(err) + } + for e := range w.Updates() { + if e != nil && e.Key() == "b" { + break + } + } + if s := r.Followers()[0].Status(); s.Asks != 1 || s.Keys != 2 { + t.Fatalf("the change was not heard as an event alone: %+v", s) + } + if _, err := js.Publish(ctx, "mesh.mod.o.event.something-else", []byte("{}")); err != nil { + t.Fatal(err) + } + if got := <-other; got != "mesh.mod.o.event.something-else" { + t.Fatalf("other got %s", got) + } +} diff --git a/go/views/views.go b/go/views/views.go index 4e1a515..7414677 100644 --- a/go/views/views.go +++ b/go/views/views.go @@ -17,12 +17,11 @@ import ( // Version is the version of every value this package writes and the only one it reads. const Version = 1 -// The owners' views families (design 25 § 12.2; ADR 0615 §5, ADR 0619 §2): one stream each on its owner's old `streams` -// section, kept by limits 7 days and at most 256 MiB, dropping its oldest when full, so a reader that missed a dropped -// event sees the gap in revisions and asks once more. A stream's name is its owner's to declare; views names none. +// The owners' views families (design 25 § 12.2; ADR 0615 §5, ADR 0619 §2, novox/hq ADR 0635 §5): ordinary events of the +// owner, `mesh.mod..event.views.` on the shared events stream, kept as every event is; a reader that missed a +// dropped one sees the gap in revisions and asks its first view once more. An owner declares them among its `emits` +// (`views.`), a reader `consumes` `.views.`; views names no owner. const ( - StreamDays = 7 - StreamMiB = 256 // EventPrefix starts each event's local name: `views.`, one event per changed key of that state. EventPrefix = "views." // EventPattern is every event of a views family, as a stream's events and a reader's consumes name it. @@ -48,6 +47,19 @@ const Bound = 2 * time.Minute // EventName is the local name of the event saying a change of state: `views.`. func EventName(state string) string { return EventPrefix + state } +// OwnerOfEvent is the owner whose views event a subject is, `mesh.mod..event.views.`. +func OwnerOfEvent(subject string) (string, bool) { + rest, ok := strings.CutPrefix(subject, "mesh.mod.") + if !ok { + return "", false + } + owner, ev, ok := strings.Cut(rest, ".event.") + if !ok || owner == "" || strings.Contains(owner, ".") || StateOfEvent(ev) == "" { + return "", false + } + return owner, true +} + // StateOfEvent is the state an event's local name (or the last tokens of its subject) says, or "". func StateOfEvent(name string) string { if i := strings.LastIndex(name, ".event."+EventPrefix); i >= 0 { diff --git a/go/views/views_test.go b/go/views/views_test.go index 2aa94b5..272afb7 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -295,7 +295,7 @@ func TestViews(t *testing.T) { t.Errorf("asked again by hand: %v, %v", again, err) } }) - t.Run("a process's reader follows the read tool and views stream its caller names, reads the state as a read-only bucket, each key's written time handed on, and opens no other", func(t *testing.T) { + t.Run("a process's reader follows the read tool its caller names, reads the state as a read-only bucket, each key's written time handed on, and opens no other", func(t *testing.T) { r := &ProcessReader{Module: "reader", Bound: time.Nanosecond, Wait: time.Millisecond, Now: now, Call: func(tool string, body any) (json.RawMessage, error) { if m, _ := body.(map[string]any); tool != "o.o_read_modules" || m["values"] != true { @@ -305,13 +305,12 @@ func TestViews(t *testing.T) { raw, _ := json.Marshal(map[string]any{"result": a}) return raw, err }} - p, err := r.Follow("o", "modules", "o.o_read_modules", "o-views") + p, err := r.Follow("o", "modules", "o.o_read_modules") if err != nil { t.Fatal(err) } - for name, c := range map[string][2]string{"another owner's tool": {"x.read_more", "o-views"}, "no stream": {"o.read_more", ""}, - "a second stream for the owner": {"o.read_more", "o-other-views"}} { - if _, err := r.Follow("o", "more", c[0], c[1]); err == nil { + for name, tool := range map[string]string{"another owner's tool": "x.read_more", "no owner in the tool": "read_more"} { + if _, err := r.Follow("o", "more", tool); err == nil { t.Fatalf("%s is followed", name) // a picture no owner answers would hold Ready } } @@ -464,6 +463,12 @@ func TestViews(t *testing.T) { if s := StateOfEvent("mesh.stream.mesh-delivery-indexer.event.views.config-candidates"); s != "config-candidates" { t.Errorf("state of subject = %q", s) } + for subject, want := range map[string]string{"mesh.mod.o.event.views.modules": "o", "mesh.mod.o.event.views.a.b": "", "mesh.mod.o.event.other": "", + "mesh.stream.o.event.views.modules": "", "mesh.mod..event.views.modules": ""} { + if got, _ := OwnerOfEvent(subject); got != want { + t.Errorf("owner of %s = %q, want %q", subject, got, want) + } + } raw, err := ev(3, "k", `{"x":1}`).Encode() got, err2 := DecodeEvent(raw) if err != nil || err2 != nil || got.Revision != 3 || string(got.Value) != `{"x":1}` { diff --git a/go/views/viewstest/owner.go b/go/views/viewstest/owner.go index 4b62535..30321a3 100644 --- a/go/views/viewstest/owner.go +++ b/go/views/viewstest/owner.go @@ -14,18 +14,17 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" ) // Serve is owner's state on the bus with the owner's side of it: the bucket `_` (made when absent, history -// 1), the owner's marks, its views stream with a durable consumer per reader (`_`), the emitter saying -// each change, and the read tool answering on nc: tool is the owner's read tool as a reader's invokes names it -// (`.`), stream its views stream as its manifest's `streams` names it, both given as the reader's -// ProcessReader.Follow takes them. The tool answers a key's history too (key with history), from what the bucket keeps: +// 1), the owner's marks, the shared events stream (made when absent) with a durable consumer per reader +// (`_`), the emitter saying each change as the owner's ordinary event, and the read tool answering on nc: +// tool is the owner's read tool as a reader's invokes names it (`.`), as the reader's ProcessReader.Follow +// takes it. The tool answers a key's history too (key with history), from what the bucket keeps: // a test that reads history makes the bucket first with the history it needs. It returns the bucket the test writes. -func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state, tool, stream string, readers ...string) jetstream.KeyValue { +func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state, tool string, readers ...string) jetstream.KeyValue { t.Helper() bucket := owner + "_" + state kv, err := js.KeyValue(ctx, bucket) @@ -38,16 +37,17 @@ func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.C if err != nil { t.Fatal(err) } - stream = catalogue.StreamBusName(stream) - if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{process.ModuleStreamSubject(owner, views.EventPrefix+">")}}); err != nil { - t.Fatal(err) - } - for _, r := range readers { - if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: r, AckPolicy: jetstream.AckExplicitPolicy}); err != nil { + if _, err := js.Stream(ctx, process.EventsStream); err != nil { + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{"mesh.mod.*.event.>"}}); err != nil { t.Fatal(err) } } - em := views.Emitter{Owner: owner, Subject: func(e string) string { return process.ModuleStreamSubject(owner, e) }, + for _, r := range readers { + if _, err := js.CreateOrUpdateConsumer(ctx, process.EventsStream, jetstream.ConsumerConfig{Durable: r, AckPolicy: jetstream.AckExplicitPolicy}); err != nil { + t.Fatal(err) + } + } + em := views.Emitter{Owner: owner, Subject: func(e string) string { return process.Account{Module: owner}.EventSubject(e) }, Publish: views.JetStreamPublish(js), Marks: marks} if err := em.Start(ctx, state, kv, nil); err != nil { t.Fatal(err) @@ -82,10 +82,10 @@ func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.C } // Grants are the subjects a reader's account publishes to follow owner's state (its `invokes` and `consumes`): the -// read tool's call subjects, its durable consumer's info, next and acknowledgements on the owner's views stream. -func Grants(owner, tool, stream, durable string) []string { +// read tool's call subjects, its durable consumer's info, next and acknowledgements on the shared events stream. +func Grants(owner, tool, durable string) []string { tool = strings.TrimPrefix(tool, owner+".") - stream = catalogue.StreamBusName(stream) + stream := process.EventsStream return []string{"mesh.mod." + owner + ".call." + tool + ".>", "mesh.mod." + owner + ".call." + tool, "$JS.API.CONSUMER.INFO." + stream + "." + durable, "$JS.API.CONSUMER.MSG.NEXT." + stream + "." + durable, "$JS.ACK." + stream + "." + durable + ".>"} -- 2.54.0 From e67f85b5fefeecd68cdaa908f3c1960d8aa14014 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:37:53 +0100 Subject: [PATCH 104/144] seatlist: one test of the changed event heard before the seat answered, not two (issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/seatlist_test.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index c01ecc4..d1eca62 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -226,26 +226,6 @@ func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { } } -// A reader started while the seat was silent reads the whole list at the first event the seat sends, and then acts on any module. -func TestAReaderStartedWhileTheSeatWasSilentReadsTheWholeListAtItsFirstEvent(t *testing.T) { - owner := &fakeOwner{down: true, table: map[string]string{"node-b": def("node-b", "node", "put"), "node-c": def("node-c", "node", "put")}} - r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) - if err != nil { - t.Fatal(err) - } - <-r.Ready() - if r.Status().Heard || r.MayAct("node-b") { - t.Fatal("the silent seat was heard") - } - owner.down = false - if err := owner.say(ChangedEvent, "node-b", SourceTrunk); err != nil { - t.Fatal(err) - } - if !r.Status().Heard || !r.MayAct("node-b") || !r.Has("node-c") { - t.Fatalf("the event did not read the whole list: %+v", r.Status()) - } -} - // A reader started while the seat was silent is heard at the seat's changed event: the whole list is read then (ADR 0636 §1). func TestAReaderStartedWhileTheSeatWasSilentIsHeardAtItsChangedEvent(t *testing.T) { owner := &fakeOwner{down: true} -- 2.54.0 From 7ea9572f033308a7fae6e58d6eb208039ff7dfb7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:40:09 +0100 Subject: [PATCH 105/144] =?UTF-8?q?keys:=20the=20version=20check,=20CheckN?= =?UTF-8?q?ame=20and=20IsHex64=20move=20here=20from=20the=20Delivery=20dom?= =?UTF-8?q?ain's=20kept=20package,=20since=20Delivery=20and=20Identity=20a?= =?UTF-8?q?nd=20access=20share=20them=20(ADR=200634=20=C2=A71,=20=C2=A74;?= =?UTF-8?q?=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/keys/keys.go | 39 +++++++++++++++++++++++++++++++++++++++ go/keys/keys_test.go | 21 +++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/go/keys/keys.go b/go/keys/keys.go index 489b37c..d80aae5 100644 --- a/go/keys/keys.go +++ b/go/keys/keys.go @@ -5,6 +5,8 @@ package keys import ( "encoding/hex" + "encoding/json" + "errors" "fmt" "regexp" "strings" @@ -15,6 +17,43 @@ const Version = 1 var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) +// ErrUnknownVersion is what CheckVersion returns for a value whose version is missing or not Version. Test it with +// errors.Is. +var ErrUnknownVersion = errors.New("keys: unknown value version") + +// CheckVersion refuses a value with no version or one this reader does not read. +func CheckVersion(data []byte) error { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return fmt.Errorf("keys: value is not JSON: %w", err) + } + if probe.V == nil { + return fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownVersion, Version) + } + if *probe.V != Version { + return fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownVersion, *probe.V, Version) + } + return nil +} + +// CheckName refuses a name that is empty or holds a dot, a wildcard, whitespace or a character a bus key refuses. +func CheckName(kind, name string) error { + if name == "" { + return fmt.Errorf("keys: the %s name is empty", kind) + } + if !nameOK.MatchString(name) { + return fmt.Errorf("keys: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name) + } + return nil +} + +var hex64 = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// IsHex64 says whether s is 64 lowercase hex digits. +func IsHex64(s string) bool { return hex64.MatchString(s) } + // EscapeName is name as a key part: a character outside letters, digits, `-`, `/` and `_` is written `=` and its // two lowercase hex digits per byte, and `=` itself is escaped, so any non-empty name keys and a name of allowed // characters only keys as it is. UnescapeName is its inverse. diff --git a/go/keys/keys_test.go b/go/keys/keys_test.go index 25ed7f3..2f0a962 100644 --- a/go/keys/keys_test.go +++ b/go/keys/keys_test.go @@ -1,6 +1,7 @@ package keys import ( + "errors" "strings" "testing" ) @@ -27,3 +28,23 @@ func TestNamesEscapeIntoKeyPartsAndBack(t *testing.T) { t.Errorf("Version = %d: a kept value's readers read the one version", Version) } } + +func TestAKeptValueIsReadOnlyAtItsVersionAndAKeyPartOnlyOfKeyCharacters(t *testing.T) { + for data, ok := range map[string]bool{`{"v":1}`: true, `{"v":2}`: false, `{}`: false} { + err := CheckVersion([]byte(data)) + if (err == nil) != ok || (!ok && !errors.Is(err, ErrUnknownVersion)) { + t.Errorf("CheckVersion(%s) = %v", data, err) + } + } + if CheckVersion([]byte(`nope`)) == nil { + t.Error("a value that is no JSON is read") + } + for name, ok := range map[string]bool{"a-b_c/d=": true, "": false, "a.b": false, "a b": false, "a*": false} { + if (CheckName("module", name) == nil) != ok { + t.Errorf("CheckName(%q) = %v", name, CheckName("module", name)) + } + } + if !IsHex64(strings.Repeat("a", 64)) || IsHex64(strings.Repeat("A", 64)) || IsHex64("a") { + t.Error("IsHex64 reads a wrong form") + } +} -- 2.54.0 From e648fe53741f34db456556ec4fc72ec8c9397aab Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:07:39 +0100 Subject: [PATCH 106/144] =?UTF-8?q?process:=20one=20EventHub=20pulls=20the?= =?UTF-8?q?=20module's=20one=20durable=20consumer,=20handlers=20by=20subje?= =?UTF-8?q?ct=20pattern;=20the=20views=20reader=20registers=20on=20it=20in?= =?UTF-8?q?stead=20of=20pulling=20(class=20fix=20of=20B7c=20finding=201;?= =?UTF-8?q?=20ADR=200635=20=C2=A75;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/process/event_hub.go | 146 ++++++++++++++++++++ go/process/event_hub_test.go | 38 +++++ go/views/process_reader.go | 18 ++- go/views/process_reader_integration_test.go | 79 +++++++++++ 4 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 go/process/event_hub.go create mode 100644 go/process/event_hub_test.go diff --git a/go/process/event_hub.go b/go/process/event_hub.go new file mode 100644 index 0000000..7d0a6a8 --- /dev/null +++ b/go/process/event_hub.go @@ -0,0 +1,146 @@ +package process + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/nats-io/nats.go/jetstream" +) + +// EventHub is the one pull of a module's durable consumer on the shared events stream (novox/hq ADR 0635 §5): a module +// has one durable, `_`, so two loops pulling it each take messages the other needs. Every part of the +// module that hears events registers a handler by subject pattern, and the hub pulls once and hands each message to the +// handler whose pattern it matches. A message no handler takes is acknowledged and said, never left to be redelivered. +// The hub never makes the stream or the consumer: they are the controller's, and a bus without them is an error of Start. +// +// Register every handler, then Start once: a message heard before its handler is registered would be taken by none. +type EventHub struct { + logf func(string, ...any) + + mu sync.RWMutex + routes []hubRoute + started bool + cons jetstream.ConsumeContext + startErr error +} + +type hubRoute struct { + pattern string + handle func(jetstream.Msg) +} + +// NewEventHub is a hub saying through logf (nil says nothing). +func NewEventHub(logf func(string, ...any)) *EventHub { + if logf == nil { + logf = func(string, ...any) {} + } + return &EventHub{logf: logf} +} + +// Handle registers handle for the subjects matching pattern (`*` one token, `>` the rest). The handler acknowledges +// the message itself. A pattern that overlaps one registered (one matches every subject of the other) is refused, +// so a message always has one taker. +func (h *EventHub) Handle(pattern string, handle func(jetstream.Msg)) error { + if pattern == "" || handle == nil { + return fmt.Errorf("an event handler needs a subject pattern and a handler") + } + h.mu.Lock() + defer h.mu.Unlock() + for _, r := range h.routes { + if subjectsOverlap(r.pattern, pattern) { + return fmt.Errorf("the events %q overlap %q, already handled: a message has one taker", pattern, r.pattern) + } + } + h.routes = append(h.routes, hubRoute{pattern, handle}) + return nil +} + +// Patterns are the registered subject patterns, in order. +func (h *EventHub) Patterns() []string { + h.mu.RLock() + defer h.mu.RUnlock() + out := make([]string, len(h.routes)) + for i, r := range h.routes { + out[i] = r.pattern + } + return out +} + +// Start binds the module's durable on the shared events stream and pulls it; later calls do nothing and return the +// first one's result. It makes no stream and no consumer. +func (h *EventHub) Start(ctx context.Context, js jetstream.JetStream, cred Account) error { + h.mu.Lock() + defer h.mu.Unlock() + if h.started { + return h.startErr + } + h.started = true + cons, err := cred.EventConsumer(ctx, js, EventsStream) + if err == nil { + h.cons, err = HearEvents(cons, h.hear, h.logf) + } + if err != nil { + h.startErr = fmt.Errorf("the consumer %s on %s is not heard: %w", cred.EventConsumerName(), EventsStream, err) + } + return h.startErr +} + +// Stop ends the pull. +func (h *EventHub) Stop() { + h.mu.Lock() + defer h.mu.Unlock() + if h.cons != nil { + h.cons.Stop() + h.cons = nil + } +} + +func (h *EventHub) hear(m jetstream.Msg) { + h.mu.RLock() + var take func(jetstream.Msg) + for _, r := range h.routes { + if subjectMatches(r.pattern, m.Subject()) { + take = r.handle + break + } + } + h.mu.RUnlock() + if take == nil { + h.logf("events: %s is taken by no handler of this module, acknowledged", m.Subject()) + _ = m.Ack() + return + } + take(m) +} + +// subjectMatches says whether subject is one the pattern (`*` one token, `>` the rest) names. +func subjectMatches(pattern, subject string) bool { + pt, st := strings.Split(pattern, "."), strings.Split(subject, ".") + for i, t := range pt { + if t == ">" { + return len(st) > i + } + if i >= len(st) || (t != "*" && t != st[i]) { + return false + } + } + return len(pt) == len(st) +} + +// subjectsOverlap says whether some subject matches both patterns. +func subjectsOverlap(a, b string) bool { + at, bt := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; ; i++ { + if i >= len(at) || i >= len(bt) { + return len(at) == len(bt) + } + if at[i] == ">" || bt[i] == ">" { + return true + } + if at[i] != "*" && bt[i] != "*" && at[i] != bt[i] { + return false + } + } +} diff --git a/go/process/event_hub_test.go b/go/process/event_hub_test.go new file mode 100644 index 0000000..790e3f5 --- /dev/null +++ b/go/process/event_hub_test.go @@ -0,0 +1,38 @@ +package process + +import ( + "testing" + + "github.com/nats-io/nats.go/jetstream" +) + +func TestTheHubMatchesSubjectsAndRefusesOverlappingPatterns(t *testing.T) { + for _, c := range []struct { + a, b string + match, overlap bool + }{ + {"mesh.mod.*.event.views.*", "mesh.mod.o.event.views.x", true, true}, + {"mesh.mod.p.event.>", "mesh.mod.p.event", false, false}, + {"mesh.mod.p.event.a", "mesh.mod.q.event.a", false, false}, + {"mesh.mod.p.event.>", "mesh.mod.*.event.a", false, true}, + {"mesh.mod.p.event.a", "mesh.mod.p.event.a.b", false, false}, + } { + if got := subjectMatches(c.a, c.b); got != c.match && c.match { + t.Errorf("%s should match %s", c.a, c.b) + } + if got := subjectsOverlap(c.a, c.b); got != c.overlap { + t.Errorf("overlap(%s, %s) = %v", c.a, c.b, got) + } + } + h := NewEventHub(nil) + nop := func(jetstream.Msg) {} + if err := h.Handle("mesh.mod.p.event.>", nop); err != nil { + t.Fatal(err) + } + if err := h.Handle("mesh.mod.*.event.a", nop); err == nil { + t.Fatal("an overlapping pattern was accepted") + } + if err := h.Handle("mesh.mod.q.event.>", nop); err != nil { + t.Fatal(err) + } +} diff --git a/go/views/process_reader.go b/go/views/process_reader.go index 1ab7611..9cc1d74 100644 --- a/go/views/process_reader.go +++ b/go/views/process_reader.go @@ -46,6 +46,11 @@ type ProcessReader struct { // views event, so a module that hears other events through the same consumer (a module has one) handles them there. // Nil says them in the log, acknowledged: the module hears views events alone. Other func(jetstream.Msg) + // Hub is the module's one pull of its durable consumer (process.EventHub), when other parts of the module hear events + // too: the reader registers its views events on it and does not pull; the host starts the hub once, after every part + // registered. Nil: the reader pulls the durable itself and takes every message (Other the rest), for a module that + // hears nothing else. + Hub *process.EventHub mu sync.Mutex pics map[string]*Picture // by bucket name _ @@ -528,14 +533,21 @@ func (e entry) Created() time.Time { return e.at } func (e entry) Delta() uint64 { return 0 } func (e entry) Operation() jetstream.KeyValueOp { return e.op } +// EventsPattern is the subjects of every owner's views events on the shared events stream. +const EventsPattern = "mesh.mod.*.event." + EventPrefix + "*" + // Start hears the owners' views events on the process's durable consumer of the shared events stream (its manifest's // `consumes` `.views.`), keeps each status in the process's state `first-views` and hears the hand asks the // tools bundle's `_first_view_again` writes there (StatusTools), then asks every first view (Ask). A consumer the // bus does not have yet is said: the picture is then the first view's alone, asked again on a gap or by hand. func (r *ProcessReader) Start(ctx context.Context, js jetstream.JetStream, cred process.Account, status *process.AwaitedBucket) { - cons, err := cred.EventConsumer(ctx, js, process.EventsStream) - if err == nil { - _, err = process.HearEvents(cons, r.Hear(ctx), r.logf) + hub, pattern, own := r.Hub, EventsPattern, false + if hub == nil { + hub, pattern, own = process.NewEventHub(r.logf), ">", true + } + err := hub.Handle(pattern, r.Hear(ctx)) + if err == nil && own { + err = hub.Start(ctx, js, cred) } if err != nil { r.logf("the consumer %s on %s is not heard (%v): the states followed are the first views' alone, asked again by hand, until the %s starts again", diff --git a/go/views/process_reader_integration_test.go b/go/views/process_reader_integration_test.go index ab740da..e8310e0 100644 --- a/go/views/process_reader_integration_test.go +++ b/go/views/process_reader_integration_test.go @@ -78,3 +78,82 @@ func TestAReaderHearsAnOwnersChangesAsOrdinaryEvents(t *testing.T) { t.Fatalf("other got %s", got) } } + +// A module has one durable consumer on the shared events stream, so the loops that hear events in one module must share +// one pull (process.EventHub): two handlers and the views reader on the hub each get their own messages and no other's, +// and a message nobody takes is acknowledged. +func TestTheViewsReaderAndTwoHandlersShareTheModulesOneDurable(t *testing.T) { + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cred := process.Account{Node: "n1", Module: "reader"} + kv := viewstest.Serve(t, ctx, js, nc, "o", "modules", "o.o_read_modules", cred.EventConsumerName()) + hub := process.NewEventHub(t.Logf) + got := map[string]chan string{"a": make(chan string, 8), "b": make(chan string, 8)} + for name, pattern := range map[string]string{"a": "mesh.mod.p.event.a", "b": "mesh.mod.q.event.b.*"} { + if err := hub.Handle(pattern, func(m jetstream.Msg) { _ = m.Ack(); got[name] <- m.Subject() }); err != nil { + t.Fatal(err) + } + } + r := &views.ProcessReader{Module: "reader", Hub: hub, Logf: t.Logf, + Call: func(tool string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + m, err := nc.Request("mesh.mod.o.call.o_read_modules.n0", raw, 5*time.Second) + if err != nil { + return nil, err + } + return m.Data, nil + }} + if _, err := r.Follow("o", "modules", "o.o_read_modules"); err != nil { + t.Fatal(err) + } + r.Start(ctx, js, cred, nil) + if err := hub.Start(ctx, js, cred); err != nil { + t.Fatal(err) + } + <-r.Ready() + pic, err := r.KeyValue(ctx, "o_modules") + if err != nil { + t.Fatal(err) + } + w, err := pic.Watch(ctx, "b") + if err != nil { + t.Fatal(err) + } + for _, s := range []string{"mesh.mod.p.event.a", "mesh.mod.q.event.b.c", "mesh.mod.z.event.nobody", "mesh.mod.p.event.a"} { + if _, err := js.Publish(ctx, s, []byte("{}")); err != nil { + t.Fatal(err) + } + } + if _, err := kv.Put(ctx, "b", []byte(`{"n":2}`)); err != nil { + t.Fatal(err) + } + for e := range w.Updates() { + if e != nil && e.Key() == "b" { + break + } + } + for _, want := range []struct{ who, subject string }{{"a", "mesh.mod.p.event.a"}, {"b", "mesh.mod.q.event.b.c"}, {"a", "mesh.mod.p.event.a"}} { + if s := <-got[want.who]; s != want.subject { + t.Fatalf("handler %s got %s, wanted %s", want.who, s, want.subject) + } + } + if len(got["a"])+len(got["b"]) != 0 { + t.Fatal("a handler heard a message that was another's") + } + cons, err := js.Consumer(ctx, process.EventsStream, cred.EventConsumerName()) + if err != nil { + t.Fatal(err) + } + if info, err := cons.Info(ctx); err != nil || info.NumAckPending != 0 { + t.Fatalf("a message was left unacknowledged: %+v %v", info, err) + } +} -- 2.54.0 From 52ab209fcfc49af1c67a62599bb78988288a4951 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:10:17 +0100 Subject: [PATCH 107/144] =?UTF-8?q?seatlisttest=20equals=20the=20contracts?= =?UTF-8?q?=20on=20the=20join=20heads=20(renamed=20seats=20mesh-node-enrol?= =?UTF-8?q?ler,=20mesh-module-assigner;=20domain;=20B2's=20delivery=20seat?= =?UTF-8?q?s);=20the=20bootstrap=20is=20held=20equal=20to=20it=20by=20a=20?= =?UTF-8?q?test;=20the=20compiled-list=20golden=20is=20gone=20(B7c=20findi?= =?UTF-8?q?ng=202;=20ADR=200634=20=C2=A79,=20ADR=200636=20=C2=A77;=20issue?= =?UTF-8?q?=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/catalogue/own_manifest_test.go | 5 +- go/catalogue/seats_test.go | 111 - go/catalogue/test_seats_test.go | 4 +- go/catalogue/testdata/seats-before-move.json | 4810 ----------------- go/catalogue/verbs_test.go | 6 +- go/seatlist/bootstrap/mesh-catalog.json | 141 +- go/seatlist/bootstrap/node-engine.json | 1052 +++- go/seatlist/bootstrap/operator-channel.json | 295 +- go/seatlist/seatlist_test.go | 21 + .../seatlisttest/parts/build-step.json | 721 +++ .../seats/container-image-registry.json | 137 +- .../seatlisttest/seats/mesh-catalog.json | 986 +++- .../seatlisttest/seats/mesh-conditions.json | 563 +- .../seatlisttest/seats/mesh-config.json | 49 + .../seats/mesh-delivery-checker.json | 390 ++ .../seatlisttest/seats/mesh-delivery.json | 346 +- .../seatlisttest/seats/mesh-hand-acts.json | 231 +- .../seatlisttest/seats/mesh-healers.json | 182 +- .../seats/mesh-module-assigner.json | 942 ++++ ...enrolment.json => mesh-node-enroller.json} | 189 +- .../seatlisttest/seats/mesh-placement.json | 138 - .../seatlisttest/seats/mesh-provisioner.json | 387 +- .../seatlisttest/seats/mesh-self-check.json | 129 +- .../seatlisttest/seats/mesh-vault.json | 137 +- .../seatlisttest/seats/mesh-wants.json | 234 +- .../seatlisttest/seats/mesh-watchdogs.json | 155 +- .../seatlisttest/seats/node-build-go.json | 83 +- .../seatlisttest/seats/node-build-image.json | 83 +- .../seatlisttest/seats/node-build-node.json | 83 +- .../seatlisttest/seats/node-engine.json | 1595 ++++++ .../seats/oci-artifact-registry.json | 137 +- .../seatlisttest/seats/operator-channel.json | 295 +- .../seats/the-private-network.json | 15 +- go/testdata/seat-names.txt | 1 - 34 files changed, 9407 insertions(+), 5246 deletions(-) delete mode 100644 go/catalogue/testdata/seats-before-move.json create mode 100644 go/seatlist/seatlisttest/parts/build-step.json create mode 100644 go/seatlist/seatlisttest/seats/mesh-module-assigner.json rename go/seatlist/seatlisttest/seats/{mesh-enrolment.json => mesh-node-enroller.json} (51%) delete mode 100644 go/seatlist/seatlisttest/seats/mesh-placement.json diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index 21ae5f8..dae109f 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -45,8 +45,11 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { added = append(added, r) } } + // The two seats the milestone renamed (ADR 0634 §9) are read by their new names; a node-engine built with the baseline + // reads the old ones, and a manifest of the new names is the milestone's to deliver after the engines. + renamed := []string{"seat:mesh-enrolment", "seat:mesh-placement"} for _, b := range baseline { - if !slices.Contains(reads, b) { + if !slices.Contains(reads, b) && !slices.Contains(renamed, b) { t.Errorf("the baseline's %s is no reader capability of this contract", b) } } diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index f899561..e4e5217 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -1,10 +1,6 @@ package catalogue import ( - "encoding/json" - "os" - "reflect" - "slices" "testing" ) @@ -34,111 +30,4 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { if len(Seats()) != 1 { t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats())) } - - // **The compiled set is the generated one, and the move changed nothing a reader sees** (design 26, SD-M3, SDK-8): - // every seat the list mesh-sdk compiled before the move held (testdata, written from 5a38a12) is in the set - // generated from mesh-contracts' definitions, entry for entry, its verbs' optional marks and a kind's code - // included. A seat defined whole may add verbs, events and answers; the vault drops accept and move-asks (ADR 0620 - // §3); three registry seats are new. - UseSeats(DefaultSeats()) - type golden struct { - Seat - Optional []string - ShapeCode []string - } - raw, err := os.ReadFile("testdata/seats-before-move.json") - if err != nil { - t.Fatal(err) - } - var compiled []golden - if err := json.Unmarshal(raw, &compiled); err != nil { - t.Fatal(err) - } - whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"} - dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} - // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. - addedVerbs := map[string]bool{} - // A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the - // checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it). - retyped := map[string]bool{"node-engine.compose-candidate": true} - // A seat whose provision retired delivers nothing since: the store's, artifact-store (ADR 0632 §2). - retired := map[string]bool{"mesh-artifact-store": true} - norm := func(v any) any { - b, _ := json.Marshal(v) - var out any - _ = json.Unmarshal(b, &out) - return out - } - var names []string - for _, want := range compiled { - names = append(names, want.Name) - got, ok := SeatNamed(want.Name) - if !ok { - t.Errorf("%s left the generated set", want.Name) - continue - } - if retired[want.Name] { - want.Delivers = "" - } - g := golden{Seat: got} - for _, v := range got.Serves { - if v.Optional { - g.Optional = append(g.Optional, v.Name) - } - } - for _, r := range got.Receives { - if r.Shape != nil && (r.Shape.Check != nil || r.Shape.Reads != nil) { - g.ShapeCode = append(g.ShapeCode, r.Kind) - } - } - g.Serves = slices.DeleteFunc(slices.Clone(g.Serves), func(v Verb) bool { return addedVerbs[want.Name+"."+v.Name] }) - g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool { return addedVerbs[want.Name+"."+n] }) - if slices.Contains(whole, want.Name) { - want := &want - byName := map[string]Verb{} - for _, v := range got.Serves { - v.Output = nil - byName[v.Name] = v - } - var kept []Verb - for _, v := range want.Serves { - if dropped[want.Name+"."+v.Name] { - if _, still := byName[v.Name]; still { - t.Errorf("%s still serves %s", want.Name, v.Name) - } - continue - } - kv := byName[v.Name] - if retyped[want.Name+"."+v.Name] { - kv.Input = v.Input - } - kept = append(kept, kv) - } - g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts - // A verb the whole seat added (new, optional while its holders catch up) is no verb of the compiled list. - g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool { - return !slices.ContainsFunc(want.Serves, func(v Verb) bool { return v.Name == n }) - }) - if len(g.Optional) == 0 { - g.Optional = nil - } - want.Optional = slices.DeleteFunc(want.Optional, func(n string) bool { return dropped[want.Name+"."+n] }) - want.Serves = slices.DeleteFunc(slices.Clone(want.Serves), func(v Verb) bool { return dropped[want.Name+"."+v.Name] }) - } - if !reflect.DeepEqual(norm(g), norm(want)) { - gb, _ := json.Marshal(g) - wb, _ := json.Marshal(want) - t.Errorf("%s is not as the compiled list held it:\n got %s\nwant %s", want.Name, gb, wb) - } - } - var added []string - for _, s := range DefaultSeats() { - if !slices.Contains(names, s.Name) { - added = append(added, s.Name) - } - } - slices.Sort(added) - if want := []string{"container-image-registry", "go-package-registry", "oci-artifact-registry"}; !slices.Equal(added, want) { - t.Errorf("the generated set adds %v, want %v", added, want) - } } diff --git a/go/catalogue/test_seats_test.go b/go/catalogue/test_seats_test.go index 3862dce..4d6531e 100644 --- a/go/catalogue/test_seats_test.go +++ b/go/catalogue/test_seats_test.go @@ -18,8 +18,8 @@ func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) } const ( CatalogSeat = "mesh-catalog" CheckerSeat = "mesh-delivery-checker" - EnrolmentSeat = "mesh-enrolment" - PlacementSeat = "mesh-placement" + EnrolmentSeat = "mesh-node-enroller" + PlacementSeat = "mesh-module-assigner" DeliverySeat = "mesh-delivery" WantsSeat = "mesh-wants" HandActsSeat = "mesh-hand-acts" diff --git a/go/catalogue/testdata/seats-before-move.json b/go/catalogue/testdata/seats-before-move.json deleted file mode 100644 index 0bd3492..0000000 --- a/go/catalogue/testdata/seats-before-move.json +++ /dev/null @@ -1,4810 +0,0 @@ -[ - { - "Name": "mesh-store", - "Scope": "mesh", - "Replicated": false, - "Delivers": "postgres-database", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "databases", - "description": "Every database the store holds, with its on-disk size.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "psql -l" - ] - }, - { - "name": "query", - "description": "One read-only statement against one database the store holds.", - "input": { - "properties": { - "database": { - "description": "the database to query", - "type": "string" - }, - "sql": { - "description": "the read-only statement", - "type": "string" - } - }, - "required": [ - "database", - "sql" - ], - "type": "object" - }, - "replaces": [ - "psql" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0079" - }, - { - "Name": "mesh-broker", - "Scope": "mesh", - "Replicated": false, - "Delivers": "mesh-bus", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0079" - }, - { - "Name": "mesh-vault", - "Scope": "mesh", - "Replicated": false, - "Delivers": "secret", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "get", - "description": "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the bus proved it. Never the value in the clear, and never to a private key.", - "input": { - "properties": { - "name": { - "description": "the secret's name", - "type": "string" - }, - "public_key": { - "description": "the asker's X25519 public key as SPKI PEM — never a private key", - "type": "string" - } - }, - "required": [ - "name", - "public_key" - ], - "type": "object" - } - }, - { - "name": "accept", - "description": "Take a value given at the controller's terminal, from the controller alone: name the secret and get a call and a key made for it, then send the call and the value sealed to that key. The vault alone opens it, and never remakes an accepted credential.", - "input": { - "properties": { - "by": { - "description": "who gave it at the terminal", - "type": "string" - }, - "call": { - "description": "the second step: the call the first step answered", - "type": "string" - }, - "module": { - "description": "the module, for a module's own secret", - "type": "string" - }, - "name": { - "description": "the secret's name in the module", - "type": "string" - }, - "node": { - "description": "the machine, for a module's own secret", - "type": "string" - }, - "origin": { - "description": "issued for a bus account the controller minted for a core or moved module and hands at once (ADR 0374, issue 787); left out for a value given at the terminal", - "type": "string" - }, - "provider": { - "description": "the provider's machine, for a provision's credential", - "type": "string" - }, - "provision": { - "description": "the provision", - "type": "string" - }, - "sealed": { - "description": "the second step: the value sealed to the call's key — never the value", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "secret-ask", - "description": "Ask the operator for a module's own secret at a desk, from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR 0563 §8): the ask is recorded in the vault's store and bounded before the prompt opens, and what is typed is sealed to a key the vault made for the call and opens there alone.", - "input": { - "properties": { - "asked_by": { - "description": "who asked, as the bus named its caller", - "type": "string" - }, - "call": { - "description": "the second step: the call the first step answered", - "type": "string" - }, - "desk": { - "description": "the machine whose prompt asks", - "type": "string" - }, - "module": { - "description": "the module", - "type": "string" - }, - "name": { - "description": "the secret's name in the module", - "type": "string" - }, - "node": { - "description": "the machine the secret is for", - "type": "string" - }, - "outcome": { - "description": "instead of sealed: how the prompt ended", - "type": "string" - }, - "sealed": { - "description": "the second step: the value sealed to the call's key — never the value", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "issued", - "description": "Take the credential a provider's software issued for a provision, from the controller alone, which asks the provider's tool with a key the vault made for the call; kept as the provision's one credential, or, when the tool answers a value per holder named, each as that holder's own; the same value again writes nothing.", - "input": { - "properties": { - "call": { - "description": "the second step: the call the first step answered", - "type": "string" - }, - "holders": { - "description": "the provision's holders from that provider, each {node, module}: its tool may answer a value for each, kept as that holder's own and handed to it alone", - "items": { - "properties": { - "module": { - "description": "the consuming module", - "type": "string" - }, - "node": { - "description": "the holder's machine", - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - }, - "module": { - "description": "the provider's module", - "type": "string" - }, - "provider": { - "description": "the provider's machine", - "type": "string" - }, - "provision": { - "description": "the provision", - "type": "string" - }, - "sealed": { - "description": "the second step: the value sealed to the call's key — never the value", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "rotate", - "description": "Make a secret anew. Refused for a value a provider's software issued and for an accepted credential. Never answers the value.", - "input": { - "properties": { - "by": { - "description": "who asked, when the controller carries the act", - "type": "string" - }, - "module": { - "description": "the module", - "type": "string" - }, - "name": { - "description": "the secret's name in the module", - "type": "string" - }, - "node": { - "description": "the machine, for a module's own secret", - "type": "string" - }, - "provider": { - "description": "the provider's machine", - "type": "string" - }, - "provision": { - "description": "the provision", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "move-in", - "description": "Take one value of the controller's store in, as it is, from the node-engine of a machine it was sealed to and no other caller: name the row, its origin and generation and get a call and a key made for it, then send the call, the value sealed to that key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read back sealed to that key.", - "input": { - "properties": { - "call": { - "description": "the second step: the call the first step answered", - "type": "string" - }, - "consumer": { - "description": "the consumer's machine", - "type": "string" - }, - "consumer_module": { - "description": "the consuming module", - "type": "string" - }, - "generation": { - "description": "the controller's generation for the value", - "type": "string" - }, - "local": { - "description": "the credential's local name, when it has one", - "type": "string" - }, - "made_at": { - "description": "when it was made (RFC 3339)", - "type": "string" - }, - "module": { - "description": "its module", - "type": "string" - }, - "name": { - "description": "its name in the module", - "type": "string" - }, - "node": { - "description": "a module's own secret: its machine", - "type": "string" - }, - "origin": { - "description": "made, accepted or issued, as the controller's store says", - "type": "string" - }, - "provider": { - "description": "a pair credential: the provider's machine", - "type": "string" - }, - "provision": { - "description": "the provision", - "type": "string" - }, - "read_back_to": { - "description": "the second step: a raw X25519 public key the read-back is sealed to", - "type": "string" - }, - "sealed": { - "description": "the second step: the value sealed to the call's key — never the value", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "node-key", - "description": "State this machine's sealing key to the vault, in its own name, from the node-engine of the machine alone: at its start and whenever the key changes. A first or changed key is recorded and the machine's core secrets are said changed; the same key again changes nothing.", - "input": { - "properties": { - "key": { - "description": "the machine's sealing key, a raw X25519 public key in base64", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "own", - "description": "Answer the node-engine of one machine, about its own machine alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A credential the vault does not hold is named missing; never a value in the clear.", - "input": { - "properties": { - "credentials": { - "description": "the pair credentials the machine's composition reads, each {role: consumer|provider, provision, consumer, consumer_module, provider, local}", - "items": { - "type": "object" - }, - "type": "array" - } - }, - "type": "object" - } - }, - { - "name": "move-asks", - "description": "Copy rows of the controller's record of asks at a desk into the vault's, as they are and once, from the controller alone. No value is in any of them.", - "input": { - "properties": { - "asks": { - "description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome", - "items": { - "type": "object" - }, - "type": "array" - } - }, - "type": "object" - } - }, - { - "name": "issue-account", - "description": "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.", - "input": { - "properties": { - "user": { - "description": "the bus user, e.g. ace.mesh-build-go", - "type": "string" - } - }, - "required": [ - "user" - ], - "type": "object" - } - }, - { - "name": "sign", - "description": "Sign with the vault's key or authority (novox/hq ADR 0550 §2): kind node-certificate signs the certificate of a machine's internal name, asked by that machine's node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", - "input": { - "properties": { - "kind": { - "description": "node-certificate or enrolment", - "type": "string" - }, - "node": { - "description": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", - "type": "string" - }, - "reply": { - "description": "the redeem answer's bytes in base64, for an enrolment", - "type": "string" - }, - "serving_key": { - "description": "from mesh-enrolment: the serving key's public half the enrolling node presented", - "type": "string" - } - }, - "required": [ - "kind" - ], - "type": "object" - } - }, - { - "name": "enrolment-token", - "description": "Make a one-time token for a node mesh-enrolment issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash written as the bus user enrol.\u003cnode\u003e. Answers only what the joining machine must trust (the bus's address, the bus certificate's fingerprint, the vault's public signing key), never the token; with sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", - "input": { - "properties": { - "drop": { - "description": "\"true\": drop the node's token and its hash", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "expires": { - "description": "when the token expires (RFC 3339), as the enrolment's key says", - "type": "string" - }, - "node": { - "description": "the node the token is for", - "type": "string" - }, - "sealed": { - "description": "\"true\": answer the token sealed to the operator key", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "tunnel": { - "additionalProperties": false, - "description": "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", - "properties": { - "address": { - "description": "the joining node's tunnel address", - "type": "string" - }, - "hub_endpoint": { - "description": "the hub's endpoint, host:port", - "type": "string" - }, - "hub_key": { - "description": "the hub's tunnel public key", - "type": "string" - }, - "key": { - "description": "the joining machine's own tunnel public key, as given at issue", - "type": "string" - }, - "range": { - "description": "the private network's range, a prefix", - "type": "string" - } - }, - "required": [ - "address", - "hub_endpoint", - "hub_key", - "key", - "range" - ], - "type": "object" - } - }, - "required": [ - "node" - ], - "type": "object" - } - }, - { - "name": "node-account", - "description": "Mint a joining node's bus account node.\u003cnode\u003e, sealed to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the node's node-logins key naming node.\u003cnode\u003e. Asked by mesh-enrolment alone. Never the value in the clear.", - "input": { - "properties": { - "keys_digest": { - "description": "the digest of every public key the node presented (delivery.KeysDigest)", - "type": "string" - }, - "node": { - "description": "the joining node", - "type": "string" - }, - "retire": { - "description": "\"true\": retire the account minted for this node", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "sealing_key": { - "description": "the sealing key's public half the node presented, which the account is sealed to", - "type": "string" - } - }, - "required": [ - "node" - ], - "type": "object" - } - }, - { - "name": "rotate-node", - "description": "Begin a node's account rotation to its other login: answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.", - "input": { - "properties": { - "act": { - "description": "the id of the hand act recorded for it in mesh-hand-acts", - "type": "string" - }, - "node": { - "description": "the node whose account rotates", - "type": "string" - }, - "why": { - "description": "why, in a sentence", - "type": "string" - } - }, - "required": [ - "node", - "why", - "act" - ], - "type": "object" - } - }, - { - "name": "node-account-confirm", - "description": "Confirm a node's switch to its new login: asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. The old login's hash is then removed, and rotation-done said once the bus node's have shows the list without it.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "abandon", - "description": "Abandon a node's rotation before its old login is retired: the new login and its value dropped, the old one kept; a node that switched switches back first.", - "input": { - "properties": { - "node": { - "description": "the node whose rotation is abandoned", - "type": "string" - } - }, - "required": [ - "node" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0161", - "Optional": [ - "get", - "accept", - "secret-ask", - "issued", - "rotate", - "move-in", - "node-key", - "own", - "move-asks", - "issue-account", - "sign", - "enrolment-token", - "node-account", - "rotate-node", - "node-account-confirm", - "abandon" - ] - }, - { - "Name": "mesh-config", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "config", - "description": "Set, clear, list or read the history of config values: mesh config or node config for a predefined key, or a module's own value on one node, each set with why, validated, and naming each module on each node whose value moves.", - "input": { - "properties": { - "action": { - "description": "list (when not given), set, clear or history", - "type": "string" - }, - "by": { - "description": "who gave the value at the controller's terminal", - "type": "string" - }, - "key": { - "description": "the config key", - "type": "string" - }, - "limit": { - "description": "for history: at most this many, newest", - "type": "number" - }, - "module": { - "description": "a module: its own value, with node", - "type": "string" - }, - "node": { - "description": "a node", - "type": "string" - }, - "value": { - "description": "for set: the value, of the key's type: a number, a string, a boolean, or for a trusted key an object or a list" - }, - "why": { - "description": "for set and clear: why", - "type": "string" - } - }, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0348, ADR 0565", - "Optional": [ - "config" - ] - }, - { - "Name": "mesh-provisioner", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "provisioning", - "description": "Read what the provisioner keeps: the registry (which domain answers which kind of placeholder), the needs each module on each node recorded, what is applied where, or the items of every collection, narrowed by node and module. Reads only.", - "input": { - "properties": { - "collection": { - "description": "for collect: a collection", - "type": "string" - }, - "module": { - "description": "a module: for collect, a contributor", - "type": "string" - }, - "node": { - "description": "a node", - "type": "string" - }, - "what": { - "description": "registry (when not given), needs, applied or collect", - "type": "string" - } - }, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0349", - "Optional": [ - "provisioning" - ] - }, - { - "Name": "mesh-artifact-store", - "Scope": "mesh", - "Replicated": false, - "Delivers": "artifact-store", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0075" - }, - { - "Name": "mesh-catalog", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "modules", - "description": "Every module the trunk holds, each with its repository, its directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the catalog of the mesh, read from its holder's state. Reads only.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "module", - "description": "One module whole: its repository, directory, commit and fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.", - "input": { - "properties": { - "module": { - "description": "the module's name", - "type": "string" - } - }, - "required": [ - "module" - ], - "type": "object" - } - }, - { - "name": "provides", - "description": "Which modules provide a provision (a module provides its own name too) and which require it, from the module graph's provision edges. Reads only.", - "input": { - "properties": { - "provision": { - "description": "the provision, such as postgres-database", - "type": "string" - } - }, - "required": [ - "provision" - ], - "type": "object" - } - }, - { - "name": "dependents", - "description": "The modules whose build edges reach a module, directly or through others (a base, a mesh package it publishes, the build module that builds them): what a change of it builds again, from the module graph. Reads only.", - "input": { - "properties": { - "module": { - "description": "the module's name", - "type": "string" - } - }, - "required": [ - "module" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0604", - "Optional": [ - "modules", - "module", - "provides", - "dependents" - ] - }, - { - "Name": "mesh-delivery", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "deliveries", - "description": "Every delivery not final, and those that ended in the last day, one line each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, repository or group.", - "input": { - "properties": { - "all": { - "description": "\"true\": the final ones of the last thirty days too", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "group": { - "description": "a group's id (its branch name)", - "type": "string" - }, - "repository": { - "description": "owner/repository", - "type": "string" - }, - "state": { - "description": "one state, e.g. delivering or held", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "times", - "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the delivery view (nox-delivery): each machine's want and have, and since when", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "show", - "description": "One delivery or group whole: its delivery plan (what it builds, what each machine receives, what is not an ordinary send), every transition with when and why, the machine steps of its walk, its group and its order.", - "input": { - "properties": { - "id": { - "description": "a delivery's id, or a group's", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - } - }, - { - "name": "groups", - "description": "Every delivery group: its members in order, why each pair is ordered (declared, built by, version skew, engine before controller, by name), its composed check and its state, derived from its members.", - "input": { - "properties": { - "all": { - "description": "\"true\": the groups that ended too", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "what-if", - "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the merge check's composition, which composes every machine a change touches", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "checks", - "description": "What the mesh's checks said of a pull request's head or of one commit: each of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, mesh/delivery, …) with its state, description and when it was set; the merge check's full verdict as the controller said it — each layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's protection would let it merge, every required status being success.", - "input": { - "properties": { - "commit": { - "description": "a commit's sha, or the start of one, instead of a pull request", - "type": "string" - }, - "number": { - "description": "a pull request's number: its head is read", - "type": "string" - }, - "repository": { - "description": "owner/repository", - "type": "string" - } - }, - "required": [ - "repository" - ], - "type": "object" - } - }, - { - "name": "table", - "description": "The state table every delivery runs by: each transition with its guard, each state's bound and what healer H2 may do once it has passed; and the machine steps' table.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "stalled", - "description": "Every delivery held past its state's bound, with the transition the table lets healer H2 take for it, or none.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "recheck", - "description": "Check a rejected or ready delivery again: it goes back to proposed and the controller is asked for its check. With why.", - "input": { - "properties": { - "id": { - "description": "the delivery's id", - "type": "string" - }, - "why": { - "description": "why, kept with the transition", - "type": "string" - } - }, - "required": [ - "id", - "why" - ], - "type": "object" - } - }, - { - "name": "release", - "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "stop", - "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "close", - "description": "Healer H2's verb: take the one transition the table names for a delivery held past its bound. Refused for any other.", - "input": { - "properties": { - "id": { - "description": "the delivery's id", - "type": "string" - }, - "why": { - "description": "the condition it answers", - "type": "string" - } - }, - "required": [ - "id", - "why" - ], - "type": "object" - } - }, - { - "name": "retire-history", - "description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: nothing to retire: the gate keeps no delivery after its merge", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "settle", - "description": "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another reason, naming why.", - "input": { - "additionalProperties": false, - "properties": { - "ids": { - "description": "the held deliveries' ids (owner/repository@commit)", - "items": { - "type": "string" - }, - "minItems": 1, - "type": "array" - }, - "why": { - "description": "why they may be settled, shown to the operator", - "type": "string" - } - }, - "required": [ - "ids", - "why" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0239", - "Optional": [ - "times", - "what-if", - "checks", - "release", - "stop", - "retire-history", - "settle" - ] - }, - { - "Name": "mesh-placement", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "placement", - "description": "Read placement: one node's key, the nodes one module is placed on, or every key, each with its revision, and how full the state is against its cap. With history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the proposal's changes from its placement-changed event.", - "input": { - "properties": { - "history": { - "description": "\"true\": the changes in order, from the stream and the state's revisions", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "module": { - "description": "one module: the nodes it is placed on", - "type": "string" - }, - "node": { - "description": "one node", - "type": "string" - }, - "seats": { - "description": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "proposals", - "description": "Every open proposal of placement: its changes, the revision of each key it was laid over, its candidates, every node's answer, the differences computed and answered, its verdict and its ask. Narrowed to one by id.", - "input": { - "properties": { - "id": { - "description": "one proposal's id", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "propose", - "description": "Propose a change of placement, with why: laid over the current keys and refused at once, naming every finding, when the result cannot be placed (two modules on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", - "input": { - "additionalProperties": false, - "properties": { - "changes": { - "description": "the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)", - "items": { - "required": [ - "op", - "node" - ], - "type": "object" - }, - "minItems": 1, - "type": "array" - }, - "warrant": { - "description": "for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address", - "required": [ - "node", - "address", - "ask", - "warrant" - ], - "type": "object" - }, - "why": { - "description": "why, shown to the operator with the proposal", - "type": "string" - } - }, - "required": [ - "why", - "changes" - ], - "type": "object" - } - }, - { - "name": "withdraw", - "description": "Withdraw an open proposal: its key and its candidates are deleted and placement-withdrawn carries it whole. With why.", - "input": { - "properties": { - "id": { - "description": "the proposal's id", - "type": "string" - }, - "why": { - "description": "why, kept in the event", - "type": "string" - } - }, - "required": [ - "id", - "why" - ], - "type": "object" - } - }, - { - "name": "again", - "description": "Judge an open proposal now with the answers that stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. Nothing more: the recovery of a proposal whose judgement or ask was lost.", - "input": { - "properties": { - "id": { - "description": "the proposal's id", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0567", - "Optional": [ - "placement", - "proposals", - "propose", - "withdraw", - "again" - ] - }, - { - "Name": "mesh-delivery-checker", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "try", - "description": "Try a pushed head before its pull request is opened: the checker judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most 4 wait or run at once, mesh-wide.", - "input": { - "properties": { - "head": { - "description": "the full commit to try, 40 or 64 hex digits", - "type": "string" - }, - "level": { - "description": "unit or integration: only this level; both when absent", - "type": "string" - }, - "repository": { - "description": "\u003cowner\u003e/\u003cname\u003e", - "type": "string" - }, - "tests": { - "description": "a go test -run pattern: only those tests run, and nothing is kept", - "type": "string" - }, - "trunk": { - "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", - "type": "string" - } - }, - "required": [ - "repository", - "head" - ], - "type": "object" - }, - "replaces": [ - "sh merge-check.sh", - "docker run postgres" - ] - }, - { - "name": "try-read", - "description": "Where a try stands, as stored: queued, running, or its end (pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit it was merged onto, the time it took and what failed first. Read-only.", - "input": { - "properties": { - "id": { - "description": "the try's id, try-\u003cdigits\u003e", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - } - }, - { - "name": "try-end", - "description": "End a try that waits or runs, as error, by hand: say why (kept in what it says). Its place among the tries is free at once and the next try of its head asks anew with a new id. A try that ended already is refused.", - "input": { - "properties": { - "id": { - "description": "the try's id, try-\u003cdigits\u003e", - "type": "string" - }, - "why": { - "description": "why it is ended, in words", - "type": "string" - } - }, - "required": [ - "id", - "why" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0547", - "Optional": [ - "try", - "try-read", - "try-end" - ] - }, - { - "Name": "mesh-conditions", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "condition-raised", - "condition-changed", - "condition-cleared" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "conditions", - "description": "What is wrong with the mesh now: every open condition, urgent first, then oldest, raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition lately.", - "input": { - "properties": { - "days": { - "description": "with history: how many days back (default 7, at most 90)", - "type": "string" - }, - "history": { - "description": "\"true\": every raising, change, silence and clearing lately, oldest first", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "key": { - "description": "a condition's key: that one whole; with history, only its transitions", - "type": "string" - }, - "machine": { - "description": "only those about this machine", - "type": "string" - }, - "scope": { - "description": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module", - "type": "string" - }, - "severity": { - "description": "only urgent, or only warning", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "silence", - "description": "Stop one condition's messages for a while, a hand act with why: it stays open and in status, and the silence is kept as its transition in history.", - "input": { - "properties": { - "cause": { - "description": "the cause in a word (the condition's kind when absent)", - "type": "string" - }, - "for": { - "description": "how long: 30m, 4h, 2d; at most 7d", - "type": "string" - }, - "key": { - "description": "the condition's key", - "type": "string" - }, - "why": { - "description": "why, required and recorded", - "type": "string" - } - }, - "required": [ - "key", - "for", - "why" - ], - "type": "object" - } - }, - { - "name": "resync", - "description": "Read every observer's findings again and raise and clear what differs from the open conditions: the recovery of a keeper that missed a change.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0564" - }, - { - "Name": "mesh-watchdogs", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "signals", - "description": "Every watchdog of the signals table the watchman keeps, each a key that expires unless its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its expiry wrote.", - "input": { - "properties": { - "row": { - "description": "one row, e.g. S1", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "stuck", - "description": "Every want, build or step the watchman says stopped moving past its bound, with since when and the words of its finding.", - "input": { - "properties": { - "machine": { - "description": "only those on this machine", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "presence", - "description": "Every machine and tool runner the watchman sees connected, with its last word, and those it says silent; a machine that said it sleeps is shown as sleeping, not silent.", - "input": { - "properties": { - "machine": { - "description": "one machine", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "dead-letters", - "description": "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one whole. With deliver or drop, a hand act with why.", - "input": { - "properties": { - "cause": { - "description": "with deliver or drop: the cause in a word (dead-letter when absent)", - "type": "string" - }, - "consumer": { - "description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on", - "type": "string" - }, - "deliver": { - "description": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)", - "type": "string" - }, - "drop": { - "description": "a dead letter's id: drop it for good (needs why)", - "type": "string" - }, - "id": { - "description": "a dead letter's id, as the list gives it: that one whole, with its message", - "type": "string" - }, - "limit": { - "description": "how many to list, newest first (default 50); only when listing", - "type": "string" - }, - "why": { - "description": "with deliver or drop: why, required and recorded", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "resync", - "description": "Rebuild presence from the bus's connections, and the pulses and findings from the keys the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0564" - }, - { - "Name": "mesh-self-check", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "checked" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "doctor", - "description": "The self-check: the last run's verdict at once, each probe passed, failed, deferred (its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one probe's verdict whole. Runs every five minutes on its own; each failure is a finding.", - "input": { - "properties": { - "probe": { - "description": "one probe's id (D6): its verdict alone, with every finding", - "type": "string" - }, - "run": { - "description": "\"true\": run every probe now and answer the verdict", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "probes", - "description": "The registry: what each probe asserts, where it reads its facts, and the finding it writes.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0564" - }, - { - "Name": "mesh-healers", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "healer-acted" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "healers", - "description": "The healers: each registered response to one kind of condition, its repair, its budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.", - "input": { - "properties": { - "days": { - "description": "how many days of acts back (default 7)", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "heal", - "description": "Take one healer's act for one open condition now, within its budget and the brake.", - "input": { - "properties": { - "key": { - "description": "the condition's key", - "type": "string" - }, - "why": { - "description": "why now, kept with the act", - "type": "string" - } - }, - "required": [ - "key", - "why" - ], - "type": "object" - } - }, - { - "name": "drill", - "description": "Record a drill: something broken on purpose to see the mesh raise and clear it, with what it tests. A drill is never a repair: no healer is wanted for it however often it is run.", - "input": { - "properties": { - "condition": { - "description": "the key of the condition the drill is meant to raise, if any (optional)", - "type": "string" - }, - "what": { - "description": "what was done on purpose, in a line", - "type": "string" - }, - "why": { - "description": "what the drill tests", - "type": "string" - } - }, - "required": [ - "what", - "why" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0564" - }, - { - "Name": "mesh-wants", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "policy-changed" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "upgrade", - "description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", - "input": { - "properties": { - "compare": { - "description": "\"true\": each node's own composition, by its digest, and the assigner's findings", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "module": { - "description": "one module", - "type": "string" - }, - "policy": { - "description": "with module: roll, together, record, or default to take a person's choice back", - "type": "string" - }, - "why": { - "description": "with policy: why, required and kept", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "bus", - "description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0565" - }, - { - "Name": "mesh-hand-acts", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "act-recorded" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "hand-act", - "description": "Record one act done by hand: what, why, its cause in a word or a condition's kind (the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the runtime proves, never said by the caller. Said as act-recorded.", - "input": { - "properties": { - "cause": { - "description": "the cause in a word, or a condition's kind", - "type": "string" - }, - "condition": { - "description": "the key of the condition it addressed, if any (optional)", - "type": "string" - }, - "verb": { - "description": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", - "type": "string" - }, - "what": { - "description": "what was done, in a line", - "type": "string" - }, - "why": { - "description": "why it had to be done by hand", - "type": "string" - } - }, - "required": [ - "what", - "why" - ], - "type": "object" - } - }, - { - "name": "hand-acts", - "description": "What was done by hand lately, over this log and the controller's: who, why and the cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", - "input": { - "properties": { - "cause": { - "description": "only this cause", - "type": "string" - }, - "days": { - "description": "how many days back (default 14)", - "type": "string" - }, - "warrants": { - "description": "\"true\": every warrant recorded", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "who": { - "description": "only acts whose who contains this", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "warranted", - "description": "Record what a module did on the operator's warrant: who chose, through which channel, with which proofs and which answer read from the router's own record of that module's ask, never from the caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", - "input": { - "properties": { - "ask": { - "description": "its ask's id", - "type": "string" - }, - "asker": { - "description": "the module that asked", - "type": "string" - } - }, - "required": [ - "asker", - "ask" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0565" - }, - { - "Name": "mesh-enrolment", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "enrolment-issued", - "node-enrolled", - "enrolment-refused", - "enrolment-revoked", - "enrolment-expired" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "issue", - "description": "Issue a one-time token for a new machine, on the operator's Approve: refused at once, naming every finding, for a name that is not a node name or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise reserves the lowest free private-network address, asks the operator under the seat's own name and, on the warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.", - "input": { - "properties": { - "expires": { - "description": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours", - "type": "string" - }, - "node": { - "description": "the new machine's name in the mesh", - "type": "string" - }, - "overlay_key": { - "description": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it", - "type": "string" - }, - "why": { - "description": "why it joins, shown to the operator with the ask", - "type": "string" - } - }, - "required": [ - "node", - "why", - "overlay_key" - ], - "type": "object" - } - }, - { - "name": "redeem", - "description": "Spend a token: asked by a joining node-engine dialled as enrol.\u003cnode\u003e alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the vault for the node's account and certificate, writes the node into placement, and answers the account sealed to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys it finishes what is not done and answers the same; with other keys it is refused.", - "input": { - "additionalProperties": false, - "properties": { - "node": { - "description": "the node the token was issued for", - "type": "string" - }, - "overlay_key": { - "description": "the private network key's public half", - "type": "string" - }, - "profile": { - "description": "what the machine is, as its record says it", - "type": "object" - }, - "proof": { - "description": "the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64", - "type": "string" - }, - "public_key": { - "description": "the identity key's public half, base64", - "type": "string" - }, - "sealing_key": { - "description": "the sealing key's public half, which the account is sealed to", - "type": "string" - }, - "serving_key": { - "description": "the serving key's public half, which the certificate binds", - "type": "string" - }, - "tunnel": { - "description": "a tunnel found on the machine: everything but its private key", - "type": "object" - } - }, - "required": [ - "node", - "public_key", - "sealing_key", - "proof" - ], - "type": "object" - } - }, - { - "name": "confirm", - "description": "Confirm an enrolment: asked by the node-engine dialled as its own node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the enrolment.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "revoke", - "description": "End an enrolment at any stage, with why, without an Approve: withdraws an open ask, deletes the node's key and its address, has the vault retire an account it already minted for it, records the act as a hand act and says enrolment-revoked.", - "input": { - "properties": { - "node": { - "description": "the node", - "type": "string" - }, - "why": { - "description": "why, recorded with the hand act", - "type": "string" - } - }, - "required": [ - "node", - "why" - ], - "type": "object" - } - }, - { - "name": "tokens", - "description": "Read every open enrolment, or one: its stage, expiry, ask and revision, the bucket's fill against its cap. For a node at live, its token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With history, the ended enrolments from the seat's events, as far back as the events are kept.", - "input": { - "properties": { - "history": { - "description": "\"true\": the ended enrolments too", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "node": { - "description": "one node", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "again", - "description": "Raise an issue's ask again when the operator's channel does not hold it, and nothing more.", - "input": { - "properties": { - "node": { - "description": "the node whose ask is raised again", - "type": "string" - } - }, - "required": [ - "node" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0587" - }, - { - "Name": "mesh-artifact-sweep", - "Scope": "mesh", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "artifacts-gone" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "artifacts", - "description": "Every artifact the mesh made that lives in its artifact store, each with its repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", - "input": { - "properties": { - "collected": { - "description": "\"true\": list the collected references too (counted always)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "repository": { - "description": "only this repository's references, as \u003cmodule\u003e/\u003cartifact\u003e (the counts stay whole)", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "held", - "description": "Whether the artifact store holds each reference given, asked with one HEAD each: held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. Reads only.", - "input": { - "properties": { - "references": { - "description": "the artifacts' references, artifact-store://\u003cmodule\u003e/\u003cartifact\u003e/blobs/sha256:\u003chex\u003e: a list, or one text separated by commas", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "references" - ], - "type": "object" - } - }, - { - "name": "collect", - "description": "The artifact store's sweep, now: what is eligible is let go of. Without confirm a dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 seconds; what is left is said. Never touches a digest the mesh did not make.", - "input": { - "properties": { - "confirm": { - "description": "\"true\": let go of what is eligible (needs why); without it nothing is done", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "most": { - "description": "let go of at most this many (500 by default, at most 5000)", - "type": "string" - }, - "why": { - "description": "with confirm: why, required and recorded", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "mirrors", - "description": "The bases the mesh copied into its artifact store, each kept (a kept build stood on it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "images", - "description": "Every container image one machine's want and have name, each with the resources naming it: what the machine keeps when it prunes its images. Reads only.", - "input": { - "properties": { - "node": { - "description": "the machine's name", - "type": "string" - } - }, - "required": [ - "node" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0571" - }, - { - "Name": "npm-package-registry", - "Scope": "mesh", - "Replicated": false, - "Delivers": "npm-package-registry", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0109" - }, - { - "Name": "git", - "Scope": "mesh", - "Replicated": false, - "Delivers": "git", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0111" - }, - { - "Name": "node-build-go", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": [ - "delivery", - "check", - "recheck" - ], - "Emits": null, - "PulledByKind": true, - "Yields": true, - "Serves": [ - { - "name": "current", - "description": "The Go asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "pause", - "description": "Take no new Go ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "resume", - "description": "Take Go asks again on this node.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0525, ADR 0536, ADR 0553" - }, - { - "Name": "node-build-node", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": [ - "delivery", - "check", - "recheck" - ], - "Emits": null, - "PulledByKind": true, - "Yields": true, - "Serves": [ - { - "name": "current", - "description": "The Node asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "pause", - "description": "Take no new Node ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "resume", - "description": "Take Node asks again on this node.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0525, ADR 0536, ADR 0553" - }, - { - "Name": "node-build-image", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": [ - "delivery", - "check", - "recheck" - ], - "Emits": null, - "PulledByKind": true, - "Yields": true, - "Serves": [ - { - "name": "current", - "description": "The image asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "pause", - "description": "Take no new image ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "resume", - "description": "Take image asks again on this node.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0525, ADR 0536, ADR 0553" - }, - { - "Name": "mesh-dns-resolver", - "Scope": "mesh", - "Replicated": true, - "Delivers": "wildcard-resolution", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0194, ADR 0223" - }, - { - "Name": "node-hostname", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "entries", - "description": "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "cat /etc/hosts", - "getent hosts" - ] - }, - { - "name": "add", - "description": "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's.", - "input": { - "properties": { - "address": { - "description": "the IPv4 or IPv6 address", - "type": "string" - }, - "names": { - "description": "the names for it, separated by spaces", - "type": "string" - } - }, - "required": [ - "address", - "names" - ], - "type": "object" - }, - "replaces": [ - "edit /etc/hosts", - "HOSTALIASES" - ] - }, - { - "name": "remove", - "description": "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", - "input": { - "properties": { - "name": { - "description": "a host name, or an address to remove every line of", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "replaces": [ - "sed -i /etc/hosts" - ] - } - ], - "Receives": null, - "Paths": [ - { - "path": "/etc/hosts" - }, - { - "path": "/etc/hostname" - } - ], - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0199, ADR 0223" - }, - { - "Name": "node-intrusion-prevention", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "status", - "description": "Every jail on this machine with how many it is watching and holding now, and the totals since the jail started; one jail's detail when named.", - "input": { - "properties": { - "jail": { - "description": "one jail (optional)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "fail2ban-client status" - ] - }, - { - "name": "banned", - "description": "Every address banned on this machine right now, with the jail that holds it and when the ban ends.", - "input": { - "properties": { - "jail": { - "description": "one jail (optional)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "fail2ban-client banned" - ] - }, - { - "name": "ban", - "description": "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.", - "input": { - "properties": { - "ip": { - "description": "the address", - "type": "string" - }, - "jail": { - "description": "the jail to hold it", - "type": "string" - } - }, - "required": [ - "ip", - "jail" - ], - "type": "object" - }, - "replaces": [ - "fail2ban-client set banip" - ] - }, - { - "name": "unban", - "description": "Let one address go, from one jail or from every jail when none is named.", - "input": { - "properties": { - "ip": { - "description": "the address", - "type": "string" - }, - "jail": { - "description": "one jail (optional)", - "type": "string" - } - }, - "required": [ - "ip" - ], - "type": "object" - }, - "replaces": [ - "fail2ban-client unban", - "fail2ban-client set unbanip" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0121" - }, - { - "Name": "node-packet-filter", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "rules", - "description": "The packet filter as this machine enforces it now: the nftables ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or chain when asked.", - "input": { - "properties": { - "chain": { - "description": "one chain of that table (optional)", - "type": "string" - }, - "table": { - "description": "one nftables table, as `family name` (optional)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "nft list ruleset", - "iptables -L", - "iptables-save" - ] - }, - { - "name": "reload", - "description": "Load the mesh's own filter again from the file the mesh writes, and answer with the mesh's table as loaded.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "nft -f" - ] - }, - { - "name": "remove", - "description": "Remove one rule set the mesh did not write, named exactly as the host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an active found firewall's chains. An operator's act, by name, never a flush.", - "input": { - "properties": { - "where": { - "description": "the rule set, as `node show` lists it", - "type": "string" - } - }, - "required": [ - "where" - ], - "type": "object" - }, - "replaces": [ - "nft delete", - "iptables -X" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0121" - }, - { - "Name": "node-service-manager", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "units", - "description": "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.", - "input": { - "properties": { - "pattern": { - "description": "a glob the unit's name must match (optional)", - "type": "string" - }, - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "systemctl list-units" - ] - }, - { - "name": "status", - "description": "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl status", - "systemctl is-active" - ] - }, - { - "name": "start", - "description": "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl start" - ] - }, - { - "name": "stop", - "description": "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl stop" - ] - }, - { - "name": "restart", - "description": "Restart one unit.", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl restart" - ] - }, - { - "name": "enable", - "description": "Make one unit start at boot (or at the account's login, in user scope).", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl enable" - ] - }, - { - "name": "disable", - "description": "Stop one unit starting at boot (or at login, in user scope).", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl disable" - ] - }, - { - "name": "journal", - "description": "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as [redacted: \u003cwhat it was\u003e].", - "input": { - "properties": { - "lines": { - "description": "how many lines from the end of what matches (default 100, at most 2000)", - "type": "string" - }, - "match": { - "description": "only the lines holding this text, as written — a fixed string, not a pattern (optional)", - "type": "string" - }, - "priority": { - "description": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)", - "type": "string" - }, - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "since": { - "description": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - }, - "until": { - "description": "the window's end, in the same forms (optional; now when absent)", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "journalctl" - ] - }, - { - "name": "failed", - "description": "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, never as nothing failed.", - "input": { - "properties": { - "scope": { - "description": "\"system\" or \"user\": only that manager (both when absent)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "systemctl --failed" - ] - }, - { - "name": "reset-failed", - "description": "Clear one unit's failed record in its manager — its failed state and its start-limit count — and answer its state after. It removes no file and starts nothing.", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl reset-failed" - ] - }, - { - "name": "wanted-by", - "description": "What wants, requires or triggers one unit, read-only: the manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), each with where it points and whether it dangles — the account's own directory included in user scope.", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - } - }, - "required": [ - "unit" - ], - "type": "object" - }, - "replaces": [ - "systemctl list-dependencies --reverse", - "systemctl show -p WantedBy", - "ls ~/.config/systemd/user/*.wants" - ] - }, - { - "name": "unlink-dangling", - "description": "Remove the dangling enable links named for one unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the runtime placed, is left and said.", - "input": { - "properties": { - "scope": { - "description": "\"system\" (the default) or \"user\": the operator account's own manager", - "type": "string" - }, - "unit": { - "description": "the unit's name, as the service manager knows it", - "type": "string" - }, - "why": { - "description": "the person's reason for removing the links, kept in the record", - "type": "string" - } - }, - "required": [ - "unit", - "why" - ], - "type": "object" - }, - "replaces": [ - "rm ~/.config/systemd/user/*.wants/\u003cunit\u003e", - "unlink /etc/systemd/system/*.wants/\u003cunit\u003e" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0177", - "Optional": [ - "failed" - ] - }, - { - "Name": "node-package-manager", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0207" - }, - { - "Name": "node-container-runtime", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0166, ADR 0207" - }, - { - "Name": "node-environment", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0203" - }, - { - "Name": "node-provisioner", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0349" - }, - { - "Name": "node-login-shell", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "execute", - "description": "Run one command on this machine as the operator account, in a non-interactive login shell in its home; answers with what it printed and how it exited.", - "input": { - "properties": { - "command": { - "description": "the command line, as you would type it", - "type": "string" - }, - "timeout_seconds": { - "description": "give up after this long, at most 25 (default 20)", - "type": "string" - } - }, - "required": [ - "command" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0204", - "Optional": [ - "execute" - ] - }, - { - "Name": "node-power", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0211" - }, - { - "Name": "node-hotkeys", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": [ - { - "Kind": "trigger", - "Comment": "#", - "Dirs": false, - "Shape": null, - "Offered": false - } - ], - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0212" - }, - { - "Name": "node-message-bus", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0215" - }, - { - "Name": "node-backup", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "backed-up", - "description": "What this machine backs up: each module, what it declared, its last good night, how many restore points are kept and the repository's size.", - "input": { - "properties": { - "module": { - "description": "one module (optional)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "restic snapshots" - ] - }, - { - "name": "now", - "description": "Take a backup now, of one module or of every module on this machine — before a migration, a retirement or anything else that could go wrong.", - "input": { - "properties": { - "module": { - "description": "one module (optional)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "restic backup" - ] - }, - { - "name": "restore", - "description": "Restore one module's data from a restore point BESIDE the live data, never over it: each directory as \u003cpath\u003e.restored-\u003cdate\u003e. Swapping it in is a person's act. Lists the restore points when none is named.", - "input": { - "properties": { - "module": { - "description": "the module", - "type": "string" - }, - "path": { - "description": "one of the module's directories (all of them when omitted)", - "type": "string" - }, - "snapshot": { - "description": "the restore point (from `backed-up`; the newest when omitted)", - "type": "string" - } - }, - "required": [ - "module" - ], - "type": "object" - }, - "replaces": [ - "restic restore" - ] - } - ], - "Receives": [ - { - "Kind": "backup", - "Comment": "#", - "Dirs": true, - "Shape": null, - "Offered": false - }, - { - "Kind": "data", - "Comment": "#", - "Dirs": true, - "Shape": null, - "Offered": false - } - ], - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0214" - }, - { - "Name": "the-private-network", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0110" - }, - { - "Name": "node-uplink", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "resolvers", - "description": "The machine's resolver file as it is now: the resolvers, search domains and options it lists, whether it is the file the mesh declares, and when it is not, who wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link in its place, a known writer running.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "links", - "description": "Every network link on the machine: its state, its addresses, whether the default route leaves through it, and the resolvers and search domains the network manager knows for it — a VPN's tunnel included.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": [ - { - "path": "/etc/resolv.conf", - "steps-back-for": "node-resolver" - } - ], - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0117", - "Optional": [ - "resolvers", - "links" - ] - }, - { - "Name": "node-resolver", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "routes", - "description": "What this machine's own resolver sends where: the mesh's resolvers, which answer every name not routed elsewhere, and each link given servers of its own with the domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who wrote it as far as the file says, whether a module took it, and when the resolver's own file was put back.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "resolvectl status", - "resolvectl dns", - "resolvectl domain" - ] - }, - { - "name": "route", - "description": "Send these domains, and every name under them, to these servers over this link — and only them: the link is never the machine's default route for names, and the mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away takes its route with it.", - "input": { - "properties": { - "domains": { - "description": "the domains to route there, separated by spaces or commas", - "type": "string" - }, - "link": { - "description": "the network link the servers are reached over, by name (a VPN's tunnel interface)", - "type": "string" - }, - "servers": { - "description": "the servers' addresses, separated by spaces or commas", - "type": "string" - } - }, - "required": [ - "link", - "domains", - "servers" - ], - "type": "object" - }, - "replaces": [ - "resolvectl dns", - "resolvectl domain", - "resolvectl default-route" - ] - }, - { - "name": "unroute", - "description": "Take one link's route away: its domains go back to the mesh's resolvers. Nothing changes when the link has none.", - "input": { - "properties": { - "link": { - "description": "the network link, by name", - "type": "string" - } - }, - "required": [ - "link" - ], - "type": "object" - }, - "replaces": [ - "resolvectl revert" - ] - } - ], - "Receives": null, - "Paths": [ - { - "path": "/etc/resolv.conf" - } - ], - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0247" - }, - { - "Name": "node-nfs-server", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "exports", - "description": "Every share this machine exports: its name, its path, read-write or read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that node's private address and access (only the nodes the server grants it to and that ask for it), what is granted and not asked for or asked for and not granted, and whether the kernel holds it now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in /etc/exports.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "exportfs -v", - "cat /etc/exports", - "zfs get sharenfs" - ] - }, - { - "name": "clients", - "description": "Which machines have mounted which share now, as the NFS server knows its clients.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "ss -tn sport = :2049", - "cat /proc/fs/nfsd/clients/*/info" - ] - }, - { - "name": "test", - "description": "Whether this machine exports one share for the mesh now, and whether its NFS service is up; with an address, also whether the share is exported to that address: a node's own private address, when the server grants it the share and the node asks for it. What a machine mounting the share asks before it mounts.", - "input": { - "properties": { - "address": { - "description": "a node's private address, to ask whether the share is exported to it (optional)", - "type": "string" - }, - "share": { - "description": "the share, by its name", - "type": "string" - } - }, - "required": [ - "share" - ], - "type": "object" - }, - "replaces": [ - "showmount -e" - ] - }, - { - "name": "reload", - "description": "Have the kernel read this machine's export files again now (exportfs -ra) and answer the shares as it then holds them. The module's own process writes its export file; this is for after a change made outside it. Changes no shared path.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "exportfs -ra" - ] - }, - { - "name": "adopt", - "description": "Take over an export a person made by hand: clear a dataset's sharenfs property for a path the mesh's own export now serves — only when that export is live. Without confirm, says what it would do and changes nothing.", - "input": { - "properties": { - "confirm": { - "description": "\"true\": change it (needs why); anything else is a dry run", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "path": { - "description": "the shared path whose hand-made export is taken over", - "type": "string" - }, - "why": { - "description": "why, for the record", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "replaces": [ - "zfs set sharenfs=off" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0263" - }, - { - "Name": "node-mounts", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "list", - "description": "Every mount point on this machine: its fstab line, the mesh's mount and automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, and which settings asked for it. A password in a mount's options is never shown.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "cat /etc/fstab", - "findmnt", - "systemctl list-units --type=mount" - ] - }, - { - "name": "test", - "description": "Whether one share's or occasional source's server answers from this machine now, without touching its mount point.", - "input": { - "properties": { - "name": { - "description": "the share or source, by its name", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "replaces": [ - "showmount -e", - "ping" - ] - }, - { - "name": "mount", - "description": "Mount one share or occasional source now, rather than at its first access.", - "input": { - "properties": { - "name": { - "description": "the share or source, by its name", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "replaces": [ - "mount" - ] - }, - { - "name": "unmount", - "description": "Release one share or occasional source now; its automount stays armed, so the next access mounts it again.", - "input": { - "properties": { - "name": { - "description": "the share or source, by its name", - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "replaces": [ - "umount" - ] - }, - { - "name": "adopt", - "description": "Take over a mount a person wrote by hand: comment out the /etc/fstab line for a mount point the mesh's own units now serve, keeping a copy of the file — only when the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.", - "input": { - "properties": { - "confirm": { - "description": "\"true\": change it (needs why); anything else is a dry run", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "mountpoint": { - "description": "the mount point whose fstab line is taken over", - "type": "string" - }, - "why": { - "description": "why, for the record", - "type": "string" - } - }, - "required": [ - "mountpoint" - ], - "type": "object" - }, - "replaces": [ - "sed -i /etc/fstab" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0263" - }, - { - "Name": "node-reads", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "stat", - "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which holds no secret: a component it may not reach says so. At most 200 paths in one call.", - "input": { - "properties": { - "follow": { - "description": "\"true\": also say where the path leads once every link is followed", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "paths": { - "description": "one absolute path, or several separated by line breaks", - "type": "string" - } - }, - "required": [ - "paths" - ], - "type": "object" - }, - "replaces": [ - "stat", - "ls -ld", - "namei -l", - "findmnt -T" - ] - }, - { - "name": "readlink", - "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists.", - "input": { - "properties": { - "path": { - "description": "an absolute path", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "replaces": [ - "readlink", - "readlink -f", - "realpath" - ] - }, - { - "name": "list", - "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name.", - "input": { - "properties": { - "path": { - "description": "an absolute path to a directory", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "replaces": [ - "ls", - "ls -la" - ] - }, - { - "name": "read", - "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may.", - "input": { - "properties": { - "bytes": { - "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", - "type": "string" - }, - "from": { - "description": "the first line to answer, counted from 1 (optional)", - "type": "string" - }, - "lines": { - "description": "how many lines to answer (optional)", - "type": "string" - }, - "path": { - "description": "an absolute path to a file", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "replaces": [ - "cat", - "head", - "tail", - "less" - ] - }, - { - "name": "search", - "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met.", - "input": { - "properties": { - "case": { - "description": "\"false\": match whatever the case (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "depth": { - "description": "how many directories deep, up to 8 (optional)", - "type": "string" - }, - "matches": { - "description": "the most matches to answer, up to 500 (optional)", - "type": "string" - }, - "path": { - "description": "an absolute path to a file or a directory", - "type": "string" - }, - "text": { - "description": "the fixed string to find", - "type": "string" - } - }, - "required": [ - "text", - "path" - ], - "type": "object" - }, - "replaces": [ - "grep", - "grep -r", - "grep -F" - ] - }, - { - "name": "account", - "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field.", - "input": { - "properties": { - "group": { - "description": "a group's name or number (optional)", - "type": "string" - }, - "name": { - "description": "an account's name or number (optional)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "getent passwd", - "getent group", - "id", - "groups" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0310" - }, - { - "Name": "node-files", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "stat", - "description": "Each component of one path or several, from / down: its type, owner and group (name and number), mode, whether it is a link and to what, and whether it is a mount point and of which file system. A link is not followed unless follow is \"true\". At most 200 paths in one call. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", - "input": { - "properties": { - "follow": { - "description": "\"true\": also say where the path leads once every link is followed", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "paths": { - "description": "one absolute path, or several separated by line breaks", - "type": "string" - } - }, - "required": [ - "paths" - ], - "type": "object" - }, - "replaces": [ - "stat", - "ls -ld", - "namei -l", - "findmnt -T" - ] - }, - { - "name": "readlink", - "description": "What one link points to, as written, and where the path leads once every link is followed, and whether that exists. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", - "input": { - "properties": { - "path": { - "description": "an absolute path", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "replaces": [ - "readlink", - "readlink -f", - "realpath" - ] - }, - { - "name": "list", - "description": "Every entry of one directory with its type, owner, group, mode, size and, for a link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is refused by name. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", - "input": { - "properties": { - "path": { - "description": "an absolute path to a directory", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "replaces": [ - "ls", - "ls -la" - ] - }, - { - "name": "read", - "description": "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its number said. /proc and /sys are read as far as the holder's account may. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", - "input": { - "properties": { - "bytes": { - "description": "the most bytes to answer, up to 262144 (optional, 65536 unless given)", - "type": "string" - }, - "from": { - "description": "the first line to answer, counted from 1 (optional)", - "type": "string" - }, - "lines": { - "description": "how many lines to answer (optional)", - "type": "string" - }, - "path": { - "description": "an absolute path to a file", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "replaces": [ - "cat", - "head", - "tail", - "less" - ] - }, - { - "name": "search", - "description": "Find a fixed string (never a pattern) in one file or under one directory: every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a secret is withheld. The answer says which bound it met. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", - "input": { - "properties": { - "case": { - "description": "\"false\": match whatever the case (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "depth": { - "description": "how many directories deep, up to 8 (optional)", - "type": "string" - }, - "matches": { - "description": "the most matches to answer, up to 500 (optional)", - "type": "string" - }, - "path": { - "description": "an absolute path to a file or a directory", - "type": "string" - }, - "text": { - "description": "the fixed string to find", - "type": "string" - } - }, - "required": [ - "text", - "path" - ], - "type": "object" - }, - "replaces": [ - "grep", - "grep -r", - "grep -F" - ] - }, - { - "name": "account", - "description": "One account or group by name or number, as this node's account files say: an account's number, group, home, shell and every group it is in; a group's number and members. With neither, every account and group. Never a password field. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", - "input": { - "properties": { - "group": { - "description": "a group's name or number (optional)", - "type": "string" - }, - "name": { - "description": "an account's name or number (optional)", - "type": "string" - } - }, - "type": "object" - }, - "replaces": [ - "getent passwd", - "getent group", - "id", - "groups" - ] - }, - { - "name": "write", - "description": "Write one text file whole, replacing what it holds; the old content goes to the node's trash. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "content": { - "description": "the text to write", - "type": "string" - }, - "mode": { - "description": "the file's mode, such as 0644 (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "path": { - "description": "an absolute path", - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - } - }, - "required": [ - "path", - "content", - "reason" - ], - "type": "object" - }, - "replaces": [ - "tee", - "\u003e" - ] - }, - { - "name": "copy", - "description": "Copy a file or directory to a path on this node. A target on another node (\u003cnode\u003e:/path) is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "from": { - "description": "an absolute path", - "type": "string" - }, - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - }, - "to": { - "description": "an absolute path on this node", - "type": "string" - } - }, - "required": [ - "from", - "to", - "reason" - ], - "type": "object" - }, - "replaces": [ - "cp", - "cp -a", - "ssh \u003cnode\u003e cp" - ] - }, - { - "name": "move", - "description": "Move or rename a file or directory. A target on another node is refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "from": { - "description": "an absolute path", - "type": "string" - }, - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - }, - "to": { - "description": "an absolute path", - "type": "string" - } - }, - "required": [ - "from", - "to", - "reason" - ], - "type": "object" - }, - "replaces": [ - "mv" - ] - }, - { - "name": "delete", - "description": "Delete a file or directory into the node's trash, where restore can bring it back. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "path": { - "description": "an absolute path", - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - }, - "recursive": { - "description": "\"true\": delete a directory with what it holds", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "required": [ - "path", - "reason" - ], - "type": "object" - }, - "replaces": [ - "rm", - "rm -r" - ] - }, - { - "name": "mkdir", - "description": "Make a directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "parents": { - "description": "\"true\": make the missing parents too", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "path": { - "description": "an absolute path", - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - } - }, - "required": [ - "path", - "reason" - ], - "type": "object" - }, - "replaces": [ - "mkdir", - "mkdir -p" - ] - }, - { - "name": "chmod", - "description": "Change the mode of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "mode": { - "description": "the mode, such as 0644 or u+x", - "type": "string" - }, - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "path": { - "description": "an absolute path", - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - }, - "recursive": { - "description": "\"true\": also everything under a directory", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "required": [ - "path", - "mode", - "reason" - ], - "type": "object" - }, - "replaces": [ - "chmod" - ] - }, - { - "name": "chown", - "description": "Change the owner and group of a file or directory. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "group": { - "description": "a group's name or number (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "owner": { - "description": "an account's name or number", - "type": "string" - }, - "path": { - "description": "an absolute path", - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - }, - "recursive": { - "description": "\"true\": also everything under a directory", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "required": [ - "path", - "owner", - "reason" - ], - "type": "object" - }, - "replaces": [ - "chown", - "chgrp" - ] - }, - { - "name": "upload", - "description": "Send one file of this node into a bucket of the object store, over a short signed link and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, before anything is sent. Read as the node's operator account, through sudo -n where its rights do not reach; a place a secret is kept, or a known credential file, is refused by name.", - "input": { - "properties": { - "bucket": { - "description": "the bucket the file goes into", - "type": "string" - }, - "key": { - "description": "the object's key (optional; the file's SHA-256 when not given)", - "type": "string" - }, - "path": { - "description": "an absolute path to a file", - "type": "string" - } - }, - "required": [ - "path", - "bucket" - ], - "type": "object" - } - }, - { - "name": "download", - "description": "Write one object of a bucket to a file of this node, over a short signed link and never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "bucket": { - "description": "the bucket", - "type": "string" - }, - "key": { - "description": "the object's key", - "type": "string" - }, - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "type": "string" - }, - "path": { - "description": "an absolute path", - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - }, - "sha256": { - "description": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)", - "type": "string" - } - }, - "required": [ - "bucket", - "key", - "path", - "reason" - ], - "type": "object" - } - }, - { - "name": "trash", - "description": "What the node's trash holds: each deleted or replaced item with its id, original path and when it went.", - "input": { - "properties": {}, - "type": "object" - }, - "replaces": [ - "ls ~/.local/share/Trash" - ] - }, - { - "name": "restore", - "description": "Put an item of the node's trash back where it was; refuses when something is there now. Refuses a path a module owns unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.", - "input": { - "properties": { - "id": { - "description": "an id from trash", - "type": "string" - }, - "override": { - "description": "a reason, to change a path a module owns anyway (optional)", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "reason": { - "description": "why the file is changed, for the audit trail", - "type": "string" - } - }, - "required": [ - "id", - "reason" - ], - "type": "object" - }, - "replaces": [ - "trash-restore" - ] - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0394" - }, - { - "Name": "node-engine", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": [ - "asked", - "done", - "refused" - ], - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "hand-over", - "description": "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, §5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, reached through no link) and refuses in words, or asks the operator on their channel at the level approve, binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands it over. One ask is open per path; acts shows where it stands.", - "input": { - "properties": { - "path": { - "description": "the directory's absolute path, as the engine states it", - "type": "string" - }, - "why": { - "description": "why it is asked, in words the operator reads", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - } - }, - { - "name": "access-act", - "description": "Ask the operator for the take act on a library whose root holds data outside its group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes sets the library's group, group write and setgid under its root. One ask is open per access.", - "input": { - "properties": { - "access": { - "description": "the access, library:\u003cname\u003e", - "type": "string" - }, - "act": { - "description": "the act: take", - "type": "string" - }, - "why": { - "description": "why it is asked, in words the operator reads", - "type": "string" - } - }, - "required": [ - "act", - "access" - ], - "type": "object" - } - }, - { - "name": "judge-directories", - "description": "Which of this machine's declared directories the engine would refuse to make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "setuid-search", - "description": "Throw away the engine's last search for setuid programs and start a full one (novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while one runs, and when one was started within the hour, naming when the next may start.", - "input": { - "properties": { - "why": { - "description": "why it is asked, in words", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "root-free", - "description": "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root without a person, and the login shell's execute not served here. Anything else is no pass, and says why. Answered by this machine's engine alone.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "acts", - "description": "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the operator's warrant. Read-only.", - "input": { - "properties": { - "id": { - "description": "one act, by its id", - "type": "string" - }, - "limit": { - "description": "at most this many, newest", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "packages", - "description": "The packages this machine's compositions declare, present or absent, each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller hands its lease away, beside the controller's mirror for this machine and their difference. With write, the key written again from what the engine composed.", - "input": { - "properties": { - "write": { - "description": "\"true\": write this machine's key again from what the engine composed", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "composition", - "description": "One module's composition on this machine beside what runs of it, read-only: the composition its want's build has in the bucket composed-\u003cnode\u003e (the latest revision, or the one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs (applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers that join or leave.", - "input": { - "properties": { - "diff": { - "description": "\"true\": list the resources that differ from what runs", - "type": "string" - }, - "digest": { - "description": "a composition's digest (sha256:…), to read that revision rather than the latest", - "type": "string" - }, - "module": { - "description": "the module, as placed on this machine; node for the node's own", - "type": "string" - } - }, - "required": [ - "module" - ], - "type": "object" - } - }, - { - "name": "compose", - "description": "Compose this machine's modules now and say how it went, so a composition that failed, or a module held, recovers without a restart: what the pass refused or held, each with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried again. Changes only what a pass the engine makes on its own would change.", - "input": { - "properties": { - "module": { - "description": "a module placed on this machine whose apply is also tried again (optional)", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "compose-candidate", - "description": "Compose a check's candidate on this machine in shadow, with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it composes (with its digest and whether the candidate changes it), is refused by the candidate or is held without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", - "input": { - "properties": { - "candidate": { - "description": "the key of the indexer's candidate, \u003cgroup\u003e.\u003cheads hash\u003e", - "type": "string" - }, - "input": { - "description": "\"true\": also answer the composition input the candidate was composed from, for the sandbox path", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "required": [ - "candidate" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": true, - "Decision": "novox/hq ADR 0563", - "Optional": [ - "packages", - "composition", - "compose", - "compose-candidate" - ] - }, - { - "Name": "node-login-manager", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "sessions", - "description": "The sessions the login manager offers on this machine, and which one the operator account starts by default.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-display-server", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "displays", - "description": "The monitors connected now, each with its identity, its modes and where it is placed; and the layout profile in force, if one matches.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "layout", - "description": "The monitor layout profiles, keyed by the connected monitors' identities: list them, save the current arrangement under a name, or apply one.", - "input": { - "properties": { - "action": { - "description": "list, save or apply", - "enum": [ - "list", - "save", - "apply" - ], - "type": "string" - }, - "name": { - "description": "the profile to save or apply (save and apply only)", - "type": "string" - } - }, - "required": [ - "action" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-display-session", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "reload", - "description": "Reload the session's configuration in place, keeping its windows.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "workspaces", - "description": "The session's workspaces: each one's name, output, and whether it is visible or focused.", - "input": { - "properties": {}, - "type": "object" - } - }, - { - "name": "windows", - "description": "The session's windows: each one's title, class, workspace and whether it has focus; narrowed to one workspace when named.", - "input": { - "properties": { - "workspace": { - "description": "one workspace (optional)", - "type": "string" - } - }, - "type": "object" - } - } - ], - "Receives": [ - { - "Kind": "config", - "Comment": "#", - "Dirs": false, - "Shape": null, - "Offered": false - } - ], - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-terminal-emulator", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "open", - "description": "Open a terminal window in the operator's session, running a command or the login shell, in a directory or the account's home.", - "input": { - "properties": { - "command": { - "description": "what to run in it (optional; the login shell when absent)", - "type": "string" - }, - "directory": { - "description": "where it starts (optional; the account's home when absent)", - "type": "string" - } - }, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-launcher", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "menu", - "description": "Put a menu of choices in front of the operator and answer with the one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.", - "input": { - "properties": { - "choices": { - "description": "the lines to choose between, in order", - "items": { - "type": "string" - }, - "type": "array" - }, - "prompt": { - "description": "what the menu asks (optional)", - "type": "string" - } - }, - "required": [ - "choices" - ], - "type": "object" - } - }, - { - "name": "secret", - "description": "Ask the operator for a value in a prompt that does not show what is typed, and answer it sealed to the key the asker gives — never in the clear — or cancelled when the prompt was dismissed or not answered in time.", - "input": { - "properties": { - "module": { - "description": "the module whose own secret is asked for", - "type": "string" - }, - "node": { - "description": "the machine the module runs on", - "type": "string" - }, - "seal_to": { - "description": "the asker's public sealing key: the answer is sealed to it", - "type": "string" - }, - "secret": { - "description": "the own secret's name", - "type": "string" - }, - "timeout_seconds": { - "description": "give up after this long (optional)", - "type": "string" - } - }, - "required": [ - "module", - "secret", - "node", - "seal_to" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208", - "Optional": [ - "secret" - ] - }, - { - "Name": "node-notifier", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "send", - "description": "Show the operator a notification.", - "input": { - "properties": { - "body": { - "description": "its text (optional)", - "type": "string" - }, - "title": { - "description": "the notification's summary", - "type": "string" - }, - "urgency": { - "description": "low, normal (the default) or critical", - "enum": [ - "low", - "normal", - "critical" - ], - "type": "string" - } - }, - "required": [ - "title" - ], - "type": "object" - } - }, - { - "name": "history", - "description": "The notifications shown lately, newest first.", - "input": { - "properties": { - "limit": { - "description": "how many (optional, default 20)", - "type": "string" - } - }, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-lock-screen", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "lock", - "description": "Lock the operator's session now.", - "input": { - "properties": {}, - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-clipboard", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": [ - { - "name": "history", - "description": "What the clipboard held lately, newest first.", - "input": { - "properties": { - "limit": { - "description": "how many (optional, default 20)", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "copy", - "description": "Put text on the operator's clipboard.", - "input": { - "properties": { - "text": { - "description": "the text", - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - } - } - ], - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-bar", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": [ - { - "Kind": "block", - "Comment": "#", - "Dirs": false, - "Shape": { - "Places": [ - { - "Field": "bar", - "Values": [ - "bottom", - "top" - ] - }, - { - "Field": "place", - "Values": [ - "resources", - "status" - ] - } - ], - "Order": "order", - "Examples": [ - { - "bar": "bottom", - "place": "status", - "shows": "battery" - }, - { - "bar": "bottom", - "options": { - "device": "BAT0" - }, - "place": "status", - "shows": "battery" - }, - { - "bar": "bottom", - "options": { - "command": "echo \"a line\"", - "interval": 5 - }, - "place": "status", - "shows": "command" - }, - { - "bar": "bottom", - "options": { - "state": "power.draw" - }, - "place": "status", - "shows": "state" - }, - { - "bar": "bottom", - "options": { - "key": "a-machine", - "state": "power.draw" - }, - "place": "status", - "shows": "state" - } - ], - "Text": true - }, - "Offered": true - } - ], - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208", - "ShapeCode": [ - "block" - ] - }, - { - "Name": "node-compositor", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - }, - { - "Name": "node-secret-service", - "Scope": "node", - "Replicated": false, - "Delivers": "", - "Accepts": null, - "Emits": null, - "PulledByKind": false, - "Yields": false, - "Serves": null, - "Receives": null, - "Paths": null, - "HeldByTheEngine": false, - "Decision": "novox/hq ADR 0208" - } -] diff --git a/go/catalogue/verbs_test.go b/go/catalogue/verbs_test.go index 7e7f4c7..8947700 100644 --- a/go/catalogue/verbs_test.go +++ b/go/catalogue/verbs_test.go @@ -51,12 +51,12 @@ func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) { t.Errorf("a holder saying it serves mint: %v", err) } - // L3: the seat mesh-catalog promises four verbs, each optional while two claimants stand (ADR 0604 §1, §6). + // L3: the seat mesh-catalog promises five verbs (the fifth is the seat list, ADR 0636 §4), each optional while two claimants stand (ADR 0604 §1, §6). catalog, ok := SeatNamed(CatalogSeat) - if !ok || catalog.Scope != ScopeMesh || catalog.Decision != "novox/hq ADR 0604" || len(catalog.Serves) != 4 { + if !ok || catalog.Scope != ScopeMesh || catalog.Decision != "novox/hq ADR 0604" || len(catalog.Serves) != 5 { t.Fatalf("mesh-catalog: in the set %v, %+v", ok, catalog) } - four := []string{"modules", "module", "provides", "dependents"} + four := []string{"modules", "module", "provides", "dependents", "seat-definitions"} for i, v := range catalog.Serves { if v.Name != four[i] || !v.Optional || v.Input == nil { t.Errorf("mesh-catalog's verb %d is %+v, want %s, optional, with its input", i, v, four[i]) diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index d036149..9fb8846 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -16,7 +16,47 @@ }, "answer": { "type": "object", - "description": "what modules answers, as its description says" + "description": "every module the trunk holds, by name", + "properties": { + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + } + }, + "required": [ + "modules" + ] } }, { @@ -37,7 +77,48 @@ }, "answer": { "type": "object", - "description": "what module answers, as its description says" + "description": "the module whole", + "properties": { + "module": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + }, + "manifest": { + "type": "object", + "description": "the module's manifest as the trunk holds it" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + }, + "required": [ + "module" + ] } }, { @@ -84,7 +165,7 @@ }, { "name": "seat-definitions", - "description": "The seat list (ADR 0636 §4): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "description": "The seat list (ADR 0636 \u00a74): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", "optional": true, "input": { "properties": { @@ -163,7 +244,7 @@ } } ], - "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).", + "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 \u00a71, \u00a74).", "family": { "name": "mesh-catalog.events", "max-age-days": 7 @@ -344,8 +425,9 @@ "toolchains": { "type": "object", "additionalProperties": { - "description": "toolchain used" - } + "$ref": "#/defs/ToolchainUsed" + }, + "description": "by language: the toolchain FP2 was computed with" }, "paths": { "type": "object", @@ -543,7 +625,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -576,7 +659,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" }, "list": { "description": "the foundation's predefined list as kept" @@ -670,7 +754,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -735,7 +820,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -830,7 +916,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -904,7 +991,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -921,7 +1009,8 @@ }, "at": { "type": "string", - "description": "when (RFC 3339 time)" + "description": "when (RFC 3339 time)", + "format": "date-time" } }, "required": [ @@ -935,5 +1024,29 @@ "description": "the published packages", "history": 5 } - ] + ], + "defs": { + "ToolchainUsed": { + "type": "object", + "description": "a toolchain a fingerprint was computed with (ADR 0405 clause 7)", + "properties": { + "language": { + "type": "string" + }, + "version": { + "type": "string", + "description": "the installed toolchain's version" + }, + "recipe": { + "type": "string", + "description": "the build module's recipe version" + } + }, + "required": [ + "language", + "version", + "recipe" + ] + } + } } diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json index 541d242..237a651 100644 --- a/go/seatlist/bootstrap/node-engine.json +++ b/go/seatlist/bootstrap/node-engine.json @@ -754,58 +754,84 @@ }, "composed": { "type": "string", - "description": "the composition's digest" + "description": "the digest of the composition the node runs" }, "build": { "type": "string" }, "phase": { "type": "string", - "description": "how far it has come" + "description": "how far it has come", + "enum": [ + "backing-up", + "applying", + "checking", + "running", + "failed", + "removed", + "held", + "waiting-yes" + ] }, "health": { "type": "string", - "description": "how it is" + "description": "what the module's own check says", + "enum": [ + "ok", + "failed", + "waits", + "unknown" + ] }, "detail": { "type": "string" }, "user-list": { - "type": "object", - "description": "the user list written" + "$ref": "#/defs/UserListWritten" }, "judgement": { - "type": "string" + "type": "string", + "description": "in phase waiting-yes, the judgement of the move", + "enum": [ + "recreates", + "moves-data", + "cannot-judge" + ] }, "waits": { "type": "string", - "description": "what it waits for" + "description": "in phase waiting-yes, the digest of the composition that waits" }, "build-alone": { - "type": "boolean" + "type": "boolean", + "description": "the move changes the build alone" }, "kept": { "type": "array", "items": { "type": "string" - } + }, + "description": "what the node keeps of the module as it runs" }, "sealed": { "type": "array", "items": { "type": "string" - } + }, + "description": "each value sealed to the node's key" }, "sealed-at": { - "type": "object" + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "the sealed values by slot" }, "pull-wait": { - "type": "object", - "description": "a pull the engine waits for" + "$ref": "#/defs/PullWait" }, "handed": { - "type": "object", - "description": "the accounts handed" + "$ref": "#/defs/AccountsHanded" } }, "required": [ @@ -826,7 +852,8 @@ "type": "integer" }, "composed": { - "type": "string" + "type": "string", + "description": "the digest of the node's own composition it runs" }, "windows": { "type": "object" @@ -844,8 +871,7 @@ "type": "object" }, "record": { - "type": "object", - "description": "the node's record: its tunnel and certificate" + "$ref": "#/defs/NodeRecord" }, "sealed": { "type": "array", @@ -854,17 +880,21 @@ } }, "sealed-at": { - "type": "object" + "type": "object", + "additionalProperties": { + "type": "string" + } }, "candidates": { "type": "object", "additionalProperties": { - "type": "object" - } + "$ref": "#/defs/CandidateAnswer" + }, + "description": "the node's answer to each placement candidate naming it, by proposal id" }, "composes": { "type": "string", - "description": "own, or the controller's" + "description": "own, or none (empty)" }, "composes-why": { "type": "string" @@ -890,53 +920,96 @@ "type": "object", "description": "one composition of a module on a node under a fingerprint", "properties": { - "V": { + "v": { "type": "integer" }, - "Node": { + "node": { "type": "string" }, - "Module": { + "module": { "type": "string" }, - "FP": { + "fp": { "type": "string" }, - "Data": { - "type": "boolean" + "data": { + "type": "boolean", + "description": "the module declares data" }, - "Resources": { + "resources": { "type": "array", "items": { "type": "object" - } + }, + "description": "the declaration resources, each saying its module" }, - "Filled": { + "filled_files": { "type": "array", "items": { "type": "string" - } + }, + "description": "the files the provisioner fills" }, - "Own": { + "plan": { "type": "object", - "description": "the node's own adoption, identities, groups, libraries and provisioner account" + "description": "the module's whole provisioning plan, catalogue.Plan, kept whole: it is the manifest layer's type" }, - "Plan": { - "type": "object", - "description": "the module's plan" - }, - "Refused": { + "refused": { "type": "string", - "description": "why the composition was refused" + "description": "why the module cannot be composed at this fingerprint" } }, "required": [ - "Node", - "Module", - "FP" + "v", + "node", + "module", + "fp", + "resources" ] }, "description": "one composition" + }, + { + "key": ".node", + "value": { + "type": "object", + "description": "the node's own composition: its resources and parts", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "data": { + "type": "boolean", + "description": "the module declares data" + }, + "resources": { + "type": "array", + "items": { + "type": "object" + }, + "description": "the declaration resources, each saying its module" + }, + "filled_files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the files the provisioner fills" + }, + "own": { + "$ref": "#/defs/NodeOwn" + } + }, + "required": [ + "v", + "node", + "resources" + ] + }, + "description": "the node's own composition" } ], "description": "the compositions of one node, kept in a bucket per node named composed-" @@ -966,11 +1039,12 @@ "items": { "type": "array", "items": { - "type": "object" + "$ref": "#/defs/PlacedDataItem" } } }, "required": [ + "v", "node", "module", "digest", @@ -1012,7 +1086,12 @@ "absent": { "type": "boolean" } - } + }, + "required": [ + "package", + "modules", + "absent" + ] } } }, @@ -1043,36 +1122,36 @@ "type": "string" }, "network": { - "type": "object" + "$ref": "#/defs/NetworkHealth" }, "peers": { "type": "array", "items": { - "type": "object" + "$ref": "#/defs/PeerSeen" } }, "units": { - "type": "object" + "$ref": "#/defs/UnitsHealth" }, "relogin": { "type": "array", "items": { - "type": "object" + "$ref": "#/defs/NodeAccount" } }, "found": { "type": "array", "items": { - "type": "object" + "$ref": "#/defs/NodeDirectory" } }, "fallback": { - "type": "object" + "$ref": "#/defs/LoginFallback" }, "root": { "type": "array", "items": { - "type": "object" + "$ref": "#/defs/RootAccountVerdict" } }, "contract": { @@ -1080,6 +1159,7 @@ }, "at": { "type": "string", + "description": "when", "format": "date-time" } }, @@ -1698,6 +1778,874 @@ "at", "said" ] + }, + "UserListWritten": { + "type": "object", + "description": "the bus's user list as the engine of the bus node wrote it to the bus module's file", + "properties": { + "source": { + "type": "string", + "description": "the key the list was taken from" + }, + "revision": { + "type": "integer" + }, + "wanted": { + "type": "string", + "description": "the digest of the list the source holds, sha256:" + }, + "digest": { + "type": "string", + "description": "the digest of the file on the machine after the write" + }, + "users": { + "type": "integer" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + }, + "error": { + "type": "string", + "description": "why a write failed" + }, + "follows": { + "type": "string", + "description": "which list the node follows now" + }, + "key": { + "type": "string", + "description": "the bus node's sealing key, its public half" + }, + "unwatched": { + "type": "string", + "description": "why the bus node does not watch the grants part's key now" + }, + "handed": { + "$ref": "#/defs/AccountsHanded" + } + }, + "required": [ + "source", + "revision", + "wanted", + "users", + "at" + ] + }, + "AccountsHanded": { + "type": "object", + "description": "the hashes of the users of the list, sealed to the vault", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string", + "description": "the fingerprint of the vault's key" + }, + "users": { + "type": "integer" + }, + "box": { + "$ref": "#/defs/SealedBox" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "from", + "to", + "users", + "box", + "at" + ] + }, + "SealedBox": { + "type": "object", + "description": "a value sealed to a key", + "properties": { + "v": { + "type": "integer" + }, + "eph": { + "type": "string" + }, + "iv": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "ct": { + "type": "string" + } + }, + "required": [ + "v", + "eph", + "iv", + "tag", + "ct" + ] + }, + "PullWait": { + "type": "object", + "description": "the pull from a store a failed apply waits on", + "properties": { + "image": { + "type": "string" + }, + "seat": { + "type": "string", + "description": "the seat the pull came through" + }, + "holder": { + "type": "string", + "description": "that seat's holder, whose settling the apply waits for" + }, + "began": { + "type": "string", + "description": "when the pull began", + "format": "date-time" + }, + "retried": { + "type": "string", + "description": "what triggered the last try again, in words" + }, + "retried-at": { + "type": "string", + "description": "when the apply was last tried again", + "format": "date-time" + } + }, + "required": [ + "image", + "holder", + "began" + ] + }, + "NodeRecord": { + "type": "object", + "description": "what the machine observes of itself, for every node and composing part to read", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "tunnel-key": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealing-key": { + "type": "string" + }, + "serving-key": { + "type": "string" + }, + "certificate": { + "type": "string" + }, + "outward-links": { + "type": "array", + "items": { + "type": "string" + } + }, + "networks": { + "type": "array", + "items": { + "$ref": "#/defs/AttachedNetwork" + } + }, + "networks-said": { + "type": "boolean" + }, + "contract": { + "type": "integer" + }, + "authority": { + "type": "string" + }, + "trusts": { + "type": "array", + "items": { + "type": "string" + } + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "v", + "node" + ] + }, + "AttachedNetwork": { + "type": "object", + "description": "a network attached to a link", + "properties": { + "link": { + "type": "string" + }, + "network": { + "type": "string" + } + }, + "required": [ + "link", + "network" + ] + }, + "CandidateAnswer": { + "type": "object", + "description": "the node's answer to one placement candidate naming it", + "properties": { + "proposal": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "verdict": { + "type": "string", + "description": "same, differs or refused", + "enum": [ + "same", + "differs", + "refused" + ] + }, + "same": { + "type": "array", + "items": { + "type": "string" + }, + "description": "modules whose composition is the one running" + }, + "differs": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateDiffers" + } + }, + "added": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateAdded" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "modules the candidate removes" + }, + "refusals": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateRefusal" + } + }, + "held": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateRefusal" + } + }, + "refused": { + "type": "string", + "description": "why the node cannot compose with the candidate at all" + }, + "returns": { + "type": "string" + }, + "note": { + "type": "string" + }, + "values_differ": { + "type": "integer" + } + }, + "required": [ + "proposal", + "revision", + "verdict" + ] + }, + "CandidateDiffers": { + "type": "object", + "description": "a module whose composition with the candidate differs from the one running", + "properties": { + "module": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "running": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "why": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateBodies" + } + } + }, + "required": [ + "module", + "why" + ] + }, + "CandidateBodies": { + "type": "object", + "description": "the bodies of one resource that differ", + "properties": { + "id": { + "type": "string" + }, + "why": { + "type": "string" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "composed": { + "type": "string" + }, + "running": { + "type": "string" + } + }, + "required": [ + "id", + "why" + ] + }, + "CandidateAdded": { + "type": "object", + "description": "a module the candidate adds", + "properties": { + "module": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "unissued": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "module" + ] + }, + "CandidateRefusal": { + "type": "object", + "description": "a module refused or held with the candidate", + "properties": { + "module": { + "type": "string" + }, + "why": { + "type": "string" + }, + "today": { + "type": "string" + } + }, + "required": [ + "module", + "why" + ] + }, + "NodeOwn": { + "type": "object", + "description": "what a node's own composition carries beside its resources", + "properties": { + "adoption": { + "$ref": "#/defs/Adoption" + }, + "identities": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredIdentity" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredGroup" + } + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredLibrary" + } + }, + "provisioner_account": { + "type": "string", + "description": "the account the provisioner's client runs as on the node" + } + } + }, + "Adoption": { + "type": "object", + "description": "the node is adopted and which of its modules are taken", + "properties": { + "taken": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the modules taken" + }, + "untaken": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "by module, the resources left untaken" + } + }, + "required": [ + "taken" + ] + }, + "DeclaredIdentity": { + "type": "object", + "description": "the identity of a module assigned to the node", + "properties": { + "module": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uid": { + "type": "integer" + }, + "gid": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "type": "integer" + } + }, + "runner": { + "type": "string" + } + }, + "required": [ + "module", + "name", + "uid", + "gid" + ] + }, + "DeclaredGroup": { + "type": "object", + "description": "a group", + "properties": { + "name": { + "type": "string" + }, + "gid": { + "type": "integer" + } + }, + "required": [ + "name", + "gid" + ] + }, + "DeclaredLibrary": { + "type": "object", + "description": "a library the node is reached by", + "properties": { + "library": { + "type": "string" + }, + "root": { + "type": "string" + }, + "group": { + "type": "string" + }, + "gid": { + "type": "integer" + }, + "home": { + "type": "boolean" + }, + "taken": { + "type": "boolean" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "type": "string" + } + }, + "required": [ + "library", + "root", + "group", + "gid", + "home" + ] + }, + "PlacedDataItem": { + "type": "object", + "description": "one data item a module declares, as placed on a node", + "properties": { + "module": { + "type": "string" + }, + "item": { + "type": "string" + }, + "class": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the item's place on the node" + }, + "covered_by": { + "type": "string", + "description": "the path whose snapshot keeps it, empty when it is not backed up" + }, + "protection": { + "type": "string", + "description": "backup, redundancy, both or none" + }, + "measure": { + "type": "string", + "description": "walk, dataset or shallow" + } + }, + "required": [ + "module", + "item", + "class", + "path" + ] + }, + "NetworkHealth": { + "type": "object", + "description": "the node's network", + "properties": { + "state": { + "type": "string", + "description": "running, degraded or unknown" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/defs/NetworkPart" + } + } + }, + "required": [ + "state", + "since", + "parts" + ] + }, + "NetworkPart": { + "type": "object", + "description": "one part of the node's network", + "properties": { + "part": { + "type": "string" + }, + "state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "said": { + "type": "string" + }, + "writer": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "toward": { + "type": "array", + "items": { + "type": "string" + } + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + }, + "streak": { + "type": "integer" + } + }, + "required": [ + "part", + "state", + "since" + ] + }, + "UnitsHealth": { + "type": "object", + "description": "the node's units", + "properties": { + "state": { + "type": "string" + }, + "failed": { + "type": "array", + "items": { + "$ref": "#/defs/FailedUnit" + } + }, + "unread": { + "type": "array", + "items": { + "type": "string" + } + }, + "away": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "state", + "failed" + ] + }, + "FailedUnit": { + "type": "object", + "description": "a unit that failed", + "properties": { + "unit": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "load": { + "type": "string" + }, + "result": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "unit", + "scope", + "since" + ] + }, + "PeerSeen": { + "type": "object", + "description": "a tunnel peer and whether it is seen", + "properties": { + "key": { + "type": "string" + }, + "addresses": { + "type": "array", + "items": { + "type": "string" + } + }, + "seen": { + "type": "boolean" + } + }, + "required": [ + "key", + "seen" + ] + }, + "NodeAccount": { + "type": "object", + "description": "an account the node needs to log in again", + "properties": { + "account": { + "type": "string" + }, + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "account", + "reason", + "since" + ] + }, + "NodeDirectory": { + "type": "object", + "description": "a directory found where the node expected none", + "properties": { + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "module", + "resource", + "path", + "reason", + "since" + ] + }, + "LoginFallback": { + "type": "object", + "description": "a login that fell back to another node", + "properties": { + "node": { + "type": "string" + }, + "from": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "node", + "from", + "why", + "at" + ] + }, + "RootAccountVerdict": { + "type": "object", + "description": "the engine's verdict of whether an account's resource is root-free, as the node's health lists it", + "properties": { + "account": { + "type": "string" + }, + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "account", + "module", + "resource", + "state", + "since" + ] } } } diff --git a/go/seatlist/bootstrap/operator-channel.json b/go/seatlist/bootstrap/operator-channel.json index 0bd8c21..3f33532 100644 --- a/go/seatlist/bootstrap/operator-channel.json +++ b/go/seatlist/bootstrap/operator-channel.json @@ -182,7 +182,56 @@ "key": ".", "value": { "type": "object", - "description": "one ask the router holds and how it stands" + "description": "one ask the router holds and how it stands, as the router's tool states it", + "properties": { + "key": { + "type": "string", + "description": "." + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "headline": { + "type": "string", + "description": "its headline" + }, + "state": { + "type": "string", + "description": "how it stands: open, chosen, expired, cancelled, replaced or refused" + }, + "opened": { + "type": "string", + "description": "when it was opened, RFC 3339" + }, + "expires": { + "type": "string", + "description": "when it ends unanswered, RFC 3339" + }, + "offered_on": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the channels it was offered on" + }, + "asker_told": { + "type": "boolean", + "description": "its asker has been told how it ended" + }, + "answer": { + "type": "string", + "description": "how it ended, in words" + } + }, + "required": [ + "key", + "asker", + "headline", + "state", + "opened", + "expires" + ] }, "description": "one ask the router holds and how it stands" } @@ -563,6 +612,250 @@ "subject", "state" ] + }, + "Button": { + "type": "object", + "description": "one answer a channel offers: its label and the router's one-time ticket for it", + "properties": { + "label": { + "type": "string", + "description": "the button's label" + }, + "ticket": { + "type": "string", + "description": "the router's one-time ticket for it" + } + }, + "required": [ + "label", + "ticket" + ] + }, + "Message": { + "type": "object", + "description": "the work a channel takes on the bench channel: shown with buttons (show), shown again in place (edit) or said (send); the words are the router's, shown as given", + "properties": { + "handle": { + "type": "string", + "description": "the router's name for it, the same across a show and its edits" + }, + "title": { + "type": "string", + "description": "its title" + }, + "body": { + "type": "string", + "description": "its body" + }, + "buttons": { + "type": "array", + "items": { + "$ref": "#/defs/Button" + }, + "description": "the answers it offers (none for a send)" + }, + "note": { + "type": "string", + "description": "its secondary words after the body and the buttons: who asked and when, what each answer does, when it expires, the ask's details" + }, + "urgent": { + "type": "boolean", + "description": "urgent" + }, + "silent": { + "type": "boolean", + "description": "shown without notifying" + }, + "reply": { + "type": "string", + "description": "the choice or link request this answers, by its id: the channel shows it where that was made" + }, + "to": { + "type": "string", + "description": "the account linked as the operator on this kind, where the channel sends what is not a reply; empty when none is linked" + }, + "secret": { + "type": "boolean", + "description": "the words carry something shown once (a link's code): the channel keeps no copy" + } + }, + "required": [ + "handle", + "title", + "body" + ] + }, + "Sender": { + "type": "object", + "description": "who a channel's service says sent something; the router alone judges whether that is the operator", + "properties": { + "identity": { + "type": "string", + "description": "the account" + }, + "display": { + "type": "string", + "description": "the name it shows" + }, + "authenticated": { + "type": "boolean", + "description": "whether the service authenticated it" + } + }, + "required": [ + "identity", + "authenticated" + ] + }, + "Choice": { + "type": "object", + "description": "a button chosen on a channel, said on the bench intake as the event `choice`", + "properties": { + "id": { + "type": "string", + "description": "the channel's own for this arrival, unique, so the router acts on it once" + }, + "ticket": { + "type": "string", + "description": "the ticket of the button" + }, + "handle": { + "type": "string", + "description": "the message's handle" + }, + "sender": { + "$ref": "#/defs/Sender" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "id", + "ticket", + "sender", + "at" + ] + }, + "LinkAsked": { + "type": "object", + "description": "somebody on a channel asking to be linked as the operator, said on the bench intake as the event `link`", + "properties": { + "id": { + "type": "string", + "description": "the channel's own for this arrival" + }, + "sender": { + "$ref": "#/defs/Sender" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "id", + "sender", + "at" + ] + }, + "Failed": { + "type": "object", + "description": "a message a channel could not deliver, said on the bench intake as the event `failed`", + "properties": { + "handle": { + "type": "string", + "description": "the message's handle" + }, + "why": { + "type": "string", + "description": "why" + }, + "permanent": { + "type": "boolean", + "description": "trying again cannot help" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "handle", + "why", + "at" + ] + }, + "Standing": { + "type": "object", + "description": "what a channel says of itself at least every five minutes and whenever it changes, on the bench intake as the event `standing`", + "properties": { + "ready": { + "type": "boolean", + "description": "it can send now" + }, + "why": { + "type": "string", + "description": "why not" + }, + "edits-silently": { + "type": "boolean", + "description": "its edits notify nobody" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "ready", + "at" + ] + }, + "Code": { + "type": "object", + "description": "a code a person typed on a channel, asked as the proof `code` on the bench intake by request and reply: never in an event, never kept", + "properties": { + "sender": { + "$ref": "#/defs/Sender" + }, + "code": { + "type": "string", + "description": "the code" + }, + "purpose": { + "type": "string", + "description": "what it is for" + } + }, + "required": [ + "sender", + "code", + "purpose" + ] + }, + "ProofAnswer": { + "type": "object", + "description": "the router's answer to a proof", + "properties": { + "accepted": { + "type": "boolean", + "description": "whether it was taken" + }, + "words": { + "type": "string", + "description": "words to say to the person" + } + }, + "required": [ + "accepted", + "words" + ] } } } diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index d1eca62..e196f4d 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -4,12 +4,14 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strings" "testing" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" ) // fakeOwner is the seat mesh-catalog: its verb answers from its table and the events are delivered by the test. @@ -265,3 +267,22 @@ func TestStartReturnsAtOnceAndTheReaderSaysItIsReadingUntilTheSeatAnswers(t *tes t.Fatalf("after the read: %+v", st) } } + +// The bootstrap and the tests' seat list are copies of the same contracts files: where a seat is in both, they are equal, +// so a seat that changes is replaced in both (ADR 0636 §7). +func TestTheBootstrapEqualsTheTestsSeatList(t *testing.T) { + tests := map[string]seatdef.Definition{} + for _, d := range seatlisttest.Definitions() { + tests[d.Seat] = d + } + for _, b := range Bootstrapped() { + got, ok := tests[b.Seat] + if !ok { + t.Errorf("%s is bootstrapped but not in seatlisttest", b.Seat) + continue + } + if !reflect.DeepEqual(b, got) { + t.Errorf("%s: bootstrap/%s.json differs from seatlisttest/seats/%s.json", b.Seat, b.Seat, b.Seat) + } + } +} diff --git a/go/seatlist/seatlisttest/parts/build-step.json b/go/seatlist/seatlisttest/parts/build-step.json new file mode 100644 index 0000000..5592037 --- /dev/null +++ b/go/seatlist/seatlisttest/parts/build-step.json @@ -0,0 +1,721 @@ +{ + "format": 1, + "part": "build-step", + "description": "What the three build seats share: the verbs that read and steer a node's builds, and the layouts of the asks they accept and the steps they keep. A seat lists its states against these (a part carries no states).", + "verbs": [ + { + "name": "current", + "description": "The asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "description": "the steps running, the capacity and whether paused" + } + }, + { + "name": "pause", + "description": "Take no new ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module. A capacity of 0 is the lasting way to keep a node from building.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "description": "whether the node is paused" + } + }, + { + "name": "resume", + "description": "Take asks again on this node.", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "type": "object", + "description": "whether the node is paused" + } + } + ], + "defs": { + "StepAsk": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "build, check or compose" + }, + "language": { + "type": "string", + "description": "go, typescript or image" + }, + "asker": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "build": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "path": { + "type": "string" + }, + "fp": { + "type": "string" + }, + "fetch": { + "$ref": "#/defs/Fetch" + }, + "artifacts": { + "type": "array", + "items": { + "$ref": "#/defs/AskArtifact" + } + }, + "publish": { + "type": "boolean" + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed" + } + }, + "required": [ + "module", + "artifacts" + ] + }, + "check": { + "type": "object", + "properties": { + "fetch": { + "$ref": "#/defs/Fetch" + }, + "levels": { + "type": "array", + "items": { + "type": "string", + "description": "unit or integration" + } + }, + "kept": { + "type": "boolean" + }, + "runner": { + "type": "string" + }, + "runner_when": { + "type": "string" + }, + "run": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "fetch", + "levels" + ] + }, + "compose": { + "type": "object", + "properties": { + "fetch": { + "$ref": "#/defs/Fetch" + }, + "dir": { + "type": "string" + }, + "package": { + "type": "string" + }, + "request": { + "description": "the compose-candidate request" + }, + "reader": { + "type": "string" + } + }, + "required": [ + "fetch", + "dir", + "package", + "request" + ] + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "package to the tagged version a build pins" + } + }, + "required": [ + "id", + "kind", + "language", + "asker" + ], + "description": "one ask of a build module" + }, + "BuildAnswer": { + "type": "object", + "properties": { + "artifacts": { + "type": "array", + "items": { + "$ref": "#/defs/BuiltArtifact" + } + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed" + }, + "imports": { + "type": "array", + "items": { + "type": "string" + } + }, + "out-of-step": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "artifacts" + ] + }, + "CheckAnswer": { + "type": "object", + "properties": { + "fetch": { + "$ref": "#/defs/Fetch" + }, + "levels": { + "type": "array", + "items": { + "$ref": "#/defs/LevelAnswer" + } + } + }, + "required": [ + "fetch" + ] + }, + "ComposeAnswer": { + "type": "object", + "properties": { + "built": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "verdict": { + "description": "the compose-candidate verdict" + } + }, + "required": [ + "built" + ] + }, + "Step": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "id": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "node": { + "type": "string" + }, + "state": { + "type": "string", + "description": "running, passed, failed or refused" + }, + "started": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "ended": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "reason": { + "type": "string" + }, + "result": { + "$ref": "#/defs/StepResult" + }, + "what": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "phase-at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "owner": { + "type": "string" + } + }, + "required": [ + "id", + "tool", + "node", + "state", + "started" + ], + "description": "one step a build module runs on a node" + }, + "Toolchain": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "language": { + "type": "string", + "description": "go or typescript" + }, + "version": { + "type": "string" + }, + "recipe": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "language", + "version", + "recipe" + ], + "description": "the toolchain a node reports" + }, + "Pause": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "paused": { + "type": "boolean" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "paused" + ], + "description": "whether the node takes no new ask" + }, + "Capacity": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "effective": { + "type": "integer" + }, + "configured": { + "type": "integer" + }, + "configured-from": { + "type": "string" + }, + "quota-cores": { + "type": "number" + }, + "quota-from": { + "type": "string" + }, + "cores-per-step": { + "type": "number" + }, + "by-cpu": { + "type": "integer" + }, + "build-hours": { + "type": "string" + }, + "outside-hours": { + "type": "boolean" + }, + "build-hours-fault": { + "type": "string" + }, + "ineligible": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "effective", + "configured", + "why" + ], + "description": "how many steps the node runs at once" + }, + "Fetch": { + "type": "object", + "description": "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, the module's directory and named paths, the mesh packages to build against, and the sibling repositories placed beside", + "properties": { + "repository": { + "type": "string", + "description": "/" + }, + "commit": { + "type": "string", + "description": "the full commit" + }, + "ref": { + "type": "string", + "description": "the pull request's ref, when only its head carries the commit" + }, + "base": { + "type": "string", + "description": "the trunk commit a merge preview merges onto" + }, + "dir": { + "type": "string", + "description": "the module's directory in the repository (\".\" for its root), with paths" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the paths the module names, relative to dir" + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "the version (a full commit) of each mesh package to build against, by its module path" + }, + "beside": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + } + }, + "required": [ + "repository", + "commit" + ] + }, + "description": "the sibling repositories placed beside the fetched tree" + } + }, + "required": [ + "repository", + "commit" + ] + }, + "ToolchainUsed": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "version": { + "type": "string" + }, + "recipe": { + "type": "string" + } + }, + "required": [ + "language", + "version", + "recipe" + ], + "description": "the toolchain a build was made with: the language, the installed version and the build module's recipe" + }, + "AskArtifact": { + "type": "object", + "properties": { + "artifact": { + "type": "string" + }, + "from": { + "type": "string" + }, + "seat": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "artifact" + ], + "description": "one artifact of a build ask: built from the fetched tree, or copied from an upstream pin" + }, + "BuiltArtifact": { + "type": "object", + "properties": { + "artifact": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "artifact", + "digest" + ], + "description": "one artifact a build made: its digest and the reference it was published under" + }, + "LevelAnswer": { + "type": "object", + "properties": { + "level": { + "type": "string" + }, + "judged": { + "type": "string" + }, + "digest": { + "type": "object", + "properties": { + "level": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "beside": { + "type": "array", + "items": { + "type": "string" + } + }, + "without_tests": { + "type": "array", + "items": { + "type": "string" + } + }, + "judged_by": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "kept": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "tests": { + "type": "integer" + } + } + } + }, + "tests": { + "type": "array", + "items": { + "description": "one test's result" + } + }, + "unmet": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "required": [ + "level" + ], + "description": "one level of a check ask: the digest's answer, the kept verdicts taken, one test result per test step run, and the packages the node could not test" + }, + "StepResult": { + "type": "object", + "description": "what a step answered, by the kind of ask: a build's artifacts, toolchain, imports and out-of-step modules; a check's fetch and levels; a compose's built, key and verdict; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the four", + "properties": { + "artifacts": { + "type": "array", + "items": { + "$ref": "#/defs/BuiltArtifact" + } + }, + "toolchain": { + "$ref": "#/defs/ToolchainUsed" + }, + "imports": { + "type": "array", + "items": { + "type": "string" + } + }, + "out-of-step": { + "type": "array", + "items": { + "type": "string" + } + }, + "fetch": { + "$ref": "#/defs/Fetch" + }, + "levels": { + "type": "array", + "items": { + "$ref": "#/defs/LevelAnswer" + } + }, + "built": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "verdict": { + "description": "the compose-candidate verdict (the composer's own layout)" + }, + "declined": { + "type": "boolean", + "description": "the holder declined the ask: its node lacks what the ask needs" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "what the declined ask needs" + }, + "lacks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "what the holder's node lacks of it" + } + } + } + } +} diff --git a/go/seatlist/seatlisttest/seats/container-image-registry.json b/go/seatlist/seatlisttest/seats/container-image-registry.json index 3de19d8..bc116b4 100644 --- a/go/seatlist/seatlisttest/seats/container-image-registry.json +++ b/go/seatlist/seatlisttest/seats/container-image-registry.json @@ -341,5 +341,140 @@ "held" ] } - } + }, + "states": [ + { + "name": "holds", + "template": "state", + "history": 1, + "description": "the bundles, archives and copied bases kept from retention's removal", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one hold: an untagged bundle, archive or copied base kept from retention's removal", + "properties": { + "seat": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "seat", + "name", + "kind", + "digest", + "by", + "since" + ] + }, + "description": "one hold" + } + ] + }, + { + "name": "windows", + "template": "state", + "history": 1, + "description": "the direct-write windows, one per caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the direct-write window of one caller: open while a node holds it open", + "properties": { + "name": { + "type": "string", + "description": "the caller whose window it is" + }, + "open": { + "type": "boolean" + }, + "holding": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the nodes holding it open" + }, + "since": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "description": "node to when it began to hold" + }, + "closed": { + "type": "string", + "description": "when it last closed (RFC 3339 time)" + }, + "last_holding": { + "type": "array", + "items": { + "type": "string" + } + }, + "dropped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "node" + ] + }, + "description": "retired nodes dropped by hand" + }, + "why": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "of each node holding the window, why it holds it" + } + }, + "required": [ + "name" + ] + }, + "description": "one caller's window" + } + ] + } + ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-catalog.json b/go/seatlist/seatlisttest/seats/mesh-catalog.json index 5f71e23..9fb8846 100644 --- a/go/seatlist/seatlisttest/seats/mesh-catalog.json +++ b/go/seatlist/seatlisttest/seats/mesh-catalog.json @@ -2,7 +2,7 @@ "format": 1, "seat": "mesh-catalog", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Delivery", "decision": "novox/hq ADR 0604", "verbs": [ @@ -13,6 +13,50 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "every module the trunk holds, by name", + "properties": { + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + } + }, + "required": [ + "modules" + ] } }, { @@ -30,6 +74,51 @@ "module" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the module whole", + "properties": { + "module": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the module's directory in its repository, '.' for a root" + }, + "commit": { + "type": "string", + "description": "the merge commit it was last changed at" + }, + "fp": { + "type": "string", + "description": "its fingerprint (src2: and 64 hex digits); absent when it has none" + }, + "none": { + "type": "string", + "description": "why it has none" + }, + "manifest": { + "type": "object", + "description": "the module's manifest as the trunk holds it" + } + }, + "required": [ + "module", + "repository", + "commit" + ] + } + }, + "required": [ + "module" + ] } }, { @@ -47,6 +136,10 @@ "provision" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what provides answers, as its description says" } }, { @@ -64,7 +157,896 @@ "module" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what dependents answers, as its description says" + } + }, + { + "name": "seat-definitions", + "description": "The seat list (ADR 0636 \u00a74): with no seat, every trunk seat with its digest and the revision of the list; with a seat, that trunk definition resolved with the parts it includes. Reads only.", + "optional": true, + "input": { + "properties": { + "seat": { + "description": "the seat's name; none lists every trunk seat", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "with no seat: the revision of the list and every trunk seat with its digest; with a seat: whether it was found and its entry, the definition resolved", + "properties": { + "revision": { + "type": "integer" + }, + "seats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + }, + "seat": { + "type": "string" + }, + "found": { + "type": "boolean" + }, + "entry": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + }, + "definition": { + "type": "object", + "description": "the definition, resolved with the parts it includes" + }, + "unparsed": { + "type": "string", + "description": "why the definition could not be read, when it could not" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + } } } - ] + ], + "description": "The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 \u00a71, \u00a74).", + "family": { + "name": "mesh-catalog.events", + "max-age-days": 7 + }, + "events": [ + { + "name": "seat-definition-changed", + "body": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "description": "trunk" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "seat", + "source", + "digest" + ] + } + }, + { + "name": "seat-definition-removed", + "body": { + "type": "object", + "properties": { + "seat": { + "type": "string" + } + }, + "required": [ + "seat" + ] + } + } + ], + "states": [ + { + "name": "manifests", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string" + }, + "manifest": { + "type": "string", + "description": "the manifest, UTF-8 JSON text" + } + }, + "required": [ + "module", + "manifest" + ] + }, + "description": "a manifest by its digest, written once" + } + ], + "description": "every manifest the indexer judged", + "history": 1 + }, + { + "name": "modules", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "fp": { + "type": "string", + "description": "the old-form fingerprint" + }, + "none": { + "type": "string" + }, + "fp2": { + "type": "string", + "description": "src2: fingerprint" + }, + "none2": { + "type": "string", + "description": "why it has none" + }, + "manifest": { + "type": "string", + "description": "manifest digest" + }, + "inputs2": { + "type": "object", + "properties": { + "tree": { + "type": "string" + }, + "bases": { + "type": "array", + "items": { + "type": "string" + } + }, + "builder": { + "type": "string" + }, + "contexts": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + }, + "imports": { + "type": "array", + "items": { + "type": "string" + } + }, + "import-form": { + "type": "string" + } + }, + "required": [ + "tree" + ] + }, + "src": { + "type": "integer", + "description": "the source revision" + }, + "fp-src": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "path": { + "type": "string" + }, + "toolchains": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/ToolchainUsed" + }, + "description": "by language: the toolchain FP2 was computed with" + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "verdict": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + }, + "unknown": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "needs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repository", + "commit" + ], + "description": "one module as the trunk holds it" + }, + "description": "a module the trunk holds" + } + ], + "description": "the module catalog", + "history": 20 + }, + { + "name": "candidates", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "base": { + "type": "integer", + "description": "the source revision it is read on" + }, + "heads": { + "type": "string" + }, + "pulls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "fps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "fp2": { + "type": "string" + }, + "none2": { + "type": "string" + }, + "manifest": { + "type": "string" + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "required": [ + "repository", + "number", + "head", + "modules" + ] + } + }, + "fpset": { + "type": "string" + } + }, + "required": [ + "group", + "base", + "heads", + "pulls" + ], + "description": "the modules a group of open pull requests would change" + }, + "description": "a group's candidate" + } + ], + "description": "the candidates of open groups", + "history": 10 + }, + { + "name": "verdicts", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string" + }, + "state": { + "type": "string" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + }, + "unknown": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "judge": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "state" + ], + "description": "a manifest's verdict, by the judge named" + }, + "description": "a manifest's verdict by its digest" + } + ], + "description": "the manifest verdicts", + "history": 5 + }, + { + "name": "predefined", + "template": "state", + "key-forms": [ + { + "key": "list", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "commit": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + }, + "list": { + "description": "the foundation's predefined list as kept" + } + }, + "required": [ + "commit", + "list" + ] + }, + "description": "the one key" + } + ], + "description": "the kept predefined list", + "history": 5 + }, + { + "name": "config-candidates", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "pull": { + "type": "string" + }, + "head": { + "type": "string" + }, + "declares": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "default": { + "description": "the default" + }, + "why": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "description": "the allowed values" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "trusted": { + "type": "boolean" + }, + "predefined": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "runtime": { + "type": "boolean" + } + }, + "required": [ + "kind", + "default", + "why" + ] + } + }, + "description": "module to key to declaration" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "pull", + "head", + "declares" + ] + }, + "description": "the configuration a pull request declares" + } + ], + "description": "the configuration declared by open pull requests", + "history": 5 + }, + { + "name": "packages", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + }, + "description": "a mesh Go module as its latest trunk merge published it" + }, + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "name": { + "type": "string" + }, + "package": { + "type": "string" + }, + "series": { + "type": "string" + }, + "seat": { + "type": "string" + }, + "go-module": { + "type": "string" + }, + "newest": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "contents": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + } + }, + "required": [ + "version" + ] + }, + "numbered": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "contents": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "module": { + "type": "string", + "description": "the Go module path" + }, + "repository": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "requires": { + "type": "string", + "description": "digest of go.mod and go.sum" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "package", + "digest" + ] + } + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "module", + "repository", + "commit", + "packages" + ] + } + }, + "required": [ + "version" + ] + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)", + "format": "date-time" + } + }, + "required": [ + "name", + "series" + ] + }, + "description": "a published package series, numbered and newest" + } + ], + "description": "the published packages", + "history": 5 + } + ], + "defs": { + "ToolchainUsed": { + "type": "object", + "description": "a toolchain a fingerprint was computed with (ADR 0405 clause 7)", + "properties": { + "language": { + "type": "string" + }, + "version": { + "type": "string", + "description": "the installed toolchain's version" + }, + "recipe": { + "type": "string", + "description": "the build module's recipe version" + } + }, + "required": [ + "language", + "version", + "recipe" + ] + } + } } diff --git a/go/seatlist/seatlisttest/seats/mesh-conditions.json b/go/seatlist/seatlisttest/seats/mesh-conditions.json index b48b098..76f464c 100644 --- a/go/seatlist/seatlisttest/seats/mesh-conditions.json +++ b/go/seatlist/seatlisttest/seats/mesh-conditions.json @@ -2,9 +2,10 @@ "format": 1, "seat": "mesh-conditions", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Health and repair", - "decision": "novox/hq ADR 0564", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "What is wrong with the mesh now (ADR 0564): the open conditions raised from the observers' findings and cleared when a finding goes.", "verbs": [ { "name": "conditions", @@ -41,6 +42,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the open conditions, or one whole, or the history" } }, { @@ -71,6 +76,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the condition now silenced" } }, { @@ -79,18 +88,560 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what was raised and cleared again" } } ], "events": [ { - "name": "condition-raised" + "name": "condition-raised", + "body": { + "$ref": "#/defs/ConditionEvent" + } }, { - "name": "condition-changed" + "name": "condition-changed", + "body": { + "$ref": "#/defs/ConditionEvent" + } }, { - "name": "condition-cleared" + "name": "condition-cleared", + "body": { + "$ref": "#/defs/ConditionEvent" + } } - ] + ], + "family": { + "name": "mesh-conditions.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "conditions", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one open condition", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + } + }, + "required": [ + "key", + "kind", + "subject", + "severity", + "raised", + "last-observed" + ] + }, + "description": "one open condition, without its looks it keeps from its finding" + } + ], + "description": "the open conditions the keeper holds, one key each" + }, + { + "name": "findings", + "template": "state", + "writer": "caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "every observer's findings, one key per finding; a key written raises or changes the condition of the same key, a key deleted clears it" + } + ], + "defs": { + "Subject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "Evidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] + }, + "Action": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "ConditionEvent": { + "type": "object", + "description": "a condition and what changed", + "properties": { + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/Subject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/Action" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/Evidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "raised": { + "type": "string", + "format": "date-time" + }, + "last-observed": { + "type": "string", + "format": "date-time" + }, + "first": { + "type": "string", + "format": "date-time" + }, + "gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cleared": { + "type": "string", + "format": "date-time" + }, + "reopened": { + "type": "string", + "format": "date-time" + } + } + } + }, + "observations": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "tried": { + "type": "array", + "items": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "what": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "by": { + "type": "string" + } + } + } + }, + "silenced": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "format": "date-time" + } + } + }, + "epoch": { + "type": "integer" + }, + "looks-unread": { + "type": "string" + }, + "event": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "change": { + "type": "string", + "enum": [ + "raised", + "reopened", + "severity", + "resolver", + "silenced", + "silence-ended", + "cleared", + "words" + ] + }, + "was": { + "type": "string" + }, + "why": { + "type": "string" + }, + "cleared": { + "type": "string", + "format": "date-time" + }, + "show": { + "type": "string", + "description": "the tool call that shows it" + } + }, + "required": [ + "key", + "event", + "at", + "change" + ] + } + } } diff --git a/go/seatlist/seatlisttest/seats/mesh-config.json b/go/seatlist/seatlisttest/seats/mesh-config.json index 0a5e0e1..83c63f3 100644 --- a/go/seatlist/seatlisttest/seats/mesh-config.json +++ b/go/seatlist/seatlisttest/seats/mesh-config.json @@ -219,5 +219,54 @@ ] } } + ], + "states": [ + { + "name": "resolved", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "the value a module on a node takes for a config key, and where it came from", + "properties": { + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": {}, + "source": { + "type": "string" + }, + "predefined": { + "type": "string", + "description": "the predefined key the value falls back to" + }, + "required": { + "type": "boolean", + "description": "an unset value is the operator's own to set" + }, + "trusted": { + "type": "boolean" + } + }, + "required": [ + "node", + "module", + "key", + "source" + ] + }, + "description": "one resolved value" + } + ], + "description": "the value each module on each node takes for each config key, built from the sources alone" + } ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json b/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json index 808b3e9..0a9220d 100644 --- a/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json +++ b/go/seatlist/seatlisttest/seats/mesh-delivery-checker.json @@ -242,5 +242,395 @@ "subject": "mesh.stream..event.preview.written", "family": "checks" } + ], + "states": [ + { + "name": "verdicts", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "string", + "description": "12 hex digits" + }, + "trunk": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "repository to the trunk head merged onto" + }, + "state": { + "type": "string", + "description": "running, pass or fail" + }, + "failed": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "machine": { + "type": "string" + }, + "words": { + "type": "string" + } + } + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "test": { + "type": "string" + }, + "machine": { + "type": "string" + }, + "words": { + "type": "string" + } + } + } + }, + "levels": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "run": { + "type": "integer" + }, + "kept": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + }, + "required": [ + "run", + "kept" + ] + }, + "description": "by level, unit or integration" + }, + "took": { + "type": "object", + "properties": { + "queue": { + "type": "integer" + }, + "clone": { + "type": "integer" + }, + "unit": { + "type": "integer" + }, + "lint": { + "type": "integer" + }, + "integration-setup": { + "type": "integer" + }, + "integration-run": { + "type": "integer" + }, + "composition": { + "type": "integer" + } + }, + "description": "milliseconds" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "sdk": { + "type": "string" + }, + "composition": { + "type": "object", + "properties": { + "state": { + "type": "string", + "description": "not-run, pass or fail" + }, + "words": { + "type": "string" + }, + "sdk": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "fpset": { + "type": "string" + } + }, + "required": [ + "state" + ] + }, + "over-bound": { + "type": "array", + "items": { + "type": "string" + } + }, + "beside": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "carried-from": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "artifact": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "module", + "artifact", + "from", + "to" + ] + } + } + }, + "required": [ + "group", + "heads", + "state" + ], + "description": "one check of a group at one set of heads" + }, + "description": "one check of a group; the group is an escaped branch name, heads 12 hex digits" + } + ], + "description": "the checker's verdict of each group at each set of heads", + "history": 5 + }, + { + "name": "kept", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "state": { + "type": "string", + "description": "pass: a failure is never kept" + }, + "repository": { + "type": "string" + }, + "package": { + "type": "string" + }, + "level": { + "type": "string" + }, + "tests": { + "type": "integer" + }, + "took": { + "type": "integer" + }, + "toolchain": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "state", + "repository", + "package", + "level" + ], + "description": "one passing level of one package, kept so the next check does not run it again" + }, + "description": "a passing level of one package by its content digest" + } + ], + "description": "the kept passing levels", + "history": 1 + }, + { + "name": "owed", + "template": "state", + "key-forms": [ + { + "key": "...", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "act": { + "type": "string", + "description": "status=, view, note or comment." + }, + "context": { + "type": "string" + }, + "target": { + "type": "string" + }, + "state": { + "type": "string" + }, + "text": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "act" + ], + "description": "one act owed to the forge, written by its owner and done by the forge bridge" + }, + "description": "the status mesh/check the checker owes the forge for a head (its act is status=mesh/check)" + } + ], + "description": "the status the checker owes the forge", + "history": 5 + }, + { + "name": "previews", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "repository": { + "type": "string" + }, + "head": { + "type": "string" + }, + "trunk": { + "type": "string" + }, + "merge": { + "type": "string", + "description": "the merge commit" + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "files in conflict" + }, + "modules": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "tree": { + "type": "string" + }, + "manifest": { + "type": "string", + "description": "manifest digest" + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "paths": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "packages": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tree", + "manifest" + ] + }, + "description": "by directory" + } + }, + "required": [ + "repository", + "head", + "trunk" + ], + "description": "the merge of a head onto a trunk commit, read before a pull request is checked" + }, + "description": "the merge of a head onto a trunk commit" + } + ], + "description": "the previews of a head merged onto a trunk", + "history": 5 + } ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-delivery.json b/go/seatlist/seatlisttest/seats/mesh-delivery.json index 43ba193..8365118 100644 --- a/go/seatlist/seatlisttest/seats/mesh-delivery.json +++ b/go/seatlist/seatlisttest/seats/mesh-delivery.json @@ -2,7 +2,7 @@ "format": 1, "seat": "mesh-delivery", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Delivery", "decision": "novox/hq ADR 0239", "verbs": [ @@ -33,6 +33,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "what deliveries answers, as its description says" } }, { @@ -42,6 +46,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what times answers, as its description says" } }, { @@ -58,6 +66,10 @@ "id" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what show answers, as its description says" } }, { @@ -75,6 +87,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "what groups answers, as its description says" } }, { @@ -84,6 +100,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what what-if answers, as its description says" } }, { @@ -109,6 +129,10 @@ "repository" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what checks answers, as its description says" } }, { @@ -117,6 +141,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what table answers, as its description says" } }, { @@ -125,6 +153,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what stalled answers, as its description says" } }, { @@ -146,6 +178,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what recheck answers, as its description says" } }, { @@ -155,6 +191,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what release answers, as its description says" } }, { @@ -164,6 +204,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what stop answers, as its description says" } }, { @@ -185,6 +229,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what close answers, as its description says" } }, { @@ -194,6 +242,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what retire-history answers, as its description says" } }, { @@ -221,7 +273,299 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what settle answers, as its description says" } } + ], + "description": "The delivery gate: reads and steers deliveries, merges and the checks that decide them; the merge is made on the operator's Approve once both checks passed.", + "states": [ + { + "name": "source", + "template": "state", + "key-forms": [ + { + "key": "main", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string", + "description": "the group that moved it" + }, + "heads": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "repository to the merged head, a full commit" + } + }, + "required": [ + "group", + "heads" + ], + "description": "every repository's merged head; its revision is the mesh's source revision" + }, + "description": "the one key, written by the holder alone by compare-and-set" + } + ], + "description": "the mesh's source revision", + "history": 64 + }, + { + "name": "merges", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "ask": { + "type": "string", + "description": "the ask key" + }, + "approved": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "@ to the approved head" + }, + "pulls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string" + }, + "state": { + "type": "string", + "description": "merging, merged or refused" + }, + "commit": { + "type": "string" + }, + "words": { + "type": "string" + } + }, + "required": [ + "repository", + "number", + "head", + "state" + ] + } + } + }, + "required": [ + "group", + "ask", + "pulls" + ], + "description": "one group's merge and how far each pull request is" + }, + "description": "a group's merge" + } + ], + "description": "the merges", + "history": 10 + }, + { + "name": "asks", + "template": "state", + "key-forms": [ + { + "key": "merge..", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "/" + }, + "number": { + "type": "integer" + }, + "head": { + "type": "string", + "description": "the full commit" + } + }, + "required": [ + "repository", + "number", + "head" + ] + } + }, + "ask": { + "description": "the operator-channel ask as raised" + }, + "asking": { + "type": "integer", + "description": "the number of times it was asked" + } + }, + "required": [ + "group", + "heads", + "ask" + ] + }, + "description": "the ask of the operator for one group at one set of heads" + } + ], + "description": "the asks of the operator for a merge", + "history": 5 + }, + { + "name": "owed", + "template": "state", + "key-forms": [ + { + "key": "...", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "act": { + "type": "string", + "description": "status=, view, note or comment." + }, + "context": { + "type": "string" + }, + "target": { + "type": "string" + }, + "state": { + "type": "string" + }, + "text": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "act" + ], + "description": "one act owed to the forge, written by its owner and done by the forge bridge" + }, + "description": "one act owed to the forge" + } + ], + "description": "the acts the gate owes the forge", + "history": 5 + }, + { + "name": "turns", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "properties": { + "v": { + "type": "integer", + "description": "the layout version" + }, + "repository": { + "type": "string" + }, + "waiting": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group": { + "type": "string" + }, + "heads": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "approved": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "since": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "waits-for": { + "type": "string" + }, + "waits-since": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "step": { + "type": "string" + } + }, + "required": [ + "group", + "heads", + "approved", + "since" + ] + } + } + }, + "required": [ + "repository", + "waiting" + ], + "description": "the merges of a repository in the order they wait for their turn" + }, + "description": "the merges waiting on a repository" + } + ], + "description": "which merge waits for which", + "history": 5 + } ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-hand-acts.json b/go/seatlist/seatlisttest/seats/mesh-hand-acts.json index 9d760ec..a075cfc 100644 --- a/go/seatlist/seatlisttest/seats/mesh-hand-acts.json +++ b/go/seatlist/seatlisttest/seats/mesh-hand-acts.json @@ -2,9 +2,10 @@ "format": 1, "seat": "mesh-hand-acts", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Health and repair", - "decision": "novox/hq ADR 0565", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "Acts done by hand, recorded with who, why and cause (ADR 0565): the log every module records through.", "verbs": [ { "name": "hand-act", @@ -37,6 +38,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the act recorded" } }, { @@ -66,6 +71,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the acts lately" } }, { @@ -87,12 +96,226 @@ "ask" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the warrant recorded" } } ], "events": [ { - "name": "act-recorded" + "name": "act-recorded", + "body": { + "$ref": "#/defs/Act" + } } - ] + ], + "family": { + "name": "mesh-hand-acts.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "acts", + "template": "log", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + }, + "description": "one act" + } + ], + "max-age-days": 400, + "description": "every act, kept 400 days" + }, + { + "name": "warrants", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what a module did on the operator's warrant, made once per ask", + "properties": { + "ask": { + "type": "string" + }, + "asker": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "ask", + "asker" + ] + }, + "description": "one warrant" + } + ], + "description": "warrants recorded, one key per asker and ask" + } + ], + "defs": { + "Act": { + "type": "object", + "description": "one act done by hand", + "properties": { + "id": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string", + "description": "the caller the runtime proved" + }, + "verb": { + "type": "string" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "why": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string", + "description": "what the act was read to be from what it did" + }, + "carried": { + "type": "array", + "items": { + "type": "string" + } + }, + "via": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "proofs": { + "type": "array", + "items": { + "type": "string" + } + }, + "requested-by": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "log": { + "type": "string", + "description": "which log holds it, set when it is read" + } + }, + "required": [ + "id", + "at", + "by", + "verb", + "why", + "cause" + ] + } + } } diff --git a/go/seatlist/seatlisttest/seats/mesh-healers.json b/go/seatlist/seatlisttest/seats/mesh-healers.json index 5b67239..23772b0 100644 --- a/go/seatlist/seatlisttest/seats/mesh-healers.json +++ b/go/seatlist/seatlisttest/seats/mesh-healers.json @@ -2,9 +2,10 @@ "format": 1, "seat": "mesh-healers", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Health and repair", - "decision": "novox/hq ADR 0564", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The healers (ADR 0564): each a registered response to one kind of condition, within a budget and the mesh-wide brake.", "verbs": [ { "name": "healers", @@ -17,6 +18,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the registered healers, acts lately and the brake" } }, { @@ -38,6 +43,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the act taken" } }, { @@ -63,12 +72,179 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the drill recorded" } } ], "events": [ { - "name": "healer-acted" + "name": "healer-acted", + "body": { + "type": "object", + "description": "a healer's act", + "properties": { + "event": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "healer": { + "type": "string" + }, + "by": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "act": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "acting", + "acted", + "failed", + "escalated" + ] + }, + "said": { + "type": "string" + }, + "budget": { + "type": "string" + }, + "show": { + "type": "string" + } + }, + "required": [ + "event", + "at", + "healer", + "condition", + "outcome" + ] + } + } + ], + "family": { + "name": "mesh-healers.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "attempts", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one act of a healer, created before it is made and finished after, so a second instance cannot begin the same act", + "properties": { + "healer": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "budget-key": { + "type": "string", + "description": "what the budget counts by: the condition's key unless the healer says another" + }, + "act": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "acting", + "acted", + "failed", + "escalated" + ] + }, + "said": { + "type": "string" + }, + "begun": { + "type": "string", + "format": "date-time" + }, + "finished": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "healer", + "condition", + "kind", + "budget-key", + "act", + "outcome", + "begun" + ] + }, + "description": "one act, under the healers' own key" + } + ], + "description": "the acts begun, which the keeper reads to write a condition's tries" + }, + { + "name": "would", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "an act the healers would have taken while the controller's lease names a controller; it counts for no budget", + "properties": { + "healer": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "act": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "why": { + "type": "string" + } + }, + "required": [ + "healer", + "condition", + "act", + "at" + ] + }, + "description": "one act not taken" + } + ], + "description": "the acts the healers would have taken" } ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-module-assigner.json b/go/seatlist/seatlisttest/seats/mesh-module-assigner.json new file mode 100644 index 0000000..8e18a70 --- /dev/null +++ b/go/seatlist/seatlisttest/seats/mesh-module-assigner.json @@ -0,0 +1,942 @@ +{ + "format": 1, + "seat": "mesh-module-assigner", + "scope": "mesh", + "grain": "whole", + "domain": "Placement", + "decision": "novox/hq ADR 0567, ADR 0634, ADR 0635", + "description": "The seat that places modules on nodes (ADR 0567): the state of placement and the proposals that change it. Renamed from mesh-placement (ADR 0634 §9).", + "verbs": [ + { + "name": "placement", + "description": "Read placement: one node's key, the nodes one module is placed on, or every key, each with its revision, and how full the state is against its cap. With history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the proposal's changes from its placement-changed event.", + "optional": true, + "input": { + "properties": { + "history": { + "description": "\"true\": the changes in order, from the stream and the state's revisions", + "enum": [ + "true", + "false" + ], + "type": "string" + }, + "module": { + "description": "one module: the nodes it is placed on", + "type": "string" + }, + "node": { + "description": "one node", + "type": "string" + }, + "seats": { + "description": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's", + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "placement as read: nodes, keys with their revisions, history, seat holders, the state's fill" + } + }, + { + "name": "proposals", + "description": "Every open proposal of placement: its changes, the revision of each key it was laid over, its candidates, every node's answer, the differences computed and answered, its verdict and its ask. Narrowed to one by id.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "one proposal's id", + "type": "string" + } + }, + "type": "object" + }, + "answer": { + "type": "object", + "description": "the open proposals" + } + }, + { + "name": "propose", + "description": "Propose a change of placement, with why: laid over the current keys and refused at once, naming every finding, when the result cannot be placed (two modules on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", + "optional": true, + "input": { + "additionalProperties": false, + "properties": { + "changes": { + "description": "the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)", + "items": { + "required": [ + "op", + "node" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "warrant": { + "description": "for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address", + "required": [ + "node", + "address", + "ask", + "warrant" + ], + "type": "object" + }, + "why": { + "description": "why, shown to the operator with the proposal", + "type": "string" + } + }, + "required": [ + "why", + "changes" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the proposal's id and its verdict, or every finding refused at once" + } + }, + { + "name": "withdraw", + "description": "Withdraw an open proposal: its key and its candidates are deleted and placement-withdrawn carries it whole. With why.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + }, + "why": { + "description": "why, kept in the event", + "type": "string" + } + }, + "required": [ + "id", + "why" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "the proposal withdrawn" + } + }, + { + "name": "again", + "description": "Judge an open proposal now with the answers that stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. Nothing more: the recovery of a proposal whose judgement or ask was lost.", + "optional": true, + "input": { + "properties": { + "id": { + "description": "the proposal's id", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "answer": { + "type": "object", + "description": "what was judged or asked again" + } + } + ], + "events": [ + { + "name": "placement-proposed", + "body": { + "$ref": "#/defs/PlacementEvent" + } + }, + { + "name": "placement-withdrawn", + "body": { + "$ref": "#/defs/PlacementEvent" + } + }, + { + "name": "placement-judged", + "body": { + "$ref": "#/defs/PlacementEvent" + } + }, + { + "name": "placement-changed", + "body": { + "$ref": "#/defs/PlacementEvent" + } + } + ], + "family": { + "name": "mesh-module-assigner.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "placement", + "template": "state", + "key-forms": [ + { + "key": ".node", + "value": { + "type": "object", + "description": "a node's key: what a person decided of it, its pins and holdings, and the modules placed on it; the commit of a write", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "decided": { + "$ref": "#/defs/NodeDecided" + }, + "modules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "digest": { + "type": "string", + "description": "the digest of the module key's value it stands for" + } + }, + "required": [ + "module", + "digest" + ] + }, + "description": "the modules placed, in the order the composition reads them" + }, + "pins": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + } + }, + "holds": { + "type": "array", + "items": { + "$ref": "#/defs/Holding" + } + } + }, + "required": [ + "node", + "decided", + "modules" + ] + }, + "description": "a node's key" + }, + { + "key": ".", + "value": { + "type": "object", + "description": "a module's key: the module as placed", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "placed": { + "$ref": "#/defs/PlacedModule" + }, + "previous": { + "$ref": "#/defs/PlacedModule", + "description": "the value it replaces while a write is under way" + } + }, + "required": [ + "node", + "placed" + ] + }, + "description": "one module on a node; no module is named node" + } + ], + "history": 64, + "description": "placements, one key per node and one per module placed on it, written only on the operator's Approve of a proposal by compare-and-set at the revision it was laid over" + }, + { + "name": "proposals", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "$ref": "#/defs/Proposal" + }, + "description": "an open proposal, by its id" + } + ], + "history": 5, + "description": "open proposals; a key leaves when the proposal is applied, withdrawn, declined or expired" + }, + { + "name": "candidates", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one node's candidate in one proposal", + "properties": { + "v": { + "type": "integer" + }, + "proposal": { + "type": "string" + }, + "reached": { + "type": "boolean", + "description": "the proposal changes this node's composition through another's key" + }, + "placement": { + "$ref": "#/defs/Placement", + "description": "the node's placement with the proposal laid over it; absent when the proposal takes its key away" + } + }, + "required": [ + "proposal" + ] + }, + "description": "a node's candidate" + } + ], + "history": 5, + "description": "the candidates a proposal needs answers on; a node watches its own" + }, + { + "name": "pulses", + "template": "state", + "key-forms": [ + { + "key": "answer..", + "value": { + "type": "object", + "description": "the bound on one node's answer", + "properties": { + "v": { + "type": "integer" + } + } + }, + "description": "the bound on a node's answer" + }, + { + "key": "ask.", + "value": { + "type": "object", + "description": "the bound on a raised ask", + "properties": { + "v": { + "type": "integer" + } + } + }, + "description": "the bound on a proposal's raised ask" + } + ], + "description": "bounds, each a key that lives for the bound; the server's delete marker when one ends is the event that fails a silent node or an unanswered ask" + }, + { + "name": "seat-holders", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "$ref": "#/defs/SeatHolders" + }, + "description": "a seat held once for the mesh" + }, + { + "key": ".", + "value": { + "$ref": "#/defs/SeatHolders" + }, + "description": "a node seat on one node" + } + ], + "description": "who holds which seat on which node, composed from every node's placement" + } + ], + "defs": { + "Provider": { + "type": "object", + "description": "the module on a node that answers a requirement or holds a seat", + "properties": { + "node": { + "type": "string" + }, + "module": { + "type": "string" + } + }, + "required": [ + "node", + "module" + ] + }, + "PlacedModule": { + "type": "object", + "description": "one module placed on a node", + "properties": { + "module": { + "type": "string" + }, + "ports": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "the host port each declared port is published on, by the port the software listens on" + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + }, + "description": "by provision, the provider each requirement that keeps data is bound to" + }, + "mesh-wide": { + "type": "object", + "description": "the whole mesh's settings layer of the module's placement" + }, + "has-mesh-wide": { + "type": "boolean" + }, + "layer": { + "type": "object", + "description": "this node's settings layer of the module's placement" + }, + "has-layer": { + "type": "boolean" + }, + "mesh-wide-values": { + "type": "object", + "description": "config values a person set in the whole mesh's layer, for a module in the settings form" + }, + "values": { + "type": "object", + "description": "config values a person set in this node's layer, for a module in the settings form" + } + }, + "required": [ + "module" + ] + }, + "Holding": { + "type": "object", + "description": "one seat a module placed on the node holds", + "properties": { + "claim": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "module": { + "type": "string" + }, + "site": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "claim", + "module" + ] + }, + "NodeDecided": { + "type": "object", + "description": "what a person decides of a node; no secret and no key material", + "properties": { + "endpoint": { + "type": "string" + }, + "site": { + "type": "string" + }, + "hub": { + "type": "boolean" + }, + "address": { + "type": "string" + }, + "public-domain": { + "type": "string" + }, + "account": { + "type": "string" + }, + "account-home": { + "type": "string" + }, + "agent-account": { + "type": "string" + }, + "agent-account-home": { + "type": "string" + }, + "adopted": { + "type": "boolean" + }, + "taken": { + "type": "array", + "items": { + "type": "string" + } + }, + "tunnel": { + "type": "object", + "description": "the tunnel found on the node when it was adopted" + }, + "peer-names": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "on the hub, the name a person gave a peer of that tunnel, by its public key" + } + } + }, + "Placement": { + "type": "object", + "description": "one node's placement assembled", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "decided": { + "$ref": "#/defs/NodeDecided" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/defs/PlacedModule" + } + }, + "pins": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + }, + "description": "by provision, the provider chosen for the node" + }, + "holds": { + "type": "array", + "items": { + "$ref": "#/defs/Holding" + } + } + }, + "required": [ + "node", + "decided", + "modules" + ] + }, + "ProposalChange": { + "type": "object", + "description": "one change of a proposal", + "properties": { + "op": { + "type": "string", + "description": "place, take-off, pin, unpin, hold, unhold, decide, config, seed, add-node or remove-node" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "ports": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/Provider" + } + }, + "provision": { + "type": "string" + }, + "provider": { + "$ref": "#/defs/Provider" + }, + "claim": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "site": { + "type": "string" + }, + "field": { + "type": "string" + }, + "value": { + "description": "a decided field's value" + }, + "mesh-wide": { + "type": "object" + }, + "layer": { + "type": "object" + }, + "mesh-wide-values": { + "type": "object" + }, + "values": { + "type": "object" + }, + "whole": { + "$ref": "#/defs/Placement", + "description": "the node's whole placement, for a seed" + }, + "decided": { + "$ref": "#/defs/NodeDecided" + }, + "keys": { + "type": "object", + "properties": { + "identity": { + "type": "string" + }, + "overlay": { + "type": "string" + }, + "sealing": { + "type": "string" + }, + "serving": { + "type": "string" + } + }, + "required": [ + "identity", + "sealing" + ], + "description": "the public halves of a joining node's keys" + } + }, + "required": [ + "op", + "node" + ] + }, + "Warrant": { + "type": "object", + "description": "the enrolment's warrant an add-node carries", + "properties": { + "node": { + "type": "string" + }, + "address": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "warrant": { + "type": "string" + } + }, + "required": [ + "node", + "address", + "ask", + "warrant" + ] + }, + "Verdict": { + "type": "object", + "description": "the holder's judgement of a proposal's answers", + "properties": { + "state": { + "type": "string", + "description": "waits, pass or fail" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "fails": { + "type": "array", + "items": { + "type": "string" + } + }, + "silent": { + "type": "array", + "items": { + "type": "string" + } + }, + "until": { + "type": "string", + "format": "date-time" + }, + "intended": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "unintended": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "carried": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "answers": { + "type": "object", + "additionalProperties": { + "type": "object" + }, + "description": "every node's answer to its candidate, by node" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "state", + "at" + ] + }, + "Proposal": { + "type": "object", + "description": "an open proposal", + "properties": { + "v": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "written": { + "type": "string", + "format": "date-time" + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/defs/ProposalChange" + } + }, + "seed": { + "type": "boolean" + }, + "export-digest": { + "type": "string" + }, + "base": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "by node whose key it changes, the revision of the key it was laid over (0: none)" + }, + "must": { + "type": "array", + "items": { + "type": "string" + } + }, + "hand-back": { + "type": "boolean" + }, + "state": { + "type": "string", + "description": "answering, failed or asked" + }, + "verdict": { + "$ref": "#/defs/Verdict" + }, + "ask": { + "type": "string" + }, + "words": { + "type": "string" + }, + "again": { + "type": "string", + "format": "date-time" + }, + "warrant": { + "$ref": "#/defs/Warrant" + }, + "withdrawn": { + "type": "string" + } + }, + "required": [ + "id", + "by", + "why", + "at", + "changes", + "base", + "state" + ] + }, + "PlacementEvent": { + "type": "object", + "description": "what each placement event carries", + "properties": { + "proposal": { + "$ref": "#/defs/Proposal" + }, + "nodes": { + "type": "array", + "items": { + "type": "object" + }, + "description": "for placement-changed, by node the revisions before and after and the modules added, removed or changed" + }, + "warrant": { + "type": "object", + "description": "who approved it, through which channel, when", + "properties": { + "warrant": { + "type": "string" + }, + "words": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "warrant", + "words", + "at" + ] + }, + "ended": { + "type": "string", + "description": "for placement-withdrawn, why it ended" + } + }, + "required": [ + "proposal" + ] + }, + "SeatHolders": { + "type": "object", + "description": "one key of seat-holders", + "properties": { + "v": { + "type": "integer" + }, + "seat": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "node": { + "type": "string" + }, + "outside": { + "type": "boolean", + "description": "the claim names no seat of the seat list" + }, + "holders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + }, + "required": [ + "module", + "node" + ] + } + } + }, + "required": [ + "seat", + "scope", + "holders" + ] + } + } +} diff --git a/go/seatlist/seatlisttest/seats/mesh-enrolment.json b/go/seatlist/seatlisttest/seats/mesh-node-enroller.json similarity index 51% rename from go/seatlist/seatlisttest/seats/mesh-enrolment.json rename to go/seatlist/seatlisttest/seats/mesh-node-enroller.json index 474653a..4d21cd0 100644 --- a/go/seatlist/seatlisttest/seats/mesh-enrolment.json +++ b/go/seatlist/seatlisttest/seats/mesh-node-enroller.json @@ -1,10 +1,11 @@ { "format": 1, - "seat": "mesh-enrolment", + "seat": "mesh-node-enroller", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Identity and access", - "decision": "novox/hq ADR 0587", + "decision": "novox/hq ADR 0587, ADR 0634, ADR 0635", + "description": "The seat that enrols a new machine on the operator's Approve and a one-time token (ADR 0587). Renamed from mesh-enrolment (ADR 0634 §9).", "verbs": [ { "name": "issue", @@ -34,11 +35,15 @@ "overlay_key" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment's stage, address and ask, or every finding refused at once" } }, { "name": "redeem", - "description": "Spend a token: asked by a joining node-engine dialled as enrol.\u003cnode\u003e alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the vault for the node's account and certificate, writes the node into placement, and answers the account sealed to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys it finishes what is not done and answers the same; with other keys it is refused.", + "description": "Spend a token: asked by a joining node-engine dialled as enrol. alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the vault for the node's account and certificate, writes the node into placement, and answers the account sealed to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys it finishes what is not done and answers the same; with other keys it is refused.", "input": { "additionalProperties": false, "properties": { @@ -82,6 +87,43 @@ "proof" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the account sealed to the node, its certificate and the vault's public half, signed by the vault", + "properties": { + "node": { + "type": "string", + "description": "the node" + }, + "login": { + "type": "string", + "description": "the login it dials as next" + }, + "account": { + "type": "string", + "description": "its account sealed to the sealing key it presented, base64" + }, + "certificate": { + "type": "string", + "description": "its certificate, PEM" + }, + "signer": { + "type": "string", + "description": "the vault's public signing half, base64" + }, + "signature": { + "type": "string", + "description": "the vault's signature over the answer without it, under the context of the enrolment kind, base64" + } + }, + "required": [ + "node", + "login", + "account", + "certificate", + "signer" + ] } }, { @@ -90,6 +132,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment ended" } }, { @@ -111,6 +157,10 @@ "why" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the enrolment ended" } }, { @@ -132,6 +182,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "every open enrolment, or one" } }, { @@ -148,24 +202,139 @@ "node" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "the ask raised again" } } ], "events": [ { - "name": "enrolment-issued" + "name": "enrolment-issued", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "node-enrolled" + "name": "node-enrolled", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "enrolment-refused" + "name": "enrolment-refused", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "enrolment-revoked" + "name": "enrolment-revoked", + "body": { + "$ref": "#/defs/Enrolment" + } }, { - "name": "enrolment-expired" + "name": "enrolment-expired", + "body": { + "$ref": "#/defs/Enrolment" + } } - ] + ], + "family": { + "name": "mesh-node-enroller.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "enrolments", + "template": "state", + "key-forms": [ + { + "key": "node.", + "value": { + "$ref": "#/defs/Enrolment" + }, + "description": "a node's enrolment, living until the token expires while live" + }, + { + "key": "address.
", + "value": { + "type": "object", + "description": "the private-network address reserved at issue", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "issued": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "node" + ] + }, + "description": "an address reserved for a node" + } + ], + "history": 5, + "description": "open enrolments and the addresses reserved for them" + } + ], + "defs": { + "Enrolment": { + "type": "object", + "description": "one open enrolment", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "address": { + "type": "string" + }, + "why": { + "type": "string" + }, + "caller": { + "type": "string" + }, + "ask": { + "type": "string" + }, + "stage": { + "type": "string", + "description": "asked, live or redeeming" + }, + "issued": { + "type": "string", + "format": "date-time" + }, + "expires": { + "type": "string", + "format": "date-time" + }, + "keys-digest": { + "type": "string" + }, + "overlay-key": { + "type": "string" + } + }, + "required": [ + "node", + "address", + "stage" + ] + } + } } diff --git a/go/seatlist/seatlisttest/seats/mesh-placement.json b/go/seatlist/seatlisttest/seats/mesh-placement.json deleted file mode 100644 index c38d9f4..0000000 --- a/go/seatlist/seatlisttest/seats/mesh-placement.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "format": 1, - "seat": "mesh-placement", - "scope": "mesh", - "grain": "seat-list", - "domain": "Placement", - "decision": "novox/hq ADR 0567", - "verbs": [ - { - "name": "placement", - "description": "Read placement: one node's key, the nodes one module is placed on, or every key, each with its revision, and how full the state is against its cap. With history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the proposal's changes from its placement-changed event.", - "optional": true, - "input": { - "properties": { - "history": { - "description": "\"true\": the changes in order, from the stream and the state's revisions", - "enum": [ - "true", - "false" - ], - "type": "string" - }, - "module": { - "description": "one module: the nodes it is placed on", - "type": "string" - }, - "node": { - "description": "one node", - "type": "string" - }, - "seats": { - "description": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's", - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "proposals", - "description": "Every open proposal of placement: its changes, the revision of each key it was laid over, its candidates, every node's answer, the differences computed and answered, its verdict and its ask. Narrowed to one by id.", - "optional": true, - "input": { - "properties": { - "id": { - "description": "one proposal's id", - "type": "string" - } - }, - "type": "object" - } - }, - { - "name": "propose", - "description": "Propose a change of placement, with why: laid over the current keys and refused at once, naming every finding, when the result cannot be placed (two modules on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).", - "optional": true, - "input": { - "additionalProperties": false, - "properties": { - "changes": { - "description": "the changes, each an object with `op`: place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide (node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, on the same warrant)", - "items": { - "required": [ - "op", - "node" - ], - "type": "object" - }, - "minItems": 1, - "type": "array" - }, - "warrant": { - "description": "for an add-node alone: the enrolment's warrant {node, address, ask, warrant}, naming the change's node and address", - "required": [ - "node", - "address", - "ask", - "warrant" - ], - "type": "object" - }, - "why": { - "description": "why, shown to the operator with the proposal", - "type": "string" - } - }, - "required": [ - "why", - "changes" - ], - "type": "object" - } - }, - { - "name": "withdraw", - "description": "Withdraw an open proposal: its key and its candidates are deleted and placement-withdrawn carries it whole. With why.", - "optional": true, - "input": { - "properties": { - "id": { - "description": "the proposal's id", - "type": "string" - }, - "why": { - "description": "why, kept in the event", - "type": "string" - } - }, - "required": [ - "id", - "why" - ], - "type": "object" - } - }, - { - "name": "again", - "description": "Judge an open proposal now with the answers that stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. Nothing more: the recovery of a proposal whose judgement or ask was lost.", - "optional": true, - "input": { - "properties": { - "id": { - "description": "the proposal's id", - "type": "string" - } - }, - "required": [ - "id" - ], - "type": "object" - } - } - ] -} diff --git a/go/seatlist/seatlisttest/seats/mesh-provisioner.json b/go/seatlist/seatlisttest/seats/mesh-provisioner.json index 719758d..ad9b169 100644 --- a/go/seatlist/seatlisttest/seats/mesh-provisioner.json +++ b/go/seatlist/seatlisttest/seats/mesh-provisioner.json @@ -2,9 +2,10 @@ "format": 1, "seat": "mesh-provisioner", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Provisioning", - "decision": "novox/hq ADR 0349", + "decision": "novox/hq ADR 0349, ADR 0634, ADR 0635", + "description": "The provisioner (ADR 0349): which domain answers which kind of placeholder, what each module needs, what is applied where.", "verbs": [ { "name": "provisioning", @@ -30,7 +31,389 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the registry, the needs, what is applied, or the collections' items" } } + ], + "states": [ + { + "name": "plans", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what a module needs on a node", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "plan": { + "type": "object", + "description": "the module's plan" + } + }, + "required": [ + "node", + "module", + "plan" + ] + }, + "description": "one module's plan on one node" + } + ], + "description": "the needs recorded for each module on each node" + }, + { + "name": "provisions", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one provision between a consumer and a provider", + "properties": { + "v": { + "type": "integer" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "consumer": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "provider": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "depends-on": { + "type": "array", + "items": { + "type": "string" + } + }, + "state": { + "type": "string", + "enum": [ + "asked", + "provided", + "waits", + "failed", + "retiring" + ] + }, + "why": { + "type": "string" + }, + "reference": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "generation": { + "type": "integer" + } + } + }, + "ask": { + "type": "string" + }, + "warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + } + }, + "plan": { + "type": "object", + "description": "the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126)", + "properties": { + "name": { + "type": "string" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "provider-module": { + "type": "string" + }, + "credential-form": { + "enum": [ + "pair", + "shared", + "issued", + "given", + "none" + ] + }, + "credential-tool": { + "type": "string" + }, + "path": { + "type": "string" + }, + "contributes": { + "type": "object" + }, + "at": { + "type": "string" + }, + "from": { + "type": "string" + }, + "as": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "contributed": { + "type": "object", + "description": "the provider's own contract's bytes, carried unread" + } + }, + "required": [ + "provision", + "provider", + "provider-module" + ], + "additionalProperties": false + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "provision", + "consumer", + "provider", + "state", + "at" + ] + }, + "description": "a provision with no local name" + }, + { + "key": "...", + "value": { + "type": "object", + "description": "one provision between a consumer and a provider", + "properties": { + "v": { + "type": "integer" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "consumer": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "provider": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + "depends-on": { + "type": "array", + "items": { + "type": "string" + } + }, + "state": { + "type": "string", + "enum": [ + "asked", + "provided", + "waits", + "failed", + "retiring" + ] + }, + "why": { + "type": "string" + }, + "reference": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "generation": { + "type": "integer" + } + } + }, + "ask": { + "type": "string" + }, + "warrant": { + "type": "object", + "properties": { + "ask": { + "type": "string" + }, + "option": { + "type": "string" + }, + "by": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + } + } + }, + "plan": { + "type": "object", + "description": "the plan's provision: the entry the key was composed from, at the consumer's wanted build or its newest (issue 1126)", + "properties": { + "name": { + "type": "string" + }, + "provision": { + "type": "string" + }, + "local": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "provider-module": { + "type": "string" + }, + "credential-form": { + "enum": [ + "pair", + "shared", + "issued", + "given", + "none" + ] + }, + "credential-tool": { + "type": "string" + }, + "path": { + "type": "string" + }, + "contributes": { + "type": "object" + }, + "at": { + "type": "string" + }, + "from": { + "type": "string" + }, + "as": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "contributed": { + "type": "object", + "description": "the provider's own contract's bytes, carried unread" + } + }, + "required": [ + "provision", + "provider", + "provider-module" + ], + "additionalProperties": false + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "provision", + "consumer", + "provider", + "state", + "at" + ] + }, + "description": "a provision of a local name" + } + ], + "description": "every provision between a consumer and a provider, and how it stands" + } ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-self-check.json b/go/seatlist/seatlisttest/seats/mesh-self-check.json index 1029b9c..40025a8 100644 --- a/go/seatlist/seatlisttest/seats/mesh-self-check.json +++ b/go/seatlist/seatlisttest/seats/mesh-self-check.json @@ -2,9 +2,10 @@ "format": 1, "seat": "mesh-self-check", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Health and repair", - "decision": "novox/hq ADR 0564", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The mesh checking itself (ADR 0564): probes run every five minutes, each failure a finding the seat's conditions raise.", "verbs": [ { "name": "doctor", @@ -25,6 +26,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the last run's verdict, or one probe's, or a run now" } }, { @@ -33,12 +38,128 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "the registry of probes" } } ], "events": [ { - "name": "checked" + "name": "checked", + "body": { + "type": "object", + "description": "one run of the self-check", + "properties": { + "run": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "started": { + "type": "string", + "format": "date-time" + }, + "took": { + "type": "string" + }, + "interval-seconds": { + "type": "integer" + }, + "counts": { + "type": "object", + "properties": { + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "failed-to-run": { + "type": "integer" + }, + "deferred": { + "type": "integer" + } + } + }, + "probes": { + "type": "array", + "items": { + "$ref": "#/defs/ProbeVerdict" + } + }, + "controller": { + "type": "string" + }, + "why": { + "type": "string" + }, + "unsaid": { + "type": "integer" + } + }, + "required": [ + "run", + "at", + "probes" + ] + } } - ] + ], + "family": { + "name": "mesh-self-check.events", + "max-age-days": 7 + }, + "defs": { + "ProbeVerdict": { + "type": "object", + "description": "one probe's verdict", + "properties": { + "id": { + "type": "string" + }, + "verdict": { + "type": "string", + "enum": [ + "passed", + "failed", + "failed-to-run", + "deferred" + ] + }, + "found": { + "type": "array", + "items": { + "type": "string" + } + }, + "unconfirmed": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string" + }, + "took": { + "type": "string" + }, + "found-count": { + "type": "integer" + }, + "more": { + "type": "string" + } + }, + "required": [ + "id", + "verdict" + ] + } + } } diff --git a/go/seatlist/seatlisttest/seats/mesh-vault.json b/go/seatlist/seatlisttest/seats/mesh-vault.json index e83f02a..7c93f1b 100644 --- a/go/seatlist/seatlisttest/seats/mesh-vault.json +++ b/go/seatlist/seatlisttest/seats/mesh-vault.json @@ -4,7 +4,7 @@ "scope": "mesh", "grain": "whole", "domain": "Identity and access", - "decision": "novox/hq ADR 0161", + "decision": "novox/hq ADR 0161, ADR 0634, ADR 0635", "delivers": "secret", "verbs": [ { @@ -300,7 +300,7 @@ }, { "name": "sign", - "description": "Sign with the vault's key or authority (novox/hq ADR 0550 §2): kind node-certificate signs the certificate of a machine's internal name, asked by that machine's node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", + "description": "Sign with the vault's key or authority (novox/hq ADR 0550 §2): kind node-certificate signs the certificate of a machine's internal name, asked by that machine's node-engine for the serving key its record states, built from bus state alone, or by mesh-node-enroller for a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a redeem answer under its own context, for mesh-node-enroller alone. Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.", "optional": true, "input": { "properties": { @@ -309,7 +309,7 @@ "type": "string" }, "node": { - "description": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols", + "description": "the machine, for a node certificate: the caller's own, or the node mesh-node-enroller enrols", "type": "string" }, "reply": { @@ -317,7 +317,7 @@ "type": "string" }, "serving_key": { - "description": "from mesh-enrolment: the serving key's public half the enrolling node presented", + "description": "from mesh-node-enroller: the serving key's public half the enrolling node presented", "type": "string" } }, @@ -368,7 +368,7 @@ }, { "name": "enrolment-token", - "description": "Make a one-time token for a node mesh-enrolment issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash written as the bus user enrol.. Answers only what the joining machine must trust (the bus's address, the bus certificate's fingerprint, the vault's public signing key), never the token; with sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).", + "description": "Make a one-time token for a node mesh-node-enroller issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash written as the bus user enrol.. Answers only what the joining machine must trust (the bus's address, the bus certificate's fingerprint, the vault's public signing key), never the token; with sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash dropped, at the enrolment's end. Asked by mesh-node-enroller alone, only from the lease write. The token carries the first tunnel mesh-node-enroller built at issue (ADR 0587 §3, insight of 2026-11-02).", "optional": true, "input": { "properties": { @@ -398,7 +398,7 @@ }, "tunnel": { "additionalProperties": false, - "description": "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", + "description": "the node's first tunnel, built by mesh-node-enroller at issue and carried in the token: the hub's public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range", "properties": { "address": { "description": "the joining node's tunnel address", @@ -472,7 +472,7 @@ }, { "name": "node-account", - "description": "Mint a joining node's bus account node., sealed to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the node's node-logins key naming node.. Asked by mesh-enrolment alone. Never the value in the clear.", + "description": "Mint a joining node's bus account node., sealed to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the node's node-logins key naming node.. Asked by mesh-node-enroller alone. Never the value in the clear.", "optional": true, "input": { "properties": { @@ -758,5 +758,128 @@ ] } } + ], + "states": [ + { + "name": "signers", + "template": "state", + "key-forms": [ + { + "key": "key", + "value": { + "type": "object", + "description": "the vault's signing key's public half", + "properties": { + "v": { + "type": "integer" + }, + "key": { + "type": "string", + "description": "the signing key's public half, standard base64" + }, + "fingerprint": { + "type": "string" + }, + "made": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "key", + "fingerprint", + "made" + ] + }, + "description": "the signing key's public half" + }, + { + "key": "authority", + "value": { + "type": "object", + "description": "the vault's authority certificate", + "properties": { + "v": { + "type": "integer" + }, + "certificate": { + "type": "string", + "description": "the authority certificate, PEM" + }, + "fingerprint": { + "type": "string" + }, + "made": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "certificate", + "fingerprint", + "made" + ] + }, + "description": "the authority certificate" + } + ], + "history": 5, + "description": "the vault's public signing halves; written by the vault alone" + }, + { + "name": "node-logins", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "a node's two logins and the rotation's stage", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "login": { + "type": "string", + "description": "the login in use" + }, + "rotating": { + "type": "string", + "description": "the login rotating to, empty when none" + }, + "stage": { + "type": "string", + "description": "begun, ready or confirmed; empty when no rotation" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "list-revision": { + "type": "integer", + "description": "the user list's revision that held both logins, from ready on" + }, + "acts": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "node", + "login", + "since" + ] + }, + "description": "a node" + } + ], + "history": 5, + "description": "which login a node uses and the stage of its rotation; the key lives while the node does" + } ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-wants.json b/go/seatlist/seatlisttest/seats/mesh-wants.json index 3de06f7..236ed60 100644 --- a/go/seatlist/seatlisttest/seats/mesh-wants.json +++ b/go/seatlist/seatlisttest/seats/mesh-wants.json @@ -2,9 +2,10 @@ "format": 1, "seat": "mesh-wants", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Placement", - "decision": "novox/hq ADR 0565", + "decision": "novox/hq ADR 0565, ADR 0634, ADR 0635", + "description": "The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539).", "verbs": [ { "name": "upgrade", @@ -33,6 +34,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "each module's policy, or the one chosen, or the comparison" } }, { @@ -41,12 +46,235 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what a bus upgrade would do, read only" } } ], "events": [ { - "name": "policy-changed" + "name": "policy-changed", + "body": { + "type": "object" + } + } + ], + "family": { + "name": "mesh-wants.events", + "max-age-days": 7 + }, + "states": [ + { + "name": "wants", + "template": "state", + "key-forms": [ + { + "key": "want..", + "value": { + "type": "object", + "description": "a node's want for a module", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "removed": { + "type": "boolean" + }, + "newest": { + "type": "string" + }, + "rule": { + "type": "integer", + "description": "the rule that holds the node back, 1 to 10" + }, + "backup": { + "type": "string", + "description": "again or skip" + }, + "words": { + "type": "string" + }, + "put": { + "type": "boolean" + }, + "hold": { + "type": "boolean" + }, + "yes": { + "type": "string" + }, + "release": { + "type": "string" + }, + "self": { + "type": "boolean" + } + } + }, + "description": "a node's want for a module" + }, + { + "key": "want..node", + "value": { + "type": "object", + "description": "a node's own want", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string" + }, + "words": { + "type": "string" + }, + "self": { + "type": "boolean" + } + }, + "required": [ + "composed" + ] + }, + "description": "a node's own want, naming the digest of its own composition" + }, + { + "key": "held..", + "value": { + "type": "object", + "description": "a fingerprint the assigner holds", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "words": { + "type": "string" + } + }, + "required": [ + "node" + ] + }, + "description": "a module's fingerprint the assigner holds" + } + ], + "history": 20, + "description": "what each node should run, and the fingerprints held back" + }, + { + "name": "differences", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the difference between a want and what is sent", + "properties": { + "v": { + "type": "integer" + }, + "want": { + "type": "string" + }, + "want-removed": { + "type": "boolean" + }, + "sent": { + "type": "string" + }, + "sent-build": { + "type": "string" + }, + "rule": { + "type": "integer" + }, + "unexplained": { + "type": "boolean" + }, + "words": { + "type": "string" + } + } + }, + "description": "a module on a node" + } + ], + "history": 64, + "description": "differences between a want and what the node-engine is sent" + }, + { + "name": "compositions", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the composition verdict of a group's check at its heads", + "properties": { + "v": { + "type": "integer" + }, + "group": { + "type": "string" + }, + "heads": { + "type": "string", + "description": "12 lowercase hex digits" + }, + "state": { + "type": "string", + "description": "pass, fail or waits" + }, + "words": { + "type": "string" + }, + "sdk": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "type": "string" + } + }, + "machine": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "took": { + "type": "integer", + "description": "milliseconds" + } + }, + "required": [ + "group", + "heads", + "state" + ] + }, + "description": "a group's check at its heads" + } + ], + "history": 5, + "description": "composition verdicts of a group's check; the checker reads them" } ] } diff --git a/go/seatlist/seatlisttest/seats/mesh-watchdogs.json b/go/seatlist/seatlisttest/seats/mesh-watchdogs.json index 5d5a076..35a4341 100644 --- a/go/seatlist/seatlisttest/seats/mesh-watchdogs.json +++ b/go/seatlist/seatlisttest/seats/mesh-watchdogs.json @@ -2,9 +2,10 @@ "format": 1, "seat": "mesh-watchdogs", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Health and repair", - "decision": "novox/hq ADR 0564", + "decision": "novox/hq ADR 0564, ADR 0634, ADR 0635", + "description": "The watchman's seat (ADR 0564, ADR 0433): signals that expire unless renewed, what stopped moving, who is present, the dead letters.", "verbs": [ { "name": "signals", @@ -17,6 +18,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the watchdog table" } }, { @@ -30,6 +35,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "what stopped moving" } }, { @@ -43,6 +52,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "who is connected and who is silent" } }, { @@ -55,7 +68,7 @@ "type": "string" }, "consumer": { - "description": "a consumer's name, or \u003cstream\u003e.\u003cconsumer\u003e: only what that one gave up on", + "description": "a consumer's name, or .: only what that one gave up on", "type": "string" }, "deliver": { @@ -80,6 +93,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "the dead letters, one whole, or the act done" } }, { @@ -88,7 +105,139 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what was rebuilt" } } + ], + "states": [ + { + "name": "presence", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "whether a node is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a node" + }, + { + "key": "part..", + "value": { + "type": "object", + "description": "whether a part's instance is online", + "properties": { + "v": { + "type": "integer" + }, + "online": { + "type": "boolean" + } + }, + "required": [ + "online" + ] + }, + "description": "a part's instance" + } + ], + "history": 20, + "description": "who is connected; the entry's own time says since when" + }, + { + "name": "pulse", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "a watched key mirrored, its revision, and who owes the next move", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "owes": { + "type": "string" + } + }, + "required": [ + "bucket", + "key", + "revision" + ] + }, + "description": "the move watched at a key of a state" + } + ], + "description": "one key per watched move, living for the move's bound minus its age" + }, + { + "name": "stuck", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "what stopped moving", + "properties": { + "v": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "key": { + "type": "string" + }, + "waited": { + "type": "string", + "description": "what it waited for, in words" + }, + "since": { + "type": "string", + "format": "date-time" + }, + "moved": { + "type": "boolean", + "description": "it has moved again" + } + }, + "required": [ + "bucket", + "key", + "since" + ] + }, + "description": "a key whose pulse expired" + } + ], + "description": "what stopped moving; no key is removed, one that moves again is written as moved" + } ] } diff --git a/go/seatlist/seatlisttest/seats/node-build-go.json b/go/seatlist/seatlisttest/seats/node-build-go.json index ccb5664..a810a6b 100644 --- a/go/seatlist/seatlisttest/seats/node-build-go.json +++ b/go/seatlist/seatlisttest/seats/node-build-go.json @@ -2,46 +2,87 @@ "format": 1, "seat": "node-build-go", "scope": "node", - "grain": "seat-list", + "grain": "whole", "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, - "verbs": [ + "accepts": [ { - "name": "current", - "description": "The Go asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", - "input": { - "properties": {}, - "type": "object" + "name": "delivery", + "body": { + "$ref": "#/defs/StepAsk" } }, { - "name": "pause", - "description": "Take no new Go ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", - "input": { - "properties": {}, - "type": "object" + "name": "check", + "body": { + "$ref": "#/defs/StepAsk" } }, { - "name": "resume", - "description": "Take Go asks again on this node.", - "input": { - "properties": {}, - "type": "object" + "name": "recheck", + "body": { + "$ref": "#/defs/StepAsk" } } ], - "accepts": [ + "description": "The Go build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.", + "includes": [ + "build-step" + ], + "states": [ { - "name": "delivery" + "name": "steps", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Step" + }, + "description": "a step on a node" + } + ], + "description": "the steps this module runs", + "history": 5 }, { - "name": "check" + "name": "pauses", + "template": "state", + "key-forms": [ + { + "key": "paused.", + "value": { + "$ref": "#/defs/Pause" + }, + "description": "whether a node is paused" + }, + { + "key": "capacity.", + "value": { + "$ref": "#/defs/Capacity" + }, + "description": "a node's capacity" + } + ], + "description": "the pause and the capacity of each node", + "history": 1 }, { - "name": "recheck" + "name": "toolchains", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Toolchain" + }, + "description": "the toolchain a node reports" + } + ], + "description": "the toolchains the nodes report", + "history": 1 } ] } diff --git a/go/seatlist/seatlisttest/seats/node-build-image.json b/go/seatlist/seatlisttest/seats/node-build-image.json index b275f85..037a890 100644 --- a/go/seatlist/seatlisttest/seats/node-build-image.json +++ b/go/seatlist/seatlisttest/seats/node-build-image.json @@ -2,46 +2,87 @@ "format": 1, "seat": "node-build-image", "scope": "node", - "grain": "seat-list", + "grain": "whole", "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, - "verbs": [ + "accepts": [ { - "name": "current", - "description": "The image asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", - "input": { - "properties": {}, - "type": "object" + "name": "delivery", + "body": { + "$ref": "#/defs/StepAsk" } }, { - "name": "pause", - "description": "Take no new image ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", - "input": { - "properties": {}, - "type": "object" + "name": "check", + "body": { + "$ref": "#/defs/StepAsk" } }, { - "name": "resume", - "description": "Take image asks again on this node.", - "input": { - "properties": {}, - "type": "object" + "name": "recheck", + "body": { + "$ref": "#/defs/StepAsk" } } ], - "accepts": [ + "description": "The image build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.", + "includes": [ + "build-step" + ], + "states": [ { - "name": "delivery" + "name": "steps", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Step" + }, + "description": "a step on a node" + } + ], + "description": "the steps this module runs", + "history": 5 }, { - "name": "check" + "name": "pauses", + "template": "state", + "key-forms": [ + { + "key": "paused.", + "value": { + "$ref": "#/defs/Pause" + }, + "description": "whether a node is paused" + }, + { + "key": "capacity.", + "value": { + "$ref": "#/defs/Capacity" + }, + "description": "a node's capacity" + } + ], + "description": "the pause and the capacity of each node", + "history": 1 }, { - "name": "recheck" + "name": "toolchains", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Toolchain" + }, + "description": "the toolchain a node reports" + } + ], + "description": "the toolchains the nodes report", + "history": 1 } ] } diff --git a/go/seatlist/seatlisttest/seats/node-build-node.json b/go/seatlist/seatlisttest/seats/node-build-node.json index aa7f182..6fce146 100644 --- a/go/seatlist/seatlisttest/seats/node-build-node.json +++ b/go/seatlist/seatlisttest/seats/node-build-node.json @@ -2,46 +2,87 @@ "format": 1, "seat": "node-build-node", "scope": "node", - "grain": "seat-list", + "grain": "whole", "domain": "Delivery", "decision": "novox/hq ADR 0525, ADR 0536, ADR 0553", "pulled-by-kind": true, "yields": true, - "verbs": [ + "accepts": [ { - "name": "current", - "description": "The Node asks this node runs now (each ask's id, kind, module and when it started), its build capacity, and whether it is paused, taking no new ask.", - "input": { - "properties": {}, - "type": "object" + "name": "delivery", + "body": { + "$ref": "#/defs/StepAsk" } }, { - "name": "pause", - "description": "Take no new Node ask on this node until resumed; an ask running here ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the lasting way to keep a node from building.", - "input": { - "properties": {}, - "type": "object" + "name": "check", + "body": { + "$ref": "#/defs/StepAsk" } }, { - "name": "resume", - "description": "Take Node asks again on this node.", - "input": { - "properties": {}, - "type": "object" + "name": "recheck", + "body": { + "$ref": "#/defs/StepAsk" } } ], - "accepts": [ + "description": "The Node build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.", + "includes": [ + "build-step" + ], + "states": [ { - "name": "delivery" + "name": "steps", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Step" + }, + "description": "a step on a node" + } + ], + "description": "the steps this module runs", + "history": 5 }, { - "name": "check" + "name": "pauses", + "template": "state", + "key-forms": [ + { + "key": "paused.", + "value": { + "$ref": "#/defs/Pause" + }, + "description": "whether a node is paused" + }, + { + "key": "capacity.", + "value": { + "$ref": "#/defs/Capacity" + }, + "description": "a node's capacity" + } + ], + "description": "the pause and the capacity of each node", + "history": 1 }, { - "name": "recheck" + "name": "toolchains", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "$ref": "#/defs/Toolchain" + }, + "description": "the toolchain a node reports" + } + ], + "description": "the toolchains the nodes report", + "history": 1 } ] } diff --git a/go/seatlist/seatlisttest/seats/node-engine.json b/go/seatlist/seatlisttest/seats/node-engine.json index d09c320..237a651 100644 --- a/go/seatlist/seatlisttest/seats/node-engine.json +++ b/go/seatlist/seatlisttest/seats/node-engine.json @@ -674,6 +674,30 @@ ], "description": "what this machine's engine reads of a manifest (ADR 0618 §3)" } + }, + { + "name": "seat-list", + "description": "The seat list this engine holds and what it waits for (novox/hq ADR 0636 §7): each seat's source (trunk or bootstrap) and digest, the seats held from the bootstrap alone, the definitions refused, the last error and whether the seat mesh-catalog has answered. Read-only.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } + }, + { + "name": "seat-list-again", + "description": "Make this engine read the seat list from the seat mesh-catalog now (novox/hq ADR 0636 §7), the recovery of a list unread; it answers what it holds afterwards, and the error when the read failed. A read that changed nothing still ends the engine's wait.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } } ], "events": [ @@ -710,6 +734,550 @@ "name": "node-engine.events", "max-age-days": 7 }, + "states": [ + { + "name": "have", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "one module's state on a node: what the engine has built, composed and run", + "properties": { + "v": { + "type": "integer" + }, + "fp": { + "type": "string", + "description": "the fingerprint held" + }, + "composed": { + "type": "string", + "description": "the digest of the composition the node runs" + }, + "build": { + "type": "string" + }, + "phase": { + "type": "string", + "description": "how far it has come", + "enum": [ + "backing-up", + "applying", + "checking", + "running", + "failed", + "removed", + "held", + "waiting-yes" + ] + }, + "health": { + "type": "string", + "description": "what the module's own check says", + "enum": [ + "ok", + "failed", + "waits", + "unknown" + ] + }, + "detail": { + "type": "string" + }, + "user-list": { + "$ref": "#/defs/UserListWritten" + }, + "judgement": { + "type": "string", + "description": "in phase waiting-yes, the judgement of the move", + "enum": [ + "recreates", + "moves-data", + "cannot-judge" + ] + }, + "waits": { + "type": "string", + "description": "in phase waiting-yes, the digest of the composition that waits" + }, + "build-alone": { + "type": "boolean", + "description": "the move changes the build alone" + }, + "kept": { + "type": "array", + "items": { + "type": "string" + }, + "description": "what the node keeps of the module as it runs" + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "each value sealed to the node's key" + }, + "sealed-at": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "the sealed values by slot" + }, + "pull-wait": { + "$ref": "#/defs/PullWait" + }, + "handed": { + "$ref": "#/defs/AccountsHanded" + } + }, + "required": [ + "v", + "phase", + "health" + ] + }, + "description": "one module on a node" + }, + { + "key": ".node", + "value": { + "type": "object", + "description": "a node's own record", + "properties": { + "v": { + "type": "integer" + }, + "composed": { + "type": "string", + "description": "the digest of the node's own composition it runs" + }, + "windows": { + "type": "object" + }, + "busy": { + "type": "object" + }, + "strays": { + "type": "object" + }, + "filters": { + "type": "object" + }, + "profile": { + "type": "object" + }, + "record": { + "$ref": "#/defs/NodeRecord" + }, + "sealed": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealed-at": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "candidates": { + "type": "object", + "additionalProperties": { + "$ref": "#/defs/CandidateAnswer" + }, + "description": "the node's answer to each placement candidate naming it, by proposal id" + }, + "composes": { + "type": "string", + "description": "own, or none (empty)" + }, + "composes-why": { + "type": "string" + } + }, + "required": [ + "v" + ] + }, + "description": "the node's own record" + } + ], + "history": 20, + "description": "what each node has of each module, written by its engine" + }, + { + "name": "composed", + "template": "state", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one composition of a module on a node under a fingerprint", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "fp": { + "type": "string" + }, + "data": { + "type": "boolean", + "description": "the module declares data" + }, + "resources": { + "type": "array", + "items": { + "type": "object" + }, + "description": "the declaration resources, each saying its module" + }, + "filled_files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the files the provisioner fills" + }, + "plan": { + "type": "object", + "description": "the module's whole provisioning plan, catalogue.Plan, kept whole: it is the manifest layer's type" + }, + "refused": { + "type": "string", + "description": "why the module cannot be composed at this fingerprint" + } + }, + "required": [ + "v", + "node", + "module", + "fp", + "resources" + ] + }, + "description": "one composition" + }, + { + "key": ".node", + "value": { + "type": "object", + "description": "the node's own composition: its resources and parts", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "data": { + "type": "boolean", + "description": "the module declares data" + }, + "resources": { + "type": "array", + "items": { + "type": "object" + }, + "description": "the declaration resources, each saying its module" + }, + "filled_files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the files the provisioner fills" + }, + "own": { + "$ref": "#/defs/NodeOwn" + } + }, + "required": [ + "v", + "node", + "resources" + ] + }, + "description": "the node's own composition" + } + ], + "description": "the compositions of one node, kept in a bucket per node named composed-" + }, + { + "name": "data", + "template": "state", + "key-forms": [ + { + "key": ".", + "value": { + "type": "object", + "description": "the data items a module has on a node", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/defs/PlacedDataItem" + } + } + }, + "required": [ + "v", + "node", + "module", + "digest", + "items" + ] + }, + "description": "a module's data on a node" + } + ], + "description": "the data items of each module on each node" + }, + { + "name": "packages", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the packages a node declares", + "properties": { + "node": { + "type": "string" + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "absent": { + "type": "boolean" + } + }, + "required": [ + "package", + "modules", + "absent" + ] + } + } + }, + "required": [ + "node", + "packages" + ] + }, + "description": "a node's declared packages" + } + ], + "description": "the packages each node declares" + }, + { + "name": "health", + "template": "state", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "a node's health as its engine reports it", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "network": { + "$ref": "#/defs/NetworkHealth" + }, + "peers": { + "type": "array", + "items": { + "$ref": "#/defs/PeerSeen" + } + }, + "units": { + "$ref": "#/defs/UnitsHealth" + }, + "relogin": { + "type": "array", + "items": { + "$ref": "#/defs/NodeAccount" + } + }, + "found": { + "type": "array", + "items": { + "$ref": "#/defs/NodeDirectory" + } + }, + "fallback": { + "$ref": "#/defs/LoginFallback" + }, + "root": { + "type": "array", + "items": { + "$ref": "#/defs/RootAccountVerdict" + } + }, + "contract": { + "type": "integer" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "v", + "node", + "at" + ] + }, + "description": "a node's health" + } + ], + "description": "each node's health as its engine reports it" + }, + { + "name": "findings", + "template": "state", + "writer": "holder", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/FindingSubject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/FindingAction" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/FindingEvidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "the findings this node's engine raises about itself, one key per finding, in the layout of the seat mesh-conditions (its findings state): a key written raises the condition of the same key, a key deleted clears it (novox/hq ADR 0564 §2, ADR 0636 §1); today `machine..seat-list-unread`" + } + ], "defs": { "Act": { "type": "object", @@ -1051,6 +1619,1033 @@ "said" ], "description": "the answer of a read-only judgement" + }, + "SeatListHeld": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "trunk", + "bootstrap" + ] + }, + "digest": { + "type": "string", + "description": "the digest of the definition held; empty for a bootstrapped one" + } + }, + "required": [ + "seat", + "source" + ] + }, + "SeatList": { + "type": "object", + "description": "what an engine holds of the seat list (novox/hq ADR 0636 §7)", + "properties": { + "status": { + "type": "object", + "properties": { + "heard": { + "type": "boolean", + "description": "the seat mesh-catalog has answered" + }, + "held": { + "type": "array", + "items": { + "$ref": "#/defs/SeatListHeld" + } + }, + "waiting": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the seats held from the bootstrap alone" + }, + "refused": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "a trunk definition that does not parse, by seat: the one held stands" + }, + "last-error": { + "type": "string" + }, + "revision": { + "type": "integer" + } + }, + "required": [ + "heard", + "held" + ] + }, + "health": { + "type": "string", + "description": "the one line the engine says of it; empty when nothing waits" + }, + "error": { + "type": "string", + "description": "why the read failed, for seat-list-again" + } + }, + "required": [ + "status", + "health" + ] + }, + "FindingSubject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "FindingAction": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "FindingEvidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] + }, + "UserListWritten": { + "type": "object", + "description": "the bus's user list as the engine of the bus node wrote it to the bus module's file", + "properties": { + "source": { + "type": "string", + "description": "the key the list was taken from" + }, + "revision": { + "type": "integer" + }, + "wanted": { + "type": "string", + "description": "the digest of the list the source holds, sha256:" + }, + "digest": { + "type": "string", + "description": "the digest of the file on the machine after the write" + }, + "users": { + "type": "integer" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + }, + "error": { + "type": "string", + "description": "why a write failed" + }, + "follows": { + "type": "string", + "description": "which list the node follows now" + }, + "key": { + "type": "string", + "description": "the bus node's sealing key, its public half" + }, + "unwatched": { + "type": "string", + "description": "why the bus node does not watch the grants part's key now" + }, + "handed": { + "$ref": "#/defs/AccountsHanded" + } + }, + "required": [ + "source", + "revision", + "wanted", + "users", + "at" + ] + }, + "AccountsHanded": { + "type": "object", + "description": "the hashes of the users of the list, sealed to the vault", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string", + "description": "the fingerprint of the vault's key" + }, + "users": { + "type": "integer" + }, + "box": { + "$ref": "#/defs/SealedBox" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "from", + "to", + "users", + "box", + "at" + ] + }, + "SealedBox": { + "type": "object", + "description": "a value sealed to a key", + "properties": { + "v": { + "type": "integer" + }, + "eph": { + "type": "string" + }, + "iv": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "ct": { + "type": "string" + } + }, + "required": [ + "v", + "eph", + "iv", + "tag", + "ct" + ] + }, + "PullWait": { + "type": "object", + "description": "the pull from a store a failed apply waits on", + "properties": { + "image": { + "type": "string" + }, + "seat": { + "type": "string", + "description": "the seat the pull came through" + }, + "holder": { + "type": "string", + "description": "that seat's holder, whose settling the apply waits for" + }, + "began": { + "type": "string", + "description": "when the pull began", + "format": "date-time" + }, + "retried": { + "type": "string", + "description": "what triggered the last try again, in words" + }, + "retried-at": { + "type": "string", + "description": "when the apply was last tried again", + "format": "date-time" + } + }, + "required": [ + "image", + "holder", + "began" + ] + }, + "NodeRecord": { + "type": "object", + "description": "what the machine observes of itself, for every node and composing part to read", + "properties": { + "v": { + "type": "integer" + }, + "node": { + "type": "string" + }, + "tunnel-key": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "sealing-key": { + "type": "string" + }, + "serving-key": { + "type": "string" + }, + "certificate": { + "type": "string" + }, + "outward-links": { + "type": "array", + "items": { + "type": "string" + } + }, + "networks": { + "type": "array", + "items": { + "$ref": "#/defs/AttachedNetwork" + } + }, + "networks-said": { + "type": "boolean" + }, + "contract": { + "type": "integer" + }, + "authority": { + "type": "string" + }, + "trusts": { + "type": "array", + "items": { + "type": "string" + } + }, + "manifest-reads": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "v", + "node" + ] + }, + "AttachedNetwork": { + "type": "object", + "description": "a network attached to a link", + "properties": { + "link": { + "type": "string" + }, + "network": { + "type": "string" + } + }, + "required": [ + "link", + "network" + ] + }, + "CandidateAnswer": { + "type": "object", + "description": "the node's answer to one placement candidate naming it", + "properties": { + "proposal": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "verdict": { + "type": "string", + "description": "same, differs or refused", + "enum": [ + "same", + "differs", + "refused" + ] + }, + "same": { + "type": "array", + "items": { + "type": "string" + }, + "description": "modules whose composition is the one running" + }, + "differs": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateDiffers" + } + }, + "added": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateAdded" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "modules the candidate removes" + }, + "refusals": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateRefusal" + } + }, + "held": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateRefusal" + } + }, + "refused": { + "type": "string", + "description": "why the node cannot compose with the candidate at all" + }, + "returns": { + "type": "string" + }, + "note": { + "type": "string" + }, + "values_differ": { + "type": "integer" + } + }, + "required": [ + "proposal", + "revision", + "verdict" + ] + }, + "CandidateDiffers": { + "type": "object", + "description": "a module whose composition with the candidate differs from the one running", + "properties": { + "module": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "running": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "why": { + "type": "string" + }, + "bodies": { + "type": "array", + "items": { + "$ref": "#/defs/CandidateBodies" + } + } + }, + "required": [ + "module", + "why" + ] + }, + "CandidateBodies": { + "type": "object", + "description": "the bodies of one resource that differ", + "properties": { + "id": { + "type": "string" + }, + "why": { + "type": "string" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "composed": { + "type": "string" + }, + "running": { + "type": "string" + } + }, + "required": [ + "id", + "why" + ] + }, + "CandidateAdded": { + "type": "object", + "description": "a module the candidate adds", + "properties": { + "module": { + "type": "string" + }, + "composed": { + "type": "string" + }, + "unissued": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "module" + ] + }, + "CandidateRefusal": { + "type": "object", + "description": "a module refused or held with the candidate", + "properties": { + "module": { + "type": "string" + }, + "why": { + "type": "string" + }, + "today": { + "type": "string" + } + }, + "required": [ + "module", + "why" + ] + }, + "NodeOwn": { + "type": "object", + "description": "what a node's own composition carries beside its resources", + "properties": { + "adoption": { + "$ref": "#/defs/Adoption" + }, + "identities": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredIdentity" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredGroup" + } + }, + "libraries": { + "type": "array", + "items": { + "$ref": "#/defs/DeclaredLibrary" + } + }, + "provisioner_account": { + "type": "string", + "description": "the account the provisioner's client runs as on the node" + } + } + }, + "Adoption": { + "type": "object", + "description": "the node is adopted and which of its modules are taken", + "properties": { + "taken": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the modules taken" + }, + "untaken": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "by module, the resources left untaken" + } + }, + "required": [ + "taken" + ] + }, + "DeclaredIdentity": { + "type": "object", + "description": "the identity of a module assigned to the node", + "properties": { + "module": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uid": { + "type": "integer" + }, + "gid": { + "type": "integer" + }, + "groups": { + "type": "array", + "items": { + "type": "integer" + } + }, + "runner": { + "type": "string" + } + }, + "required": [ + "module", + "name", + "uid", + "gid" + ] + }, + "DeclaredGroup": { + "type": "object", + "description": "a group", + "properties": { + "name": { + "type": "string" + }, + "gid": { + "type": "integer" + } + }, + "required": [ + "name", + "gid" + ] + }, + "DeclaredLibrary": { + "type": "object", + "description": "a library the node is reached by", + "properties": { + "library": { + "type": "string" + }, + "root": { + "type": "string" + }, + "group": { + "type": "string" + }, + "gid": { + "type": "integer" + }, + "home": { + "type": "boolean" + }, + "taken": { + "type": "boolean" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "type": "string" + } + }, + "required": [ + "library", + "root", + "group", + "gid", + "home" + ] + }, + "PlacedDataItem": { + "type": "object", + "description": "one data item a module declares, as placed on a node", + "properties": { + "module": { + "type": "string" + }, + "item": { + "type": "string" + }, + "class": { + "type": "string" + }, + "path": { + "type": "string", + "description": "the item's place on the node" + }, + "covered_by": { + "type": "string", + "description": "the path whose snapshot keeps it, empty when it is not backed up" + }, + "protection": { + "type": "string", + "description": "backup, redundancy, both or none" + }, + "measure": { + "type": "string", + "description": "walk, dataset or shallow" + } + }, + "required": [ + "module", + "item", + "class", + "path" + ] + }, + "NetworkHealth": { + "type": "object", + "description": "the node's network", + "properties": { + "state": { + "type": "string", + "description": "running, degraded or unknown" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/defs/NetworkPart" + } + } + }, + "required": [ + "state", + "since", + "parts" + ] + }, + "NetworkPart": { + "type": "object", + "description": "one part of the node's network", + "properties": { + "part": { + "type": "string" + }, + "state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "said": { + "type": "string" + }, + "writer": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "toward": { + "type": "array", + "items": { + "type": "string" + } + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + }, + "streak": { + "type": "integer" + } + }, + "required": [ + "part", + "state", + "since" + ] + }, + "UnitsHealth": { + "type": "object", + "description": "the node's units", + "properties": { + "state": { + "type": "string" + }, + "failed": { + "type": "array", + "items": { + "$ref": "#/defs/FailedUnit" + } + }, + "unread": { + "type": "array", + "items": { + "type": "string" + } + }, + "away": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "state", + "failed" + ] + }, + "FailedUnit": { + "type": "object", + "description": "a unit that failed", + "properties": { + "unit": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "load": { + "type": "string" + }, + "result": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "unit", + "scope", + "since" + ] + }, + "PeerSeen": { + "type": "object", + "description": "a tunnel peer and whether it is seen", + "properties": { + "key": { + "type": "string" + }, + "addresses": { + "type": "array", + "items": { + "type": "string" + } + }, + "seen": { + "type": "boolean" + } + }, + "required": [ + "key", + "seen" + ] + }, + "NodeAccount": { + "type": "object", + "description": "an account the node needs to log in again", + "properties": { + "account": { + "type": "string" + }, + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "account", + "reason", + "since" + ] + }, + "NodeDirectory": { + "type": "object", + "description": "a directory found where the node expected none", + "properties": { + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "path": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "module", + "resource", + "path", + "reason", + "since" + ] + }, + "LoginFallback": { + "type": "object", + "description": "a login that fell back to another node", + "properties": { + "node": { + "type": "string" + }, + "from": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when", + "format": "date-time" + } + }, + "required": [ + "node", + "from", + "why", + "at" + ] + }, + "RootAccountVerdict": { + "type": "object", + "description": "the engine's verdict of whether an account's resource is root-free, as the node's health lists it", + "properties": { + "account": { + "type": "string" + }, + "module": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "state": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "since": { + "type": "string", + "description": "since when", + "format": "date-time" + } + }, + "required": [ + "account", + "module", + "resource", + "state", + "since" + ] } } } diff --git a/go/seatlist/seatlisttest/seats/oci-artifact-registry.json b/go/seatlist/seatlisttest/seats/oci-artifact-registry.json index a9d969c..ee6f5bf 100644 --- a/go/seatlist/seatlisttest/seats/oci-artifact-registry.json +++ b/go/seatlist/seatlisttest/seats/oci-artifact-registry.json @@ -244,5 +244,140 @@ "held" ] } - } + }, + "states": [ + { + "name": "holds", + "template": "state", + "history": 1, + "description": "the bundles, archives and copied bases kept from retention's removal", + "key-forms": [ + { + "key": "..", + "value": { + "type": "object", + "description": "one hold: an untagged bundle, archive or copied base kept from retention's removal", + "properties": { + "seat": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "since": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "seat", + "name", + "kind", + "digest", + "by", + "since" + ] + }, + "description": "one hold" + } + ] + }, + { + "name": "windows", + "template": "state", + "history": 1, + "description": "the direct-write windows, one per caller", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "the direct-write window of one caller: open while a node holds it open", + "properties": { + "name": { + "type": "string", + "description": "the caller whose window it is" + }, + "open": { + "type": "boolean" + }, + "holding": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the nodes holding it open" + }, + "since": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "when (RFC 3339 time)" + }, + "description": "node to when it began to hold" + }, + "closed": { + "type": "string", + "description": "when it last closed (RFC 3339 time)" + }, + "last_holding": { + "type": "array", + "items": { + "type": "string" + } + }, + "dropped": { + "type": "array", + "items": { + "type": "object", + "properties": { + "node": { + "type": "string" + }, + "by": { + "type": "string" + }, + "why": { + "type": "string" + }, + "at": { + "type": "string", + "description": "when (RFC 3339 time)" + } + }, + "required": [ + "node" + ] + }, + "description": "retired nodes dropped by hand" + }, + "why": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "of each node holding the window, why it holds it" + } + }, + "required": [ + "name" + ] + }, + "description": "one caller's window" + } + ] + } + ] } diff --git a/go/seatlist/seatlisttest/seats/operator-channel.json b/go/seatlist/seatlisttest/seats/operator-channel.json index 0bd8c21..3f33532 100644 --- a/go/seatlist/seatlisttest/seats/operator-channel.json +++ b/go/seatlist/seatlisttest/seats/operator-channel.json @@ -182,7 +182,56 @@ "key": ".", "value": { "type": "object", - "description": "one ask the router holds and how it stands" + "description": "one ask the router holds and how it stands, as the router's tool states it", + "properties": { + "key": { + "type": "string", + "description": "." + }, + "asker": { + "type": "string", + "description": "its asker" + }, + "headline": { + "type": "string", + "description": "its headline" + }, + "state": { + "type": "string", + "description": "how it stands: open, chosen, expired, cancelled, replaced or refused" + }, + "opened": { + "type": "string", + "description": "when it was opened, RFC 3339" + }, + "expires": { + "type": "string", + "description": "when it ends unanswered, RFC 3339" + }, + "offered_on": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the channels it was offered on" + }, + "asker_told": { + "type": "boolean", + "description": "its asker has been told how it ended" + }, + "answer": { + "type": "string", + "description": "how it ended, in words" + } + }, + "required": [ + "key", + "asker", + "headline", + "state", + "opened", + "expires" + ] }, "description": "one ask the router holds and how it stands" } @@ -563,6 +612,250 @@ "subject", "state" ] + }, + "Button": { + "type": "object", + "description": "one answer a channel offers: its label and the router's one-time ticket for it", + "properties": { + "label": { + "type": "string", + "description": "the button's label" + }, + "ticket": { + "type": "string", + "description": "the router's one-time ticket for it" + } + }, + "required": [ + "label", + "ticket" + ] + }, + "Message": { + "type": "object", + "description": "the work a channel takes on the bench channel: shown with buttons (show), shown again in place (edit) or said (send); the words are the router's, shown as given", + "properties": { + "handle": { + "type": "string", + "description": "the router's name for it, the same across a show and its edits" + }, + "title": { + "type": "string", + "description": "its title" + }, + "body": { + "type": "string", + "description": "its body" + }, + "buttons": { + "type": "array", + "items": { + "$ref": "#/defs/Button" + }, + "description": "the answers it offers (none for a send)" + }, + "note": { + "type": "string", + "description": "its secondary words after the body and the buttons: who asked and when, what each answer does, when it expires, the ask's details" + }, + "urgent": { + "type": "boolean", + "description": "urgent" + }, + "silent": { + "type": "boolean", + "description": "shown without notifying" + }, + "reply": { + "type": "string", + "description": "the choice or link request this answers, by its id: the channel shows it where that was made" + }, + "to": { + "type": "string", + "description": "the account linked as the operator on this kind, where the channel sends what is not a reply; empty when none is linked" + }, + "secret": { + "type": "boolean", + "description": "the words carry something shown once (a link's code): the channel keeps no copy" + } + }, + "required": [ + "handle", + "title", + "body" + ] + }, + "Sender": { + "type": "object", + "description": "who a channel's service says sent something; the router alone judges whether that is the operator", + "properties": { + "identity": { + "type": "string", + "description": "the account" + }, + "display": { + "type": "string", + "description": "the name it shows" + }, + "authenticated": { + "type": "boolean", + "description": "whether the service authenticated it" + } + }, + "required": [ + "identity", + "authenticated" + ] + }, + "Choice": { + "type": "object", + "description": "a button chosen on a channel, said on the bench intake as the event `choice`", + "properties": { + "id": { + "type": "string", + "description": "the channel's own for this arrival, unique, so the router acts on it once" + }, + "ticket": { + "type": "string", + "description": "the ticket of the button" + }, + "handle": { + "type": "string", + "description": "the message's handle" + }, + "sender": { + "$ref": "#/defs/Sender" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "id", + "ticket", + "sender", + "at" + ] + }, + "LinkAsked": { + "type": "object", + "description": "somebody on a channel asking to be linked as the operator, said on the bench intake as the event `link`", + "properties": { + "id": { + "type": "string", + "description": "the channel's own for this arrival" + }, + "sender": { + "$ref": "#/defs/Sender" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "id", + "sender", + "at" + ] + }, + "Failed": { + "type": "object", + "description": "a message a channel could not deliver, said on the bench intake as the event `failed`", + "properties": { + "handle": { + "type": "string", + "description": "the message's handle" + }, + "why": { + "type": "string", + "description": "why" + }, + "permanent": { + "type": "boolean", + "description": "trying again cannot help" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "handle", + "why", + "at" + ] + }, + "Standing": { + "type": "object", + "description": "what a channel says of itself at least every five minutes and whenever it changes, on the bench intake as the event `standing`", + "properties": { + "ready": { + "type": "boolean", + "description": "it can send now" + }, + "why": { + "type": "string", + "description": "why not" + }, + "edits-silently": { + "type": "boolean", + "description": "its edits notify nobody" + }, + "at": { + "type": "string", + "description": "when, RFC 3339", + "format": "date-time" + } + }, + "required": [ + "ready", + "at" + ] + }, + "Code": { + "type": "object", + "description": "a code a person typed on a channel, asked as the proof `code` on the bench intake by request and reply: never in an event, never kept", + "properties": { + "sender": { + "$ref": "#/defs/Sender" + }, + "code": { + "type": "string", + "description": "the code" + }, + "purpose": { + "type": "string", + "description": "what it is for" + } + }, + "required": [ + "sender", + "code", + "purpose" + ] + }, + "ProofAnswer": { + "type": "object", + "description": "the router's answer to a proof", + "properties": { + "accepted": { + "type": "boolean", + "description": "whether it was taken" + }, + "words": { + "type": "string", + "description": "words to say to the person" + } + }, + "required": [ + "accepted", + "words" + ] } } } diff --git a/go/seatlist/seatlisttest/seats/the-private-network.json b/go/seatlist/seatlisttest/seats/the-private-network.json index 6cd6500..bb27936 100644 --- a/go/seatlist/seatlisttest/seats/the-private-network.json +++ b/go/seatlist/seatlisttest/seats/the-private-network.json @@ -2,7 +2,18 @@ "format": 1, "seat": "the-private-network", "scope": "node", - "grain": "seat-list", + "grain": "whole", "domain": "Connectivity", - "decision": "novox/hq ADR 0110" + "decision": "novox/hq ADR 0110, ADR 0634, ADR 0635", + "description": "The seat of the private network (ADR 0110): the one network the mesh runs over, which a machine can have only one of; the mesh provides its holder itself and the provision carries no fields.", + "verbs": [], + "provisions": [ + { + "name": "private-network", + "form": { + "type": "object", + "description": "the provision has no fields: it is the machine's reach to the others" + } + } + ] } diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 4919957..c20b996 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -34,7 +34,6 @@ delivery/builder.go: container-image-registry delivery/builder.go: oci-artifact-registry delivery/checker.go: mesh-delivery-checker delivery/composed.go: mesh-provisioner -delivery/enrolment.go: mesh-enrolment delivery/held.go: mesh.seat.mesh-artifact-sweep.event. delivery/placement.go: mesh-self-check delivery/provided.go: the-private-network -- 2.54.0 From 7aea59a78968b0ef8380ca730077938c2ca7c6eb Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:19:14 +0100 Subject: [PATCH 108/144] =?UTF-8?q?seat=20list:=20the=20node-engine=20defi?= =?UTF-8?q?nition=20follows=20the=20contract's=20lists=20in=20a=20node's?= =?UTF-8?q?=20facts=20(copies=20kept=20equal;=20ADR=200636=20=C2=A77;=20is?= =?UTF-8?q?sue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/bootstrap/node-engine.json | 20 +++++++++++++++---- .../seatlisttest/seats/node-engine.json | 20 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json index 237a651..49462e9 100644 --- a/go/seatlist/bootstrap/node-engine.json +++ b/go/seatlist/bootstrap/node-engine.json @@ -856,16 +856,28 @@ "description": "the digest of the node's own composition it runs" }, "windows": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "busy": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "strays": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "filters": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "profile": { "type": "object" diff --git a/go/seatlist/seatlisttest/seats/node-engine.json b/go/seatlist/seatlisttest/seats/node-engine.json index 237a651..49462e9 100644 --- a/go/seatlist/seatlisttest/seats/node-engine.json +++ b/go/seatlist/seatlisttest/seats/node-engine.json @@ -856,16 +856,28 @@ "description": "the digest of the node's own composition it runs" }, "windows": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "busy": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "strays": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "filters": { - "type": "object" + "type": "array", + "items": { + "type": "object" + } }, "profile": { "type": "object" -- 2.54.0 From b7f6cd6bf41bfa24e526861c2823e670a1464360 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:24:52 +0100 Subject: [PATCH 109/144] seat list: the mesh-catalog definition, in the bootstrap and the test list, lists manifest-accepted and manifest-refused like the contract (B7d finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/bootstrap/mesh-catalog.json | 101 ++++++++++++++++++ .../seatlisttest/seats/mesh-catalog.json | 101 ++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/go/seatlist/bootstrap/mesh-catalog.json b/go/seatlist/bootstrap/mesh-catalog.json index 9fb8846..0830103 100644 --- a/go/seatlist/bootstrap/mesh-catalog.json +++ b/go/seatlist/bootstrap/mesh-catalog.json @@ -286,6 +286,107 @@ "seat" ] } + }, + { + "name": "manifest-accepted", + "body": { + "type": "object", + "properties": { + "v": { + "type": "integer" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "judge": { + "type": "string" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it" + }, + "bus-state": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "storage": { + "type": "string" + }, + "template": { + "type": "string" + }, + "options": { + "type": "object" + }, + "configuration": { + "type": "object" + }, + "types": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "name", + "storage" + ] + } + } + }, + "required": [ + "v", + "module", + "digest", + "judge", + "bus-state" + ] + } + }, + { + "name": "manifest-refused", + "body": { + "type": "object", + "properties": { + "v": { + "type": "integer" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "judge": { + "type": "string" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "v", + "module", + "digest", + "judge", + "problems" + ] + } } ], "states": [ diff --git a/go/seatlist/seatlisttest/seats/mesh-catalog.json b/go/seatlist/seatlisttest/seats/mesh-catalog.json index 9fb8846..0830103 100644 --- a/go/seatlist/seatlisttest/seats/mesh-catalog.json +++ b/go/seatlist/seatlisttest/seats/mesh-catalog.json @@ -286,6 +286,107 @@ "seat" ] } + }, + { + "name": "manifest-accepted", + "body": { + "type": "object", + "properties": { + "v": { + "type": "integer" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "judge": { + "type": "string" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it" + }, + "bus-state": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "storage": { + "type": "string" + }, + "template": { + "type": "string" + }, + "options": { + "type": "object" + }, + "configuration": { + "type": "object" + }, + "types": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "name", + "storage" + ] + } + } + }, + "required": [ + "v", + "module", + "digest", + "judge", + "bus-state" + ] + } + }, + { + "name": "manifest-refused", + "body": { + "type": "object", + "properties": { + "v": { + "type": "integer" + }, + "module": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "judge": { + "type": "string" + }, + "revision": { + "type": "integer", + "description": "the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it" + }, + "problems": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "v", + "module", + "digest", + "judge", + "problems" + ] + } } ], "states": [ -- 2.54.0 From 41117946907d6b4a877f2895b6c1cba099ea8be7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:25:56 +0100 Subject: [PATCH 110/144] event hub: a start that found no durable on the bus is tried again at the next start, so a part is not lost for the order in which the bus makes its consumer (B7e; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/process/event_hub.go | 22 ++++++++--------- go/views/process_reader_integration_test.go | 27 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/go/process/event_hub.go b/go/process/event_hub.go index 7d0a6a8..f648d65 100644 --- a/go/process/event_hub.go +++ b/go/process/event_hub.go @@ -19,11 +19,9 @@ import ( type EventHub struct { logf func(string, ...any) - mu sync.RWMutex - routes []hubRoute - started bool - cons jetstream.ConsumeContext - startErr error + mu sync.RWMutex + routes []hubRoute + cons jetstream.ConsumeContext } type hubRoute struct { @@ -68,23 +66,23 @@ func (h *EventHub) Patterns() []string { return out } -// Start binds the module's durable on the shared events stream and pulls it; later calls do nothing and return the -// first one's result. It makes no stream and no consumer. +// Start binds the module's durable on the shared events stream and pulls it; a call after a start that took does +// nothing. It makes no stream and no consumer: a consumer the bus does not hold yet is the error of this call, and a +// later call (the module's next change of membership) binds it, so a part is not lost for the bus's order. func (h *EventHub) Start(ctx context.Context, js jetstream.JetStream, cred Account) error { h.mu.Lock() defer h.mu.Unlock() - if h.started { - return h.startErr + if h.cons != nil { + return nil } - h.started = true cons, err := cred.EventConsumer(ctx, js, EventsStream) if err == nil { h.cons, err = HearEvents(cons, h.hear, h.logf) } if err != nil { - h.startErr = fmt.Errorf("the consumer %s on %s is not heard: %w", cred.EventConsumerName(), EventsStream, err) + return fmt.Errorf("the consumer %s on %s is not heard: %w", cred.EventConsumerName(), EventsStream, err) } - return h.startErr + return nil } // Stop ends the pull. diff --git a/go/views/process_reader_integration_test.go b/go/views/process_reader_integration_test.go index e8310e0..55f0c41 100644 --- a/go/views/process_reader_integration_test.go +++ b/go/views/process_reader_integration_test.go @@ -156,4 +156,31 @@ func TestTheViewsReaderAndTwoHandlersShareTheModulesOneDurable(t *testing.T) { if info, err := cons.Info(ctx); err != nil || info.NumAckPending != 0 { t.Fatalf("a message was left unacknowledged: %+v %v", info, err) } + // A hub started before the bus holds its durable says so, and binds at a later start: a module is not lost for the + // order in which the bus makes its consumer. + late := process.Account{Node: "n1", Module: "late"} + lateHub := process.NewEventHub(t.Logf) + heard := make(chan string, 1) + if err := lateHub.Handle("mesh.mod.l.event.>", func(m jetstream.Msg) { _ = m.Ack(); heard <- m.Subject() }); err != nil { + t.Fatal(err) + } + defer lateHub.Stop() + if err := lateHub.Start(ctx, js, late); err == nil { + t.Fatal("a hub with no durable on the bus started") + } + if _, err := js.CreateOrUpdateConsumer(ctx, process.EventsStream, jetstream.ConsumerConfig{Durable: late.EventConsumerName(), + AckPolicy: jetstream.AckExplicitPolicy, FilterSubjects: []string{"mesh.mod.l.event.>"}}); err != nil { + t.Fatal(err) + } + if err := lateHub.Start(ctx, js, late); err != nil { + t.Fatalf("the hub did not bind at its second start: %v", err) + } + if _, err := js.Publish(ctx, "mesh.mod.l.event.x", []byte("{}")); err != nil { + t.Fatal(err) + } + select { + case <-heard: + case <-time.After(10 * time.Second): + t.Fatal("the hub started late heard nothing") + } } -- 2.54.0 From 94893851656724b259c81fbc190d97e07552a1a7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:35:19 +0100 Subject: [PATCH 111/144] seat list: the bus of a process on its own credential (a verb called through its connection, the seat's events as handlers of the module's event hub), so the checker and the grants part read the list as every part does (B7e; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/seatlist/processbus.go | 44 ++++++++++++++++++++++++++++++++ go/seatlist/processbus_test.go | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 go/seatlist/processbus.go create mode 100644 go/seatlist/processbus_test.go diff --git a/go/seatlist/processbus.go b/go/seatlist/processbus.go new file mode 100644 index 0000000..b9a3e88 --- /dev/null +++ b/go/seatlist/processbus.go @@ -0,0 +1,44 @@ +package seatlist + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// EventHandlers is where a process registers the handler of a subject on the module's one pull of its durable consumer +// (process.EventHub). Register every handler, then start the hub. +type EventHandlers interface { + Handle(pattern string, handle func(jetstream.Msg)) error +} + +type processBus struct { + call func(string, any) (json.RawMessage, error) + hub EventHandlers +} + +// ProcessBus is the bus of a process on its own credential: a verb of the seat is called through its connection +// (process.Conn.Call), and the seat's events are heard as handlers of the module's event hub, on the seat's own subject +// (`mesh.seat..event.`). The process starts the hub after the reader is started, so every handler is registered. +func ProcessBus(call func(string, any) (json.RawMessage, error), hub EventHandlers) Bus { + return processBus{call: call, hub: hub} +} + +func (b processBus) Ask(key string, body any) (json.RawMessage, error) { return b.call(key, body) } + +func (b processBus) Subscribe(pattern string, handle func(key string, body json.RawMessage) error) error { + seat, event, ok := strings.Cut(pattern, ".") + if !ok || seat == "" || event == "" { + return fmt.Errorf("%q is no seat event's pattern, .", pattern) + } + return b.hub.Handle(seatdef.EventSubject(seat, event), func(m jetstream.Msg) { + // A reader that could not take the event says so in its status and reads again at the next event or by hand + // (Reload): the message is not delivered again for it. + _ = handle(pattern, m.Data()) + _ = m.Ack() + }) +} diff --git a/go/seatlist/processbus_test.go b/go/seatlist/processbus_test.go new file mode 100644 index 0000000..478bfa9 --- /dev/null +++ b/go/seatlist/processbus_test.go @@ -0,0 +1,46 @@ +package seatlist + +import ( + "encoding/json" + "testing" + + "github.com/nats-io/nats.go/jetstream" +) + +type hubStub map[string]func(jetstream.Msg) + +func (h hubStub) Handle(pattern string, f func(jetstream.Msg)) error { h[pattern] = f; return nil } + +type msgStub struct { + jetstream.Msg + data []byte + acked *int +} + +func (m msgStub) Data() []byte { return m.data } +func (m msgStub) Ack() error { *m.acked++; return nil } + +func TestAProcessBusAsksByKeyAndHearsTheSeatsEventsOnTheHub(t *testing.T) { + var asked string + hub := hubStub{} + bus := ProcessBus(func(key string, body any) (json.RawMessage, error) { asked = key; return json.RawMessage(`{}`), nil }, hub) + if _, err := bus.Ask(Key(), nil); err != nil || asked != Key() { + t.Fatalf("asked %q, %v", asked, err) + } + var got []string + if err := bus.Subscribe(Pattern(ChangedEvent), func(key string, body json.RawMessage) error { got = append(got, key+" "+string(body)); return nil }); err != nil { + t.Fatal(err) + } + h := hub["mesh.seat."+Seat+".event."+ChangedEvent] + if h == nil { + t.Fatalf("handlers on %v, want the seat's own subject", hub) + } + acked := 0 + h(msgStub{data: []byte(`{"seat":"x"}`), acked: &acked}) + if len(got) != 1 || got[0] != Pattern(ChangedEvent)+` {"seat":"x"}` || acked != 1 { + t.Errorf("heard %v, acked %d", got, acked) + } + if err := bus.Subscribe("nodot", nil); err == nil { + t.Error("a pattern that names no seat event was taken") + } +} -- 2.54.0 From a25c6008105dbe8bb9e89dc077aabf55a2a95360 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:19:26 +0100 Subject: [PATCH 112/144] seat list: ReloadIfUnread reads the list again once when a host's events path is live; event hub: StartUntilBound binds a late consumer without a restart (B9e; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/process/event_hub.go | 39 ++++++++++++++ go/process/event_hub_integration_test.go | 67 ++++++++++++++++++++++++ go/seatlist/seatlist.go | 32 +++++++++++ go/seatlist/seatlist_test.go | 40 ++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 go/process/event_hub_integration_test.go diff --git a/go/process/event_hub.go b/go/process/event_hub.go index f648d65..df8537e 100644 --- a/go/process/event_hub.go +++ b/go/process/event_hub.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "sync" + "time" "github.com/nats-io/nats.go/jetstream" ) @@ -85,6 +86,44 @@ func (h *EventHub) Start(ctx context.Context, js jetstream.JetStream, cred Accou return nil } +// StartUntilBound is Start that does not give up: when the module's consumer is not there yet it returns that error at +// once, and tries again on a goroutine of its own, after each wait, until it binds or ctx ends; then live is called (nil +// calls nothing), so a part that needs the events path live does what it does only then. wait returns false to stop +// trying (nil waits five seconds or for ctx). The bus makes the consumer when the module's account is composed, which +// says nothing a module can hear before the consumer exists, so the retry is a wait on that, bounded by ctx. +func (h *EventHub) StartUntilBound(ctx context.Context, js jetstream.JetStream, cred Account, wait func(context.Context) bool, live func()) error { + err := h.Start(ctx, js, cred) + if err == nil { + if live != nil { + live() + } + return nil + } + if wait == nil { + wait = func(ctx context.Context) bool { + t := time.NewTimer(5 * time.Second) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } + } + } + go func() { + for wait(ctx) && ctx.Err() == nil { + if h.Start(ctx, js, cred) == nil { + if live != nil { + live() + } + return + } + } + }() + return err +} + // Stop ends the pull. func (h *EventHub) Stop() { h.mu.Lock() diff --git a/go/process/event_hub_integration_test.go b/go/process/event_hub_integration_test.go new file mode 100644 index 0000000..f8fa9bf --- /dev/null +++ b/go/process/event_hub_integration_test.go @@ -0,0 +1,67 @@ +//go:build integration + +package process_test + +import ( + "context" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +// A module whose consumer the bus makes after the module started is not lost for that order: the hub tries again after +// each wait and calls live once it is bound (the checker's "until it starts again" hole). +func TestTheHubBindsALateConsumerAndSaysItIsLive(t *testing.T) { + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{"mesh.mod.*.event.>"}}); err != nil { + t.Fatal(err) + } + cred := process.Account{Node: "n1", Module: "late"} + hub := process.NewEventHub(t.Logf) + waits, live := make(chan chan bool), make(chan struct{}, 1) + wait := func(ctx context.Context) bool { + step := make(chan bool) + select { + case waits <- step: + case <-ctx.Done(): + return false + } + return <-step + } + if err := hub.StartUntilBound(ctx, js, cred, wait, func() { live <- struct{}{} }); err == nil { + t.Fatal("no consumer was there, and the start said nothing") + } + select { + case <-live: + t.Fatal("live before the consumer was there") + default: + } + step := <-waits + step <- true // tried again: still no consumer + step = <-waits + if _, err := js.CreateOrUpdateConsumer(ctx, process.EventsStream, jetstream.ConsumerConfig{Durable: cred.EventConsumerName(), AckPolicy: jetstream.AckExplicitPolicy}); err != nil { + t.Fatal(err) + } + step <- true + select { + case <-live: + case <-ctx.Done(): + t.Fatal("the consumer was made and the hub never said it is live") + } + hub.Stop() +} diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index 9d0dd06..cc366f3 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -115,6 +115,7 @@ type Reader struct { ready chan struct{} // closed once the first read of the seat has come to an end, answered or not revision uint64 lastErr string + again chan struct{} // the read ReloadIfUnread began, closed when it has come to an end } // Held is one definition a reader holds: its seat, where from, and the digest of the trunk file it came from. @@ -326,6 +327,37 @@ func (r *Reader) Reload() error { return nil } +// ReloadIfUnread reads the list again, on a goroutine of its own, when the seat has not answered yet; it is for a host whose +// path for the seat's events has just become live. The seat may have said it speaks before that path existed, and events +// start at the stream's end, so without this call the list would stay unread until the seat's next event (ADR 0636 §1). +// It returns nil when the list is already read; otherwise a channel closed when the read has come to an end, which is the +// reader's first read or the read a call before began while one is still running, never a second one beside it. Whether the +// seat answered is Status().Heard; an unanswered read leaves the list unread, to be read at the seat's next event. +func (r *Reader) ReloadIfUnread() <-chan struct{} { + r.mu.Lock() + defer r.mu.Unlock() + if r.spoken { + return nil + } + if !r.isReady() { + return r.ready + } + if r.again != nil { + select { + case <-r.again: + default: + return r.again + } + } + done := make(chan struct{}) + r.again = done + go func() { + _ = r.Reload() + close(done) + }() + return done +} + // fetch asks the seat for one definition and holds it, or drops it when the seat holds none. func (r *Reader) fetch(seat string) error { raw, err := r.cfg.Bus.Ask(Key(), map[string]any{"seat": seat}) diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index e196f4d..e0e8147 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -286,3 +286,43 @@ func TestTheBootstrapEqualsTheTestsSeatList(t *testing.T) { } } } + +// A host whose path for the seat's events became live after the seat spoke reads the list at once, once: a read +// already running is the one waited for, and a list that is read is left alone. +func TestReloadIfUnreadReadsAListTheSeatSaidItsEventBeforeTheHostHeardIt(t *testing.T) { + owner := &fakeOwner{down: true} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + <-r.Ready() + if r.Status().Heard { + t.Fatal("the silent seat must leave the reader unheard") + } + // the seat still silent: a read is made and the list stays unread + if done := r.ReloadIfUnread(); done == nil { + t.Fatal("an unread list was not read again") + } else { + <-done + } + if r.Status().Heard { + t.Fatal("a silent seat cannot have been heard") + } + // the seat answered (its event was said before the host listened): the next call reads, and only one read runs beside another + owner.down = false + owner.table = map[string]string{"node-b": def("node-b", "node", "put")} + owner.gate = make(chan struct{}) + first := r.ReloadIfUnread() + second := r.ReloadIfUnread() + if first == nil || first != second { + t.Fatal("a second call beside a running read must wait for the same one") + } + close(owner.gate) + <-first + if st := r.Status(); !st.Heard || !r.Has("node-b") { + t.Fatalf("after the read: %+v", st) + } + if r.ReloadIfUnread() != nil { + t.Fatal("a list that is read was read again") + } +} -- 2.54.0 From c84a2e58e88ca4207c44de3aa3f4f41576e25087 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:37:56 +0100 Subject: [PATCH 113/144] Delete go/composer: delivery sdk's composer holds it, no importer left on any join head (S-7, B9f; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/composer/composer.go | 209 ----------------------------------- go/composer/composer_test.go | 58 ---------- go/packages_list_test.go | 2 +- 3 files changed, 1 insertion(+), 268 deletions(-) delete mode 100644 go/composer/composer.go delete mode 100644 go/composer/composer_test.go diff --git a/go/composer/composer.go b/go/composer/composer.go deleted file mode 100644 index 8a33b1a..0000000 --- a/go/composer/composer.go +++ /dev/null @@ -1,209 +0,0 @@ -// Package composer is the shape of a composition over a plain request (novox/hq ADR 0482 clause 4, issue 1621, as ADR -// 0593 §7 changes it): the request the command mesh-compose reads and the verdict it answers. The command and the -// composer that runs it are the node-engine's (mesh-host cmd/mesh-compose), built from its repository at a group's -// head, so a change to its composition code is judged by that code. -// -// **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the -// request (raw, as their repository holds them), the rest of the mesh is a compose.Input, and the answer is a value. -// A manifest named only by its digest in the delivery indexer's bucket is filled before the run by Fill, by whoever -// holds the bus (the Go build module's compose job); Run refuses a digest left unfilled. -package composer - -import ( - "encoding/json" - "errors" - "fmt" - "slices" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Version is the version of a Request and of a Verdict; a reader refuses another. -const Version = 1 - -// Package is the import path of the command, which a compose job builds at a commit of the node-engine's repository -// (novox/hq ADR 0593 §7). -const Package = "github.com/novox/mesh-host/cmd/mesh-compose" - -// The depths of a composition. -const ( - // DepthResolve resolves each machine's set over the rest of the mesh (compose.Resolve): what the assigner judges - // while the facts snapshot is not on the bus (issue 1461). - DepthResolve = "resolve" - // DepthCompose composes each machine's whole declaration and runs the node-engine's validator over it - // (compose.Compose). - DepthCompose = "compose" -) - -// Request is everything one run composes: the rest of the mesh, one or more cases of manifests composed over it, and -// the machines composed in each case. -type Request struct { - V int `json:"v"` - // Input is the composition's input without its catalogue (and without each node's kept manifests): the cases give - // those. A Catalogue or Kept it holds is ignored. It is the node-engine's composition input as JSON, which only the - // composer decodes (novox/hq ADR 0593 §7), so this shape imports no composition. - Input json.RawMessage `json:"input"` - // Cases are composed one after the other over Input, each with its own manifests; their names are unique. - Cases []Case `json:"cases"` - // Machines are the nodes composed in every case; none means the manifests are only read. - Machines []string `json:"machines,omitempty"` - // Depth is DepthResolve or DepthCompose; empty is DepthCompose. - Depth string `json:"depth,omitempty"` - // HoldingsFromClaims adds to Input's holdings every seat an assigned module claims, held on its node (the - // assigner's input, which has no holdings of record). - HoldingsFromClaims bool `json:"holdings_from_claims,omitempty"` - // Declarations asks each composed machine's declared paths (DepthCompose only). - Declarations bool `json:"declarations,omitempty"` - // Predefined is the list of predefined keys at the catalogue's root (predefined.json, novox/hq ADR 0348 §7). No - // longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts - // come in each case's Verdicts. Kept so an asker that still sends it is read. - Predefined catalogue.PredefinedList `json:"predefined,omitempty"` - // Packages are the known packages a manifest's `packages` may name, each by its name in its own ecosystem (design 32: - // a Go package's go.mod module path, a TypeScript package's package.json name), for the indexer's judge (novox/hq ADR - // 0578 §5, ADR 0606 §4); the composer does not read them. Nil is no list at hand, and the judge judges no name; an - // empty list is a list, and every name is unknown to it. - Packages []string `json:"packages,omitzero"` -} - -// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs. -// -// Placements are, by node, the placement the case composes that node with in place of what Input says of it: a change's -// own placement files, so a check composes a catalogue pull request's placement (issue 1126, frombus.Placed); a null -// placement leaves nothing placed on the node. A case without them composes Input's. -// -// Verdicts are, by module, the indexer's judge's verdict on its manifest in Manifests (novox/hq ADR 0578 §5), put in by -// the assigner or the checker that runs the composer: a refused one is held, composed as its stub so what it offers -// still shapes the rest, and named in the case's verdict. A module without one is accepted (delivery.TakeManifestVerdict). -type Case struct { - Name string `json:"name"` - Manifests map[string]Manifest `json:"manifests"` - Verdicts map[string]delivery.ManifestVerdict `json:"verdicts,omitempty"` - Kept map[string]map[string]Manifest `json:"kept,omitempty"` - Placements map[string]*delivery.Placement `json:"placements,omitempty"` -} - -// Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps -// it under (Digest), which Fill replaces by the bytes. On the wire it is the manifest itself, or {"digest": "..."}. -type Manifest struct { - Raw json.RawMessage - Digest string -} - -// MarshalJSON writes the raw manifest, or {"digest": ...} when only the digest is known. -func (m Manifest) MarshalJSON() ([]byte, error) { - if len(m.Raw) > 0 { - return m.Raw, nil - } - return json.Marshal(map[string]string{"digest": m.Digest}) -} - -// UnmarshalJSON reads {"digest": "..."} (an object with that one key) as a digest, and any other value as the raw -// manifest. -func (m *Manifest) UnmarshalJSON(raw []byte) error { - var probe map[string]json.RawMessage - if json.Unmarshal(raw, &probe) == nil && len(probe) == 1 { - if d, ok := probe["digest"]; ok { - var digest string - if err := json.Unmarshal(d, &digest); err != nil { - return fmt.Errorf("a manifest's digest is not a string: %w", err) - } - *m = Manifest{Digest: digest} - return nil - } - } - *m = Manifest{Raw: slices.Clone(raw)} - return nil -} - -// Verdict is what one run answers: the mesh-sdk the command was built with, and each case's verdict in the order asked. -type Verdict struct { - V int `json:"v"` - SDK string `json:"sdk"` - Cases []CaseVerdict `json:"cases"` -} - -// CaseVerdict is one case's: every manifest read or refused, every machine composed or not. -type CaseVerdict struct { - Name string `json:"name"` - Manifests map[string]ManifestRead `json:"manifests"` - Machines map[string]Machine `json:"machines,omitempty"` -} - -// ManifestRead is one manifest as this mesh-sdk reads it: accepted, or refused with why: the indexer's verdict -// (Case.Verdicts) or bytes that do not decode. A field the decoder does not know is set aside, named in SetAside, and -// the manifest still accepted (novox/hq ADR 0578 §6). -type ManifestRead struct { - Accepted bool `json:"accepted"` - Refused string `json:"refused,omitempty"` - SetAside string `json:"set_aside,omitempty"` -} - -// Machine is one machine composed: whether it composes (resolves, at DepthResolve), why not, the node-engine -// validator's problems and the composition's refusals (DepthCompose), and its declared paths when asked. NotJudged says, -// in words, why the composer did not judge the machine (an older node-engine's input that does not say how it composes, -// novox/hq ADR 0593 §9): the machine is then neither composed nor refused, and a reader decides by this field alone, -// never by the words of Reason. -type Machine struct { - Composes bool `json:"composes"` - NotJudged string `json:"not_judged,omitempty"` - Reason string `json:"reason,omitempty"` - Problems []string `json:"problems,omitempty"` - Refusals []string `json:"refusals,omitempty"` - Declared []Path `json:"declared,omitempty"` -} - -// Path is one path a declaration puts on the machine, with its kind (the resource's type), owner, group and mode as -// declared, and the module it is of. -type Path struct { - Path string `json:"path"` - Kind string `json:"kind"` - Owner string `json:"owner,omitempty"` - Group string `json:"group,omitempty"` - Mode string `json:"mode,omitempty"` - Module string `json:"module,omitempty"` -} - -// ErrUnfilled is a manifest named by a digest that nobody replaced by its bytes. -var ErrUnfilled = errors.New("composer: a manifest is named by its digest and was not filled") - -// Decode reads a request; it refuses one that is not JSON or of another version. -func Decode(data []byte) (Request, error) { - var r Request - if err := json.Unmarshal(data, &r); err != nil { - return Request{}, fmt.Errorf("composer: the request is not JSON of its shape: %w", err) - } - if r.V != Version { - return Request{}, fmt.Errorf("composer: a request of version %d, this composer reads %d", r.V, Version) - } - return r, nil -} - -// Fill replaces every manifest named by its digest by the bytes get answers for that digest; a digest get cannot answer -// stops it, named. -func Fill(r *Request, get func(digest string) ([]byte, error)) error { - fill := func(m map[string]Manifest) error { - for name, man := range m { - if len(man.Raw) > 0 { - continue - } - raw, err := get(man.Digest) - if err != nil { - return fmt.Errorf("composer: the manifest of %s (digest %s): %w", name, man.Digest, err) - } - m[name] = Manifest{Raw: raw} - } - return nil - } - for _, c := range r.Cases { - if err := fill(c.Manifests); err != nil { - return err - } - for _, kept := range c.Kept { - if err := fill(kept); err != nil { - return err - } - } - } - return nil -} diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go deleted file mode 100644 index 9ee1c40..0000000 --- a/go/composer/composer_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package composer - -import ( - "encoding/json" - "errors" - "strings" - "testing" -) - -const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}` - -// A manifest named by its digest goes on the wire as {"digest": ...}, and Fill puts the bytes the indexer keeps in its -// place; a raw manifest goes as itself. -func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { - r := Request{V: Version, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{ - "demo": {Digest: "d1"}, "raw": {Raw: json.RawMessage(`{"module":"raw","version":"1"}`)}}, - Kept: map[string]map[string]Manifest{"laptop": {"demo": {Digest: "d1"}}}}}} - wire, err := json.Marshal(r) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(wire), `"demo":{"digest":"d1"}`) || !strings.Contains(string(wire), `"raw":{"module":"raw","version":"1"}`) { - t.Fatalf("on the wire: %s", wire) - } - back, err := Decode(wire) - if err != nil { - t.Fatal(err) - } - asked := 0 - if err := Fill(&back, func(d string) ([]byte, error) { - asked++ - if d != "d1" { - return nil, errors.New("no such digest") - } - return []byte(demo), nil - }); err != nil { - t.Fatal(err) - } - if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo { - t.Fatalf("filled %d times: %+v", asked, back.Cases[0]) - } - // The known packages: none is no list (not on the wire), an empty list is a list, and a list reads back whole. - for _, known := range [][]string{nil, {}, {"git.novox.be/novox/mesh-sdk", "@novox/mesh-sdk"}} { - wire, err := json.Marshal(Request{V: Version, Packages: known}) - if err != nil { - t.Fatal(err) - } - back, err := Decode(wire) - if err != nil || (back.Packages == nil) != (known == nil) || strings.Join(back.Packages, ",") != strings.Join(known, ",") || - known == nil && strings.Contains(string(wire), "packages") { - t.Errorf("the known packages %#v went as %s and read back %#v (%v)", known, wire, back.Packages, err) - } - } - bad := Request{V: Version, Cases: []Case{{Name: "c", Manifests: map[string]Manifest{"x": {Digest: "nope"}}}}} - if err := Fill(&bad, func(string) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") { - t.Fatalf("a digest the indexer does not hold: %v", err) - } -} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index c160b28..857ca19 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -24,7 +24,7 @@ import ( // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", - "composer", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", + "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } -- 2.54.0 From 20495478b63ce06cb5629f7bdff143a8217d7a64 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:37:57 +0100 Subject: [PATCH 114/144] Delete go/parts: delivery sdk's parts holds it, no importer left on any join head (S-7, B9f; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/packages_list_test.go | 2 +- go/parts/bus.go | 154 -------------- go/parts/parts.go | 145 ------------- go/parts/parts_test.go | 122 ----------- go/parts/receive.go | 441 --------------------------------------- go/parts/receive_test.go | 305 --------------------------- 6 files changed, 1 insertion(+), 1168 deletions(-) delete mode 100644 go/parts/bus.go delete mode 100644 go/parts/parts.go delete mode 100644 go/parts/parts_test.go delete mode 100644 go/parts/receive.go delete mode 100644 go/parts/receive_test.go diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 857ca19..165ca36 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -25,7 +25,7 @@ import ( var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", - "objectlink", "outward", "overlay", "parts", "pernode", "process", "provider", "provider/harness", "provisions", + "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } diff --git a/go/parts/bus.go b/go/parts/bus.go deleted file mode 100644 index 6dc9d4d..0000000 --- a/go/parts/bus.go +++ /dev/null @@ -1,154 +0,0 @@ -package parts - -import ( - "encoding/json" - "errors" - "fmt" - "sync/atomic" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/busstate" -) - -// BusObjects is a Receiver's Objects over the holder's own object store, asked for in `requires` by local (the -// template objects, novox/hq ADR 0595 §2), through the runtime: every holder binds the same adapter, none its own. -func BusObjects(local string) Objects { return busObjects{busstate.ObjectsOf(local)} } - -type busObjects struct{ o busstate.Objects } - -func (b busObjects) Put(name string, data []byte) error { - _, err := b.o.Put(name, data, "") - return err -} -func (b busObjects) Get(name string) ([]byte, error) { - d, err := b.o.Get(name) - return d.Data, err -} -func (b busObjects) Delete(name string) error { return b.o.Delete(name) } -func (b busObjects) Used() (uint64, error) { - f, err := b.o.Fill() - return f.Bytes, err -} - -// BusTransfers is a Receiver's Transfers over the holder's own key-value state of that name (the template state), -// through the runtime, each transfer under its id. -func BusTransfers[M any](state string) Transfers[M] { return busTransfers[M]{state} } - -type busTransfers[M any] struct{ state string } - -func (b busTransfers[M]) Get(id string) (Transfer[M], bool, error) { - var t Transfer[M] - e, err := stdio.State(b.state).Get(id) - if err != nil || e == nil { - return t, false, err - } - return t, true, json.Unmarshal(e.Value, &t) -} -func (b busTransfers[M]) Put(t Transfer[M]) error { - if t.ID == "" { - return errors.New("a transfer without an id cannot be kept") - } - _, err := stdio.State(b.state).Put(t.ID, t) - return err -} -func (b busTransfers[M]) Delete(id string) error { return stdio.State(b.state).Delete(id) } -func (b busTransfers[M]) List() ([]Transfer[M], error) { - keys, err := stdio.State(b.state).Keys() - if err != nil { - return nil, err - } - var out []Transfer[M] - for _, k := range keys { - if t, ok, err := b.Get(k); err == nil && ok { - out = append(out, t) - } - } - return out, nil -} - -// BusCap is a Receiver's Bound over the holder's own object store of that name: the cap its binding carries (the bus's -// own bound, the store's max bytes), read through the runtime once it answers one, since a binding's cap does not -// change while the bundle runs. Make it once per bundle, not per receiver. -func BusCap(local string) func() (uint64, error) { - var known atomic.Uint64 - return func() (uint64, error) { - if c := known.Load(); c > 0 { - return c, nil - } - f, err := busstate.ObjectsOf(local).Fill() - if err != nil { - return 0, fmt.Errorf("%s's cap cannot be read from its binding: %w", local, err) - } - if f.MaxBytes == 0 { - return 0, fmt.Errorf("%s's binding carries no cap", local) - } - known.Store(f.MaxBytes) - return f.MaxBytes, nil - } -} - -// BusArm is a Receiver's Arm over the holder's own key-value state of that name, declared key-ttl: one key per open -// transfer with its own time to live; a key already there is the same bound, kept. -func BusArm(state string) func(id string, ttl time.Duration) error { - return func(id string, ttl time.Duration) error { - if _, err := stdio.State(state).CreateTTL(id, map[string]any{"transfer": id}, ttl); err != nil && !errors.Is(err, stdio.ErrChanged) { - return err - } - return nil - } -} - -// FollowIdle follows the holder's idle state (the one BusArm writes) once the bundle serves, and hands the end of each -// key (the bus's marker, OpExpire) to expired, a Receiver's Expired: the event drops an idle transfer, no timer runs. -// Once the watch is taken it hands every transfer open (open, such as IDs of the holder's Transfers) to expired too, so -// a key that ended while the bundle was down, whose marker no watch saw, still drops its transfer, and a key lost is -// armed again. said is told each outcome (dropped, or the error that stopped it) for the holder's log. It returns -// once the start's look is done; run it in its own goroutine. -func FollowIdle(state string, open func() ([]string, error), expired func(id string) (bool, error), said func(id string, dropped bool, err error)) { - <-stdio.Served() - stdio.State(state).Follow("", func(ch stdio.StateChange) error { - if ch.Op != stdio.OpExpire { - return nil - } - dropped, err := expired(ch.Key) - if said != nil && (dropped || err != nil) { - said(ch.Key, dropped, err) - } - return err - }) - expireOpen(state, open, expired, said) -} - -// expireOpen is FollowIdle's look at its start: every open transfer handed to expired, as its key's end would. -func expireOpen(state string, open func() ([]string, error), expired func(id string) (bool, error), said func(id string, dropped bool, err error)) { - say := func(id string, dropped bool, err error) { - if said != nil && (dropped || err != nil) { - said(id, dropped, err) - } - } - ids, err := open() - if err != nil { - say("", false, fmt.Errorf("the open transfers cannot be read at the start of %s's watch, so one whose idle key ended while the bundle was down waits for a look by hand: %w", state, err)) - return - } - for _, id := range ids { - dropped, err := expired(id) - say(id, dropped, err) - } -} - -// IDs answers the ids of the transfers open in t: FollowIdle's open. -func IDs[M any](t Transfers[M]) func() ([]string, error) { - return func() ([]string, error) { - all, err := t.List() - if err != nil { - return nil, err - } - out := make([]string, 0, len(all)) - for _, x := range all { - out = append(out, x.ID) - } - return out, nil - } -} diff --git a/go/parts/parts.go b/go/parts/parts.go deleted file mode 100644 index e16c463..0000000 --- a/go/parts/parts.go +++ /dev/null @@ -1,145 +0,0 @@ -// Package parts is the runtime's sender of a file in parts (novox/hq ADR 0611, ADR 0627 §2, design 51 *Publishing*): -// the caller opens a transfer naming every part's digest and size, the receiver answers the parts it lacks, and only -// those travel, in chunks a bus message carries, in sequence with the last marked. A cut transfer goes on from what -// the receiver answers when the same transfer is opened again. It names no seat, no verb and no seat's type: the -// caller's Open and Chunk are its own calls (a registry seat's client binds them to that seat's verbs and types), so -// this package holds no transport and no contract. -package parts - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" -) - -// MaxChunk is the most bytes of a part one chunk carries: base64 makes it about 1 000 KiB, under 1 MiB, the server's -// largest message, with the message's own fields (design 51: each part travels in chunks of at most 1 MiB). -const MaxChunk = 760 << 10 - -// Part is one part of a transfer: its digest (sha256:), its size and how to read its bytes. -type Part struct { - Digest string - Size int64 - Open func() (io.ReadCloser, error) -} - -// Opened is the receiver's answer to opening a transfer: whether it holds the whole already, and the digests of the -// parts it lacks. -type Opened struct { - Held bool - Lacks []string -} - -// Open opens (or opens again) the transfer on the receiver, naming every part: the caller's own call. -type Open func(ctx context.Context, parts []Part) (Opened, error) - -// Chunk sends one chunk of a part: the part's digest, its sequence from 0, whether it is the part's last, its bytes. -type Chunk func(ctx context.Context, part string, sequence int, last bool, data []byte) error - -// Sent is what a Send did: whether the receiver held the whole, the parts it lacked and were sent, the chunks they -// took. -type Sent struct { - Held bool - Parts []string - Chunks int -} - -// Send opens the transfer and sends the parts the receiver answers it lacks, each in chunks of at most MaxChunk, in -// sequence, the last marked. A part whose bytes do not hash to its digest is refused before anything of it is sent; a -// part the receiver does not name is never read; a part the receiver names that the transfer does not is refused. -func Send(ctx context.Context, open Open, chunk Chunk, parts []Part) (Sent, error) { - byDigest := map[string]Part{} - for _, p := range parts { - byDigest[p.Digest] = p - } - opened, err := open(ctx, parts) - if err != nil { - return Sent{}, err - } - sent := Sent{Held: opened.Held} - for _, d := range opened.Lacks { - p, ok := byDigest[d] - if !ok { - return sent, fmt.Errorf("the receiver lacks the part %s, which the transfer does not name", d) - } - if err := ctx.Err(); err != nil { - return sent, err - } - if err := check(p); err != nil { - return sent, err - } - n, err := sendPart(ctx, chunk, p) - sent.Chunks += n - if err != nil { - return sent, err - } - sent.Parts = append(sent.Parts, d) - } - return sent, nil -} - -// check reads the part once and refuses it when its bytes are not its digest or its size. -func check(p Part) error { - r, err := p.Open() - if err != nil { - return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - defer r.Close() - h := sha256.New() - n, err := io.Copy(h, r) - if err != nil { - return fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - if got := "sha256:" + hex.EncodeToString(h.Sum(nil)); got != p.Digest || n != p.Size { - return fmt.Errorf("the part %s holds %d bytes of digest %s, not its own %d: nothing of it is sent", p.Digest, n, got, p.Size) - } - return nil -} - -func sendPart(ctx context.Context, chunk Chunk, p Part) (int, error) { - r, err := p.Open() - if err != nil { - return 0, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - defer r.Close() - buf := make([]byte, MaxChunk) - // A chunk is sent once the next read says whether it is the last, so the last is always marked: an empty part is - // one empty chunk, marked last. - var prev []byte - sent := 0 - send := func(data []byte, last bool) error { - if err := chunk(ctx, p.Digest, sent, last, data); err != nil { - return fmt.Errorf("chunk %d of the part %s: %w", sent, p.Digest, err) - } - sent++ - return nil - } - for { - if err := ctx.Err(); err != nil { - return sent, err - } - n, err := io.ReadFull(r, buf) - end := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) - if err != nil && !end { - return sent, fmt.Errorf("the part %s cannot be read: %w", p.Digest, err) - } - if end && n == 0 { - if prev == nil { - prev = []byte{} - } - return sent, send(prev, true) - } - if prev != nil { - if err := send(prev, false); err != nil { - return sent, err - } - } - prev = append([]byte(nil), buf[:n]...) - if end { - return sent, send(prev, true) - } - } -} diff --git a/go/parts/parts_test.go b/go/parts/parts_test.go deleted file mode 100644 index b3ecf06..0000000 --- a/go/parts/parts_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package parts - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "io" - "strings" - "testing" -) - -// A file travels in parts (ADR 0611, ADR 0627 §2, design 51 *Publishing*; SDK-1), both sides in one test, since -// mesh-sdk is over its test budget: the sender's and the holder's (aHolderTakesAFileInParts, receive_test.go). -func TestAFileTravelsInTheParts(t *testing.T) { - t.Run("the sender sends only what the holder lacks", theSenderSendsOnlyWhatTheHolderLacks) - t.Run("a holder takes a file in parts", aHolderTakesAFileInParts) -} - -// Only the parts the holder says it lacks are read and sent, each in chunks of at most MaxChunk in sequence with the -// last marked, so a cut transfer opened again goes on from what the holder holds; a part whose bytes are not its -// digest is refused before any of it travels. -func theSenderSendsOnlyWhatTheHolderLacks(t *testing.T) { - big := bytes.Repeat([]byte("x"), MaxChunk+10) - small, empty := []byte("config"), []byte{} - part := func(b []byte, opened *int) Part { - sum := sha256.Sum256(b) - return Part{Digest: "sha256:" + hex.EncodeToString(sum[:]), Size: int64(len(b)), - Open: func() (io.ReadCloser, error) { *opened++; return io.NopCloser(bytes.NewReader(b)), nil }} - } - type chunk struct { - part string - seq int64 - last bool - data []byte - } - type receiver struct { - open Open - chunk Chunk - } - holder := func(lacks []string, got *[]chunk, asked *[]string) receiver { - return receiver{ - open: func(_ context.Context, ps []Part) (Opened, error) { - *asked = append(*asked, "open") - return Opened{Lacks: lacks, Held: len(lacks) == 0}, nil - }, - chunk: func(_ context.Context, part string, seq int, last bool, data []byte) error { - if len(data) > MaxChunk { - t.Errorf("a chunk is %d bytes, over MaxChunk", len(data)) - } - *got = append(*got, chunk{part, int64(seq), last, append([]byte(nil), data...)}) - return nil - }, - } - } - var opens [3]int - parts := []Part{part(big, &opens[0]), part(small, &opens[1]), part(empty, &opens[2])} - - for _, c := range []struct { - name string - lacks []int - chunks int - err string - }{ - {"a fresh transfer sends every part, the big one in two chunks and the empty one as one", []int{0, 1, 2}, 4, ""}, - {"a cut transfer opened again sends only what the holder lacks", []int{2}, 1, ""}, - {"a digest the holder holds sends nothing", nil, 0, ""}, - } { - opens = [3]int{} - var lacks []string - for _, i := range c.lacks { - lacks = append(lacks, parts[i].Digest) - } - var got []chunk - var asked []string - r := holder(lacks, &got, &asked) - sent, err := Send(context.Background(), r.open, r.chunk, parts) - if err != nil || sent.Chunks != c.chunks || len(got) != c.chunks || len(sent.Parts) != len(c.lacks) { - t.Errorf("%s: sent %+v, %d chunks heard, %v", c.name, sent, len(got), err) - continue - } - for i := range parts { - want := 0 - for _, l := range c.lacks { - if l == i { - want = 2 // read once to check its digest, once to send it - } - } - if opens[i] != want { - t.Errorf("%s: part %d read %d times, want %d", c.name, i, opens[i], want) - } - } - whole := map[string][]byte{} - for i, ch := range got { - whole[ch.part] = append(whole[ch.part], ch.data...) - lastOfPart := i == len(got)-1 || got[i+1].part != ch.part - if ch.last != lastOfPart || (i > 0 && got[i-1].part == ch.part && got[i-1].seq+1 != ch.seq) { - t.Errorf("%s: chunk %d of %s is %d, last %v", c.name, i, ch.part[:12], ch.seq, ch.last) - } - } - for _, l := range c.lacks { - if p := parts[l]; int64(len(whole[p.Digest])) != p.Size { - t.Errorf("%s: part %d arrived as %d bytes of %d", c.name, l, len(whole[p.Digest]), p.Size) - } - } - } - - // A part whose bytes are not its digest travels not at all; a part the holder names that the transfer does not is - // refused, naming it. - liar := part(small, &opens[1]) - liar.Digest = parts[0].Digest - var got []chunk - var asked []string - r := holder([]string{liar.Digest}, &got, &asked) - if _, err := Send(context.Background(), r.open, r.chunk, []Part{liar}); err == nil || len(got) != 0 || !strings.Contains(err.Error(), "nothing of it is sent") { - t.Errorf("a part not its digest: %v, %d chunks sent", err, len(got)) - } - r = holder([]string{"sha256:" + strings.Repeat("f", 64)}, &got, &asked) - if _, err := Send(context.Background(), r.open, r.chunk, parts); err == nil || !strings.Contains(err.Error(), "does not name") { - t.Errorf("a part the transfer does not name: %v", err) - } -} diff --git a/go/parts/receive.go b/go/parts/receive.go deleted file mode 100644 index c77bcfe..0000000 --- a/go/parts/receive.go +++ /dev/null @@ -1,441 +0,0 @@ -package parts - -// The receiving side of a file in parts (novox/hq ADR 0611, ADR 0627 §2, design 51 *Publishing*): a holder opens a -// transfer naming every part, takes the chunks of the parts it lacks into its own objects, checks each whole part's -// hash, drops a transfer left idle when its idle bound ends and says when its objects fill. Like the sender it names no seat, no verb and no -// seat's type: the caller's own shape of an opening rides along as M, its objects and its state are its own, and the -// checks before storing, the storing and the saying of a refusal are the caller's functions. Each holder binds it to -// the seat it holds. - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "slices" - "sort" - "strings" - "time" -) - -// The bounds design 51 gives the receiving side; a Receiver's own fields override them. -const ( - // MaxReceived is the most bytes one chunk may carry when it arrives. - MaxReceived = 1 << 20 - // IdleAfter is how long a transfer with no chunk stays open. - IdleAfter = 24 * time.Hour - // FullPercent is the fill of the objects past which the receiver says they fill. - FullPercent = 80 -) - -// Want is one part an opening names: its digest (sha256:) and its size. -type Want struct { - Digest string `json:"digest"` - Size int64 `json:"size"` -} - -// Transfer is one open transfer, kept in the caller's state; its chunks are objects of the caller's object store. Shape -// is the caller's own body of the opening, kept as it came. -type Transfer[M any] struct { - ID string `json:"id"` - Opener string `json:"opener"` - Parts []Want `json:"parts"` - // Held are the parts whose every chunk arrived and hashed right, or that the holder held at the open; Chunks the - // chunks taken per part. - Held []string `json:"held,omitempty"` - Chunks map[string]int `json:"chunks,omitempty"` - Opened time.Time `json:"opened"` - Last time.Time `json:"last"` - Shape M `json:"shape"` -} - -// Lacks is the digests of the parts the transfer does not hold yet, sorted. -func (t Transfer[M]) Lacks() []string { - var out []string - for _, p := range t.Parts { - if !slices.Contains(t.Held, p.Digest) && !slices.Contains(out, p.Digest) { - out = append(out, p.Digest) - } - } - sort.Strings(out) - return out -} - -// Taken says whether any chunk of the part was taken in this transfer (a part held at the open was not). -func (t Transfer[M]) Taken(digest string) bool { - _, ok := t.Chunks[digest] - return ok -} - -// Objects is the holder's own object store the chunks go into. -type Objects interface { - Put(name string, data []byte) error - Get(name string) ([]byte, error) - Delete(name string) error - // Used is the bytes the store holds. - Used() (uint64, error) -} - -// Transfers is the holder's own state of open transfers, keyed by Transfer.ID. -type Transfers[M any] interface { - Get(id string) (Transfer[M], bool, error) - Put(t Transfer[M]) error - Delete(id string) error - List() ([]Transfer[M], error) -} - -// Received is the receiver's answer to an open or a chunk: the parts still lacked, and whether the transfer ended -// (stored or refused). -type Received struct { - ID string - Lacks []string - Ended bool -} - -// AtCap opens the reason of a refusal for want of room: the objects would pass their cap. A holder tells it from -// the other refusals with RefusedAtCap, so the finding it raises for it can clear once it takes a file again. -const AtCap = "the objects are at their cap" - -// RefusedAtCap says whether a refusal's reason is one for want of room (AtCap). -func RefusedAtCap(reason string) bool { return strings.HasPrefix(reason, AtCap) } - -// Receiver takes transfers in parts into a holder's own objects. -type Receiver[M any] struct { - Objects Objects - Transfers Transfers[M] - // Prefix starts the name of every chunk object, so two receivers can share one object store. - Prefix string - // Cap is the bytes the objects may hold; an opening that would pass it is refused. Required: it is said where - // Bound answers no cap. - Cap uint64 - // Bound reads the cap the objects' binding carries (BusCap): the bus's own bound, which then counts over Cap. - // Optional; an error or a zero falls back to Cap. - Bound func() (uint64, error) - // MaxChunk, Idle and Full override MaxReceived, IdleAfter and FullPercent when set. - MaxChunk int - Idle time.Duration - Full uint64 - // Holds says the holder already holds a part, which then counts as held at the open and is not sent. Optional. - Holds func(t Transfer[M], digest string) bool - // Check is the caller's check of a whole transfer before anything is stored; its error is the refusal's reason. - // Optional. - Check func(t Transfer[M]) error - // Store stores a whole, checked transfer: the parts taken in it by digest (not those held at the open). It says - // the outcome itself; its error is answered as it is. - Store func(t Transfer[M], parts map[string][]byte) error - // Refuse says a refusal by the receiver (the cap, the caller's check, a part it cannot read back). Its own error is - // joined to the refusal's. - Refuse func(t Transfer[M], reason string) error - // Filled is told the objects' fill after every change: used, the cap and whether it is past Full percent. Optional. - Filled func(used, cap uint64, full bool) - // Arm writes a transfer's idle key with its own time to live, once (BusArm over a state declared key-ttl); one - // already there is no fault. The key's end is the event that calls Expired, so an open transfer left with no - // chunk is dropped without a timer. Optional: nil arms nothing, and only DropIdle drops. - Arm func(id string, ttl time.Duration) error - Now func() time.Time -} - -func (r *Receiver[M]) now() time.Time { - if r.Now != nil { - return r.Now().UTC() - } - return time.Now().UTC() -} - -func (r *Receiver[M]) maxChunk() int { - if r.MaxChunk > 0 { - return r.MaxChunk - } - return MaxReceived -} - -func (r *Receiver[M]) idle() time.Duration { - if r.Idle > 0 { - return r.Idle - } - return IdleAfter -} - -// CapNow is the cap the receiver counts against: the binding's (Bound) when it answers one, else Cap. -func (r *Receiver[M]) CapNow() uint64 { - if r.Bound != nil { - if c, err := r.Bound(); err == nil && c > 0 { - return c - } - } - return r.Cap -} - -func (r *Receiver[M]) fullPct() uint64 { - if r.Full > 0 { - return r.Full - } - return FullPercent -} - -// Open opens a transfer for opener, or opens it again: a transfer already open answers what it lacks, so a cut transfer -// goes on from there, and only its opener may open it again. A new transfer that would pass the cap is refused; parts -// the holder holds count as held; when nothing is lacked the transfer ends at once. -func (r *Receiver[M]) Open(id, opener string, parts []Want, shape M) (Received, error) { - switch { - case strings.TrimSpace(id) == "" || strings.ContainsAny(id, "/. *>"): - return Received{}, fmt.Errorf("transfer %q is no transfer id", id) - case len(parts) == 0: - return Received{}, errors.New("no parts") - } - for _, p := range parts { - if !strings.HasPrefix(p.Digest, "sha256:") || len(p.Digest) != len("sha256:")+64 { - return Received{}, fmt.Errorf("part digest %q is no sha256 digest", p.Digest) - } - } - if t, found, err := r.Transfers.Get(id); err != nil { - return Received{}, err - } else if found { - if t.Opener != opener { - return Received{}, fmt.Errorf("refused for %s: transfer %s was opened by another caller", opener, id) - } - return Received{ID: id, Lacks: t.Lacks()}, r.arm(id) - } - t := Transfer[M]{ID: id, Opener: opener, Parts: parts, Chunks: map[string]int{}, Opened: r.now(), Last: r.now(), Shape: shape} - used, err := r.Objects.Used() - if err != nil { - return Received{}, fmt.Errorf("the objects' fill cannot be read: %w", err) - } - var size uint64 - for _, p := range parts { - size += uint64(max(p.Size, 0)) - } - if cap := r.CapNow(); used+size > cap { - return Received{ID: id, Ended: true}, r.refuse(t, fmt.Sprintf(AtCap+" of %d bytes (%d held, %d asked)", cap, used, size)) - } - if r.Holds != nil { - for _, p := range parts { - if r.Holds(t, p.Digest) { - t.Held = append(t.Held, p.Digest) - } - } - } - if err := r.Transfers.Put(t); err != nil { - return Received{}, fmt.Errorf("transfer %s cannot be kept: %w", id, err) - } - r.fill() - if len(t.Lacks()) == 0 { - return Received{ID: id, Ended: true}, r.finish(t) - } - return Received{ID: id, Lacks: t.Lacks()}, r.arm(id) -} - -// arm bounds an open transfer by its idle key. A key that cannot be written fails the open loudly (the transfer is -// kept, so the opener's next open of it answers what is held and arms it again). -func (r *Receiver[M]) arm(id string) error { - if r.Arm == nil { - return nil - } - if err := r.Arm(id, r.idle()); err != nil { - return fmt.Errorf("transfer %s is open but its idle bound is not kept, so it would never be dropped: %w", id, err) - } - return nil -} - -// Take takes one chunk of a part from the transfer's opener: chunks of a part arrive in sequence from 0, the last -// marked; the whole part must hash to its digest, else its chunks are dropped and the part is lacked again. Belongs, -// when given, refuses a transfer the caller's own shape says is not this call's (another seat's, say). When the last -// lacked part arrives the transfer ends: the caller's check, then its store. -func (r *Receiver[M]) Take(id, opener, digest string, sequence int, last bool, data []byte, belongs func(Transfer[M]) error) (Received, error) { - t, found, err := r.Transfers.Get(id) - switch { - case err != nil: - return Received{}, err - case !found: - return Received{}, fmt.Errorf("transfer %s is not open (dropped after %s with no chunk, or never opened)", id, r.idle()) - case t.Opener != opener: - return Received{}, fmt.Errorf("refused for %s: transfer %s was opened by another caller", opener, id) - } - if belongs != nil { - if err := belongs(t); err != nil { - return Received{}, err - } - } - switch { - case len(data) > r.maxChunk(): - return Received{}, fmt.Errorf("a chunk of %d bytes is over the %d a chunk may carry", len(data), r.maxChunk()) - case !slices.ContainsFunc(t.Parts, func(p Want) bool { return p.Digest == digest }): - return Received{}, fmt.Errorf("transfer %s names no part %s", id, digest) - case slices.Contains(t.Held, digest): - return Received{ID: id, Lacks: t.Lacks()}, nil - case sequence != t.Chunks[digest]: - return Received{}, fmt.Errorf("part %s of transfer %s waits for chunk %d, not %d", digest, id, t.Chunks[digest], sequence) - } - if err := r.Objects.Put(r.chunkName(id, digest, sequence), data); err != nil { - return Received{}, fmt.Errorf("the objects did not take the chunk: %w", err) - } - // A transfer read back from a key-value state has no Chunks until one was taken: the empty map is kept omitted. - if t.Chunks == nil { - t.Chunks = map[string]int{} - } - t.Chunks[digest] = sequence + 1 - t.Last = r.now() - if last { - whole, err := r.part(t, digest) - if err != nil { - return Received{}, err - } - if got := "sha256:" + sum(whole); got != digest { - r.dropChunks(t, digest) - delete(t.Chunks, digest) - _ = r.Transfers.Put(t) - r.fill() - return Received{}, fmt.Errorf("refused: the bytes of part %s hash to %s; send the part again", digest, got) - } - t.Held = append(t.Held, digest) - } - if err := r.Transfers.Put(t); err != nil { - return Received{}, err - } - r.fill() - if len(t.Lacks()) == 0 { - return Received{ID: id, Ended: true}, r.finish(t) - } - return Received{ID: id, Lacks: t.Lacks()}, nil -} - -// finish checks and stores a whole transfer; its chunks and its key go either way. -func (r *Receiver[M]) finish(t Transfer[M]) error { - defer func() { - for _, p := range t.Parts { - r.dropChunks(t, p.Digest) - } - _ = r.Transfers.Delete(t.ID) - r.fill() - }() - if r.Check != nil { - if err := r.Check(t); err != nil { - return r.refuse(t, err.Error()) - } - } - parts := map[string][]byte{} - for _, p := range t.Parts { - if !t.Taken(p.Digest) { - continue // held by the holder already - } - b, err := r.part(t, p.Digest) - if err != nil { - return r.refuse(t, "part "+p.Digest+" cannot be read from the objects: "+err.Error()) - } - parts[p.Digest] = b - } - return r.Store(t, parts) -} - -func (r *Receiver[M]) refuse(t Transfer[M], reason string) error { - err := errors.New("refused: " + reason) - if r.Refuse != nil { - if serr := r.Refuse(t, reason); serr != nil { - return fmt.Errorf("%w; and the refusal could not be said: %w", err, serr) - } - } - return err -} - -func (r *Receiver[M]) part(t Transfer[M], digest string) ([]byte, error) { - var buf bytes.Buffer - for seq := 0; seq < t.Chunks[digest]; seq++ { - b, err := r.Objects.Get(r.chunkName(t.ID, digest, seq)) - if err != nil { - return nil, err - } - buf.Write(b) - } - return buf.Bytes(), nil -} - -func (r *Receiver[M]) dropChunks(t Transfer[M], digest string) { - for seq := 0; seq < t.Chunks[digest]; seq++ { - _ = r.Objects.Delete(r.chunkName(t.ID, digest, seq)) - } -} - -func (r *Receiver[M]) chunkName(id, digest string, seq int) string { - return fmt.Sprintf("%s%s/%s/%d", r.Prefix, id, strings.TrimPrefix(digest, "sha256:"), seq) -} - -// fill tells Filled the objects' fill. -func (r *Receiver[M]) fill() { - cap := r.CapNow() - if r.Filled == nil || cap == 0 { - return - } - used, err := r.Objects.Used() - if err != nil { - return - } - r.Filled(used, cap, used*100 >= cap*r.fullPct()) -} - -// Drop drops a transfer, by hand or because it was idle: its chunks and its key. -func (r *Receiver[M]) Drop(id string) (Transfer[M], error) { - t, found, err := r.Transfers.Get(id) - if err != nil { - return Transfer[M]{}, err - } - if !found { - return Transfer[M]{}, fmt.Errorf("transfer %s is not open", id) - } - for _, p := range t.Parts { - r.dropChunks(t, p.Digest) - } - if err := r.Transfers.Delete(id); err != nil { - return Transfer[M]{}, err - } - r.fill() - return t, nil -} - -// Expired is told that a transfer's idle key ended (the bus's marker, FollowIdle): a transfer with no chunk for Idle -// is dropped with its chunks; one that took a chunk since is armed again for the rest of its bound; one already gone -// is nothing to do. It answers whether it dropped the transfer. -func (r *Receiver[M]) Expired(id string) (bool, error) { - t, found, err := r.Transfers.Get(id) - if err != nil { - return false, fmt.Errorf("transfer %s's idle bound ended, but it cannot be read: %w", id, err) - } - if !found { - return false, nil - } - idle := r.now().Sub(t.Last) - if idle >= r.idle() { - if _, err := r.Drop(id); err != nil { - return false, fmt.Errorf("transfer %s had no chunk for %s and is not dropped: %w", id, r.idle(), err) - } - return true, nil - } - if r.Arm == nil { - return false, nil - } - return false, r.Arm(id, max(r.idle()-idle, time.Second)) -} - -// DropIdle drops every transfer with no chunk for Idle and answers their ids: a look by hand (a listing tool). The -// drop itself follows each transfer's idle key (Arm, Expired), never a timer. -func (r *Receiver[M]) DropIdle() ([]string, error) { - all, err := r.Transfers.List() - if err != nil { - return nil, err - } - var out []string - for _, t := range all { - if r.now().Sub(t.Last) >= r.idle() { - if _, err := r.Drop(t.ID); err == nil { - out = append(out, t.ID) - } - } - } - sort.Strings(out) - return out, nil -} - -func sum(b []byte) string { - s := sha256.Sum256(b) - return hex.EncodeToString(s[:]) -} diff --git a/go/parts/receive_test.go b/go/parts/receive_test.go deleted file mode 100644 index d4ee5df..0000000 --- a/go/parts/receive_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package parts - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "reflect" - "strings" - "testing" - "time" -) - -type memObjects map[string][]byte - -func (m memObjects) Put(n string, d []byte) error { m[n] = append([]byte(nil), d...); return nil } -func (m memObjects) Get(n string) ([]byte, error) { - if d, ok := m[n]; ok { - return d, nil - } - return nil, errors.New("no object " + n) -} -func (m memObjects) Delete(n string) error { delete(m, n); return nil } -func (m memObjects) Used() (uint64, error) { - var u uint64 - for _, d := range m { - u += uint64(len(d)) - } - return u, nil -} - -// memTransfers keeps each transfer as the bus keeps it, its JSON, so every case reads back what a key-value state -// gives (BusTransfers): an empty map kept with omitempty comes back nil. -type memTransfers[M any] map[string][]byte - -func (m memTransfers[M]) Get(id string) (Transfer[M], bool, error) { - var t Transfer[M] - raw, ok := m[id] - if !ok { - return t, false, nil - } - return t, true, json.Unmarshal(raw, &t) -} -func (m memTransfers[M]) Put(t Transfer[M]) error { - raw, err := json.Marshal(t) - m[t.ID] = raw - return err -} -func (m memTransfers[M]) Delete(id string) error { delete(m, id); return nil } -func (m memTransfers[M]) List() ([]Transfer[M], error) { - var out []Transfer[M] - for id := range m { - t, _, err := m.Get(id) - if err != nil { - return nil, err - } - out = append(out, t) - } - return out, nil -} - -// Two shapes of an opening, as two different seats' holders would keep them. -type packageShape struct{ Name, Version string } -type imageShape struct { - Repository string - Layers int -} - -type ended struct { - id, outcome string - parts map[string][]byte -} - -func digestOf(b []byte) string { s := sha256.Sum256(b); return "sha256:" + hex.EncodeToString(s[:]) } - -func receiverOf[M any](objects memObjects, prefix string, now *time.Time, log *[]ended, check func(Transfer[M]) error) *Receiver[M] { - return &Receiver[M]{Objects: objects, Transfers: memTransfers[M]{}, Prefix: prefix, Cap: 1 << 20, MaxChunk: 1000, - Check: check, Now: func() time.Time { return *now }, - Store: func(t Transfer[M], parts map[string][]byte) error { - *log = append(*log, ended{t.ID, "stored", parts}) - return nil - }, - Refuse: func(t Transfer[M], reason string) error { - *log = append(*log, ended{t.ID, "refused: " + reason, nil}) - return nil - }} -} - -// aHolderTakesAFileInParts (a case of TestAFileTravelsInTheParts) runs the receiving side for two holders with two shapes and two prefixes on one object -// store (ADR 0627 §2): neither names a seat; the shapes, the checks and the saying are each caller's own. -func aHolderTakesAFileInParts(t *testing.T) { - objects := memObjects{} - now := time.Unix(1000, 0) - var pkgLog, imgLog []ended - pkg := receiverOf(objects, "npm/", &now, &pkgLog, func(t Transfer[packageShape]) error { - if t.Shape.Version == "" { - return errors.New("the builder did not number it") - } - return nil - }) - img := receiverOf[imageShape](objects, "image/", &now, &imgLog, nil) - blob := []byte(strings.Repeat("a", 1500)) - part := Want{Digest: digestOf(blob), Size: int64(len(blob))} - layer := []byte("layer") - held := Want{Digest: digestOf(layer), Size: int64(len(layer))} - img.Holds = func(_ Transfer[imageShape], d string) bool { return d == held.Digest } - - t.Run("two holders, one transfer id, two shapes", func(t *testing.T) { - a, err := pkg.Open("t1", "builder", []Want{part}, packageShape{"m/program", "0.1.4"}) - if err != nil || !reflect.DeepEqual(a.Lacks, []string{part.Digest}) { - t.Fatalf("pkg open = %+v, %v", a, err) - } - b, err := img.Open("t1", "builder", []Want{part, held}, imageShape{"m/app", 2}) - if err != nil || !reflect.DeepEqual(b.Lacks, []string{part.Digest}) { - t.Fatalf("img open = %+v, %v: a part the holder holds is not lacked", b, err) - } - for _, r := range []func(int, bool, []byte) (Received, error){ - func(s int, l bool, d []byte) (Received, error) { - return pkg.Take("t1", "builder", part.Digest, s, l, d, nil) - }, - func(s int, l bool, d []byte) (Received, error) { - return img.Take("t1", "builder", part.Digest, s, l, d, nil) - }, - } { - if _, err := r(0, false, blob[:1000]); err != nil { - t.Fatal(err) - } - } - // A cut transfer opened again goes on from what is held; another caller may not. - if a, _ := pkg.Open("t1", "builder", []Want{part}, packageShape{}); !reflect.DeepEqual(a.Lacks, []string{part.Digest}) { - t.Errorf("reopen = %+v", a) - } - if _, err := pkg.Open("t1", "other", []Want{part}, packageShape{}); err == nil || !strings.Contains(err.Error(), "another caller") { - t.Errorf("reopen by another = %v", err) - } - if _, err := pkg.Take("t1", "builder", part.Digest, 0, false, blob[:1000], nil); err == nil || !strings.Contains(err.Error(), "waits for chunk 1") { - t.Errorf("a chunk out of sequence = %v", err) - } - a, err = pkg.Take("t1", "builder", part.Digest, 1, true, blob[1000:], nil) - if err != nil || !a.Ended || len(pkgLog) != 1 || pkgLog[0].outcome != "stored" || string(pkgLog[0].parts[part.Digest]) != string(blob) { - t.Fatalf("pkg end = %+v, %v, %+v", a, err, pkgLog) - } - // The image holder's chunks stood apart under its own prefix and are still there. - if _, ok := objects["image/t1/"+strings.TrimPrefix(part.Digest, "sha256:")+"/0"]; !ok || len(objects) != 1 { - t.Fatalf("objects after pkg's end = %v keys, want only the image holder's chunk", len(objects)) - } - b, err = img.Take("t1", "builder", part.Digest, 1, true, blob[1000:], func(t Transfer[imageShape]) error { return nil }) - if err != nil || !b.Ended || len(imgLog) != 1 || len(imgLog[0].parts) != 1 { - t.Fatalf("img end = %+v, %v, %+v: only the part taken is stored", b, err, imgLog) - } - if len(objects) != 0 { - t.Errorf("objects after both ends = %d, want none", len(objects)) - } - }) - - t.Run("refusals", func(t *testing.T) { - for _, c := range []struct { - name, want string - do func() error - }{ - {"no transfer id", "no transfer id", func() error { _, err := pkg.Open("a/b", "builder", []Want{part}, packageShape{}); return err }}, - {"no sha256 digest", "no sha256 digest", func() error { - _, err := pkg.Open("t", "builder", []Want{{Digest: "md5:x"}}, packageShape{}) - return err - }}, - {"another caller's chunk", "another caller", func() error { - _, _ = pkg.Open("t2", "builder", []Want{part}, packageShape{"m", "1"}) - _, err := pkg.Take("t2", "other", part.Digest, 0, true, blob, nil) - return err - }}, - {"a chunk over the bound", "over the 1000", func() error { - _, err := pkg.Take("t2", "builder", part.Digest, 0, false, make([]byte, 1001), nil) - return err - }}, - {"a part the transfer does not name", "names no part", func() error { - _, err := pkg.Take("t2", "builder", held.Digest, 0, true, layer, nil) - return err - }}, - {"the caller's own belongs", "not this seat's", func() error { - _, err := pkg.Take("t2", "builder", part.Digest, 0, true, blob, func(Transfer[packageShape]) error { return errors.New("not this seat's") }) - return err - }}, - {"bytes that do not hash", "send the part again", func() error { - _, err := pkg.Take("t2", "builder", part.Digest, 0, true, []byte("other"), nil) - return err - }}, - {"the caller's check", "refused: the builder did not number it", func() error { - _, _ = pkg.Open("t3", "builder", []Want{part}, packageShape{Name: "m"}) - _, _ = pkg.Take("t3", "builder", part.Digest, 0, false, blob[:1000], nil) - _, err := pkg.Take("t3", "builder", part.Digest, 1, true, blob[1000:], nil) - return err - }}, - {"the cap", "at their cap", func() error { - pkg.Cap = 100 - defer func() { pkg.Cap = 1 << 20 }() - _, err := pkg.Open("t4", "builder", []Want{part}, packageShape{}) - return err - }}, - } { - if err := c.do(); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: err = %v, want %q", c.name, err, c.want) - } - } - if got := pkgLog[len(pkgLog)-1].outcome; !strings.HasPrefix(got, "refused: the objects are at their cap") { - t.Errorf("the cap's refusal said %q", got) - } - if !RefusedAtCap(strings.TrimPrefix(pkgLog[len(pkgLog)-1].outcome, "refused: ")) || RefusedAtCap(strings.TrimPrefix(pkgLog[len(pkgLog)-2].outcome, "refused: ")) { - t.Errorf("RefusedAtCap tells the cap's refusal from the check's: %q, %q", pkgLog[len(pkgLog)-1].outcome, pkgLog[len(pkgLog)-2].outcome) - } - if got := pkgLog[len(pkgLog)-2].outcome; got != "refused: the builder did not number it" { - t.Errorf("the check's refusal said %q", got) - } - // A part that did not hash is lacked again, its chunk gone. - if a, _ := pkg.Open("t2", "builder", []Want{part}, packageShape{}); !reflect.DeepEqual(a.Lacks, []string{part.Digest}) { - t.Errorf("after a bad hash = %+v", a) - } - }) - - t.Run("idle drop and the fill", func(t *testing.T) { - var fills []bool - pkg.Filled = func(used, cap uint64, full bool) { fills = append(fills, full) } - pkg.Cap, pkg.Full = 1500, 60 - defer func() { pkg.Cap, pkg.Full, pkg.Filled = 1<<20, 0, nil }() - _, _ = pkg.Open("t5", "builder", []Want{part}, packageShape{"m", "1"}) - _, _ = pkg.Take("t5", "builder", part.Digest, 0, false, blob[:1000], nil) - if len(fills) == 0 || !fills[len(fills)-1] { - t.Errorf("fills = %v, want full at 1000 of 1500 bytes (60 percent)", fills) - } - now = now.Add(IdleAfter) - dropped, err := pkg.DropIdle() - if err != nil || !reflect.DeepEqual(dropped, []string{"t2", "t5"}) { - t.Errorf("DropIdle = %v, %v", dropped, err) - } - if fills[len(fills)-1] || len(objects) != 0 { - t.Errorf("after the drop: full %v, %d objects", fills[len(fills)-1], len(objects)) - } - if _, err := pkg.Take("t5", "builder", part.Digest, 1, true, blob[1000:], nil); err == nil || !strings.Contains(err.Error(), "not open") { - t.Errorf("a chunk after the drop = %v", err) - } - }) - - // An open transfer is bounded by its idle key (Arm); the key's end (Expired) drops it idle, arms it again for the - // rest when it took a chunk since, and leaves a gone one; a key not written fails the open loudly. The cap the - // binding carries (Bound) counts over Cap. - t.Run("the idle key and the binding's cap", func(t *testing.T) { - armed := map[string]time.Duration{} - pkg.Arm = func(id string, ttl time.Duration) error { armed[id] = ttl; return nil } - defer func() { pkg.Arm, pkg.Bound = nil, nil }() - if _, err := pkg.Open("t6", "builder", []Want{part}, packageShape{"m", "1"}); err != nil || armed["t6"] != IdleAfter { - t.Fatalf("open = %v, armed %v; want t6's idle key for %s", err, armed, IdleAfter) - } - now = now.Add(time.Hour) - _, _ = pkg.Take("t6", "builder", part.Digest, 0, false, blob[:1000], nil) - now = now.Add(IdleAfter - time.Hour) - if dropped, err := pkg.Expired("t6"); err != nil || dropped || armed["t6"] != time.Hour { - t.Errorf("expired after a chunk = %v, %v, armed %v; want armed again for the hour left", dropped, err, armed["t6"]) - } - now = now.Add(time.Hour) - if dropped, err := pkg.Expired("t6"); err != nil || !dropped || len(objects) != 0 { - t.Errorf("expired idle = %v, %v, %d objects; want dropped with its chunks", dropped, err, len(objects)) - } - if dropped, err := pkg.Expired("t6"); err != nil || dropped { - t.Errorf("expired once gone = %v, %v; want nothing to do", dropped, err) - } - pkg.Arm = func(string, time.Duration) error { return errors.New("bus down") } - if _, err := pkg.Open("t7", "builder", []Want{part}, packageShape{"m", "1"}); err == nil || !strings.Contains(err.Error(), "never be dropped") { - t.Errorf("open with no idle key = %v, want a loud failure", err) - } - pkg.Bound = func() (uint64, error) { return 100, nil } - if _, err := pkg.Open("t8", "builder", []Want{part}, packageShape{}); err == nil || !strings.Contains(err.Error(), "cap of 100 bytes") { - t.Errorf("open past the binding's cap = %v, want refused at 100 bytes", err) - } - if pkg.Bound = func() (uint64, error) { return 0, errors.New("no binding") }; pkg.CapNow() != pkg.Cap { - t.Errorf("CapNow with no binding read = %d, want Cap %d", pkg.CapNow(), pkg.Cap) - } - pkg.Bound = nil - // At the start of the idle watch every open transfer is looked at (expireOpen): one whose key ended while the - // bundle was down is dropped, one still within its bound is armed again for the rest. - pkg.Arm = func(id string, ttl time.Duration) error { armed[id] = ttl; return nil } - for id := range pkg.Transfers.(memTransfers[packageShape]) { - _, _ = pkg.Drop(id) - } - _, _ = pkg.Open("t9", "builder", []Want{part}, packageShape{"m", "1"}) - now = now.Add(IdleAfter + time.Hour) - _, _ = pkg.Open("t10", "builder", []Want{part}, packageShape{"m", "1"}) - now = now.Add(time.Hour) - var dropped []string - expireOpen("idle", IDs(pkg.Transfers), pkg.Expired, func(id string, d bool, err error) { - if err != nil { - t.Errorf("start's look at %s: %v", id, err) - } - if d { - dropped = append(dropped, id) - } - }) - if !reflect.DeepEqual(dropped, []string{"t9"}) || armed["t10"] != IdleAfter-time.Hour { - t.Errorf("at the start: dropped %v, t10 armed for %s; want t9 dropped and t10 armed for %s", dropped, armed["t10"], IdleAfter-time.Hour) - } - var said error - expireOpen("idle", func() ([]string, error) { return nil, errors.New("bus down") }, pkg.Expired, func(_ string, _ bool, err error) { said = err }) - if said == nil || !strings.Contains(said.Error(), "bus down") { - t.Errorf("start's look with no list said %v, want the failure", said) - } - }) -} -- 2.54.0 From 989a3c66704521e6ca0775aab7faa923177ad065 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:37:59 +0100 Subject: [PATCH 115/144] Delete go/provider and its harness: the provisioner's client holds it, no importer left on any join head (S-7, B9f; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/packages_list_test.go | 2 +- go/provider/consumers.go | 72 -- go/provider/consumers_test.go | 68 -- go/provider/ended_test.go | 20 - go/provider/harness/ctx_test.go | 5 - go/provider/harness/early_test.go | 62 -- go/provider/harness/grants_moved_test.go | 132 --- go/provider/harness/harness.go | 572 ------------ go/provider/harness/harness_test.go | 164 ---- go/provider/harness/moved.go | 312 ------- go/provider/harness/moved_test.go | 476 ---------- go/provider/harness/retirement.go | 626 -------------- go/provider/harness/retirement_test.go | 596 ------------- go/provider/harness/standing_test.go | 205 ----- go/provider/provider.go | 858 ------------------ go/provider/provider_test.go | 1002 ---------------------- go/provider/statebus_test.go | 154 ---- 17 files changed, 1 insertion(+), 5325 deletions(-) delete mode 100644 go/provider/consumers.go delete mode 100644 go/provider/consumers_test.go delete mode 100644 go/provider/ended_test.go delete mode 100644 go/provider/harness/ctx_test.go delete mode 100644 go/provider/harness/early_test.go delete mode 100644 go/provider/harness/grants_moved_test.go delete mode 100644 go/provider/harness/harness.go delete mode 100644 go/provider/harness/harness_test.go delete mode 100644 go/provider/harness/moved.go delete mode 100644 go/provider/harness/moved_test.go delete mode 100644 go/provider/harness/retirement.go delete mode 100644 go/provider/harness/retirement_test.go delete mode 100644 go/provider/harness/standing_test.go delete mode 100644 go/provider/provider.go delete mode 100644 go/provider/provider_test.go delete mode 100644 go/provider/statebus_test.go diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 165ca36..201e472 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -25,7 +25,7 @@ import ( var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", - "objectlink", "outward", "overlay", "pernode", "process", "provider", "provider/harness", "provisions", + "objectlink", "outward", "overlay", "pernode", "process", "provisions", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } diff --git a/go/provider/consumers.go b/go/provider/consumers.go deleted file mode 100644 index 326d0a9..0000000 --- a/go/provider/consumers.go +++ /dev/null @@ -1,72 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" -) - -// The provider's consumers' credentials, from the file its machine's provisioner client fills (novox/hq ADR 0373, -// issue 679). -// -// **The provider never asks the vault.** A provider in the tool runner has no account of its own, and the vault -// hands a secret only to a node's provisioner client or to a module under an account of its own (ADR 0349 clause -// 11, ADR 0370 clause 2). So the provider's template names `${consumers:}`, and its machine's client -// fills that file with one entry per consumer bound to it for that provision, each credential asked of the vault in -// the client's own name; the file is the provider's alone (mode 0600, owned). ConsumersFile is that file as the -// helper's Vault: it reads the file on each ask, so a consumer that binds or a credential the vault remakes reaches -// the provider at its next pass. - -// ConsumerCredential is one entry of the file: the consumer, the vault's name of its credential, and its value. -type ConsumerCredential struct { - // Consumer and Module are the consumer's machine and module, Local its local name for the provision, if any. - Consumer string `json:"consumer"` - Module string `json:"module"` - Local string `json:"local,omitempty"` - // Credential is the vault's name of the consumer's credential, PairName's. - Credential string `json:"credential"` - // Value is the credential itself. - Value string `json:"value"` -} - -// ConsumersFile answers a credential from the files the provider machine's client fills: one per provision whose -// template names `${consumers:}`, each a JSON list of ConsumerCredential. -type ConsumersFile struct { - Paths []string -} - -// Value is the credential named name, from the first file carrying it. A file not there yet is passed over; one not -// in the client's form is an error, never read as no consumers. -func (f ConsumersFile) Value(_ context.Context, name string) ([]byte, error) { - var missing []string - for _, at := range f.Paths { - raw, err := os.ReadFile(at) - if errors.Is(err, fs.ErrNotExist) { - missing = append(missing, at) - continue - } - if err != nil { - return nil, fmt.Errorf("the consumers' file %s: %w", at, err) - } - var entries []ConsumerCredential - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, fmt.Errorf("the consumers' file %s is not the provisioner client's list of consumers", at) - } - for _, e := range entries { - if e.Credential == name { - if e.Value == "" { - return nil, fmt.Errorf("the consumers' file %s carries %s with no value", at, name) - } - return []byte(e.Value), nil - } - } - } - if len(missing) == len(f.Paths) { - return nil, fmt.Errorf("%s: no consumers' file is filled yet (%v)", name, f.Paths) - } - return nil, fmt.Errorf("%s is in no consumers' file: the vault holds none for it yet, or its machine's client "+ - "has not filled it", name) -} diff --git a/go/provider/consumers_test.go b/go/provider/consumers_test.go deleted file mode 100644 index aaf4aa8..0000000 --- a/go/provider/consumers_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package provider - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" -) - -// The provider reads its consumers' credentials only from the file its machine's provisioner client fills (novox/hq -// ADR 0373 clause 3, issue 679): one entry per consumer, naming the consumer and the vault's name for its credential, -// and carrying the value. The helper is wired to it as its Vault and asks nothing of the vault itself. -func TestTheConsumersFileAnswersEachConsumersCredential(t *testing.T) { - dir := t.TempDir() - at := filepath.Join(dir, "consumers.json") - file := `[{"consumer":"ace","module":"letta","credential":"pair/novox/postgres-database/ace/letta","value":"s3cret"}, - {"consumer":"g14","module":"gitea","local":"cache","credential":"pair/novox/postgres-database/g14/gitea/cache","value":"other"}]` - if err := os.WriteFile(at, []byte(file), 0o600); err != nil { - t.Fatal(err) - } - v := ConsumersFile{Paths: []string{filepath.Join(dir, "absent.json"), at}} - got, err := v.Value(context.Background(), PairName("novox", "postgres-database", "ace", "letta", "")) - if err != nil || string(got) != "s3cret" { - t.Fatalf("ace's letta: %q, %v; want s3cret", got, err) - } - got, err = v.Value(context.Background(), PairName("novox", "postgres-database", "g14", "gitea", "cache")) - if err != nil || string(got) != "other" { - t.Fatalf("g14's gitea under cache: %q, %v; want other", got, err) - } - // A consumer the file does not carry is waited for, and never told of a login its provider has not made. - _, err = v.Value(context.Background(), PairName("novox", "postgres-database", "shanks", "photos", "")) - if err == nil || !strings.Contains(err.Error(), "pair/novox/postgres-database/shanks/photos") { - t.Fatalf("a consumer the file does not carry: %v", err) - } - if strings.Contains(err.Error(), "s3cret") || strings.Contains(err.Error(), "other") { - t.Fatalf("an error names a credential: %v", err) - } - - // Wired as the helper's Vault, the file is what makes each consumer's login. - bus := newStandIn() - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - var made []string - p := New(Options{Node: "novox", Module: "postgres", Bus: bus, Vault: v, - Offers: map[string]Offer{"postgres-database": {Serves: map[string]any{"port": 5432}}}, - Make: func(_ context.Context, c Consumer) error { - made = append(made, c.Entry.User+"="+string(c.Credential)) - return nil - }, Logf: func(string, ...any) {}}) - if _, err := p.Pass(context.Background()); err != nil { - t.Fatal(err) - } - if len(made) != 1 || made[0] != "mesh_ace_letta=s3cret" { - t.Fatalf("made %v, want ace's letta under the file's credential", made) - } -} - -// A file that is not the client's form is said, never read as no consumers. -func TestAConsumersFileNotInItsFormIsRefused(t *testing.T) { - at := filepath.Join(t.TempDir(), "consumers.json") - if err := os.WriteFile(at, []byte(`{"not": "a list"}`), 0o600); err != nil { - t.Fatal(err) - } - _, err := ConsumersFile{Paths: []string{at}}.Value(context.Background(), "pair/novox/p/ace/m") - if err == nil || !strings.Contains(err.Error(), at) { - t.Fatalf("a file not in the client's form: %v", err) - } -} diff --git a/go/provider/ended_test.go b/go/provider/ended_test.go deleted file mode 100644 index 45d6f28..0000000 --- a/go/provider/ended_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package provider - -import ( - "testing" -) - -func TestStandingIsFalseOnlyForAnEndedOrPendingValue(t *testing.T) { - for raw, want := range map[string]bool{ - `{"node":"a","module":"b"}`: true, - `{"node":"a","module":"b","ended":"","state":"","x":{"y":1}}`: true, - `{"node":"a","module":"b","ended":null}`: true, - `{"node":"a","module":"b","build":"b1","commit":"c","rollout":"r","since":"t","artifact":{"name":"n"}}`: true, - `{"node":"a","module":"b","ended":"2026-10-18T01:00:00Z"}`: false, - `{"node":"a","module":"b","state":"pending"}`: false, - } { - if got := Standing([]byte(raw)); got != want { - t.Errorf("%s: %v, want %v", raw, got, want) - } - } -} diff --git a/go/provider/harness/ctx_test.go b/go/provider/harness/ctx_test.go deleted file mode 100644 index 736ef25..0000000 --- a/go/provider/harness/ctx_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package harness - -import "context" - -var ctx = context.Background() diff --git a/go/provider/harness/early_test.go b/go/provider/harness/early_test.go deleted file mode 100644 index bb75652..0000000 --- a/go/provider/harness/early_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package harness - -// The moved path started before its runtime answers asks again until it does (issue 943). - -import ( - "context" - "errors" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/provider" -) - -// earlyBus refuses its first watches as the runtime does before Serve runs, then answers as the bucket bus does. -type earlyBus struct { - *bucketBus - refuse int -} - -func (b *earlyBus) Watch(ctx context.Context, bucket string, handle func(string, []byte)) error { - if b.refuse > 0 { - b.refuse-- - return errors.New("before Serve: a bundle reaches the bus through the runtime that launched it") - } - return b.bucketBus.Watch(ctx, bucket, handle) -} - -func TestAMovedPathStartedTooEarlyAsksAgainUntilTheRuntimeAnswers(t *testing.T) { - w := newMovedWorld(t, map[[2]string]string{}) - w.run.FirstRetry = time.Millisecond - w.run.loaded = false - bus := &earlyBus{bucketBus: w.bus, refuse: 3} - p := provider.New(provider.Options{Node: "novox", Module: "postgres", Bus: w.bus, - Vault: provider.ConsumersFile{Paths: []string{w.file}}, Make: w.run.Make, Gone: w.run.Gone, - Every: time.Hour, Logf: func(string, ...any) {}}) - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- w.run.Run(ctx, p, bus) }() - deadline := time.After(time.Second) - for { - w.run.mu.Lock() - loaded := w.run.loaded - w.run.mu.Unlock() - if loaded { - break - } - select { - case err := <-done: - cancel() - t.Fatalf("Run stopped before the runtime answered: %v", err) - case <-deadline: - cancel() - t.Fatal("the plans were never watched") - case <-time.After(time.Millisecond): - } - } - cancel() - <-done - if bus.refuse != 0 { - t.Fatalf("%d refusals left", bus.refuse) - } -} diff --git a/go/provider/harness/grants_moved_test.go b/go/provider/harness/grants_moved_test.go deleted file mode 100644 index cf43517..0000000 --- a/go/provider/harness/grants_moved_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package harness - -// postgres handed to the provisioner still serves the consumers the controller's grant file gives (issue 937): a -// consumer not moved has no plan naming postgres, only its grant, until R1 (issue 682). - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" - "time" -) - -// granting gives the moved world a grant file, as the controller writes it for the consumers not moved. -func (w *movedWorld) granting(given ...map[string]any) { - if w.h.Receives == "" { - w.h.Receives = filepath.Join(w.t.TempDir(), "mesh.json") - } - dir := filepath.Dir(w.h.Receives) - for _, g := range given { - secret := filepath.Join(dir, g["as"].(string)+".secret") - if err := os.WriteFile(secret, []byte("pw-"+g["as"].(string)+"\n"), 0o600); err != nil { - w.t.Fatal(err) - } - g["secret"] = secret - } - if given == nil { - given = []map[string]any{} - } - raw, _ := json.Marshal(map[string]any{"requirement": ProvisionName, "given": given}) - if err := os.WriteFile(w.h.Receives, raw, 0o600); err != nil { - w.t.Fatal(err) - } -} - -// settled is enough passes and time for a retirement to be stable. -func (w *movedWorld) settled() { - for i := 0; i < 8; i++ { - w.pass() - } - w.now = w.now.Add(15 * time.Minute) - w.pass() - w.pass() -} - -// The logins the grant file gives are asked for: made as the grant path makes them, never offered for retirement, -// beside a moved consumer the helper makes from its plan. -func testAConsumerOnlyTheGrantFileGivesIsAskedForAndMade(t *testing.T) { - bound, logins := bindings(1) - w := newMovedWorld(t, bound) - w.a.inv.Active = []string{"mesh_ace_baserow", "mesh_ace_letta", logins[0]} - w.bus.bind("ace", "app1", nil) - w.granting( - map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}}, - map[string]any{"as": "mesh_ace_letta", "node": "ace", - "values": map[string]any{"name": "letta", "extensions": []any{"vector"}}}, - ) - w.settled() - if len(w.a.removed) != 0 || w.h.r.waiting != nil { - t.Fatalf("retired %v, waiting %+v: the grant file's logins were counted unasked", w.a.removed, w.h.r.waiting) - } - made := map[string]Provision{} - for _, c := range w.a.created { - made[c.As] = c - } - letta := made["mesh_ace_letta"] - if letta.Password != "pw-mesh_ace_letta" || letta.Consumer != "ace" || - !reflect.DeepEqual(letta.Values, map[string]any{"name": "letta", "extensions": []any{"vector"}}) { - t.Fatalf("made %+v for letta, as its grant gives it", letta) - } - if _, ok := made["mesh_ace_baserow"]; !ok { - t.Fatalf("baserow not made: %+v", w.a.created) - } - if _, ok := made[logins[0]]; !ok { - t.Fatalf("the moved consumer not made: %+v", w.a.created) - } -} - -// A login both a plan and the grant file name is the plan's: made once, with the plan's credential. -func testALoginAPlanNamesIsNotMadeFromTheGrantFile(t *testing.T) { - bound, logins := bindings(1) - w := newMovedWorld(t, bound) - w.bus.bind("ace", "app1", nil) - w.granting(map[string]any{"as": logins[0], "node": "ace", "values": map[string]any{"name": "app1"}}) - w.pass() - w.pass() - for _, c := range w.a.created { - if c.As == logins[0] && c.Password != "pw-app1" { - t.Fatalf("made %s with the grant's password %q, not the plan's", c.As, c.Password) - } - } -} - -// An unreadable grant file is not nobody asking (issue 241): nothing is retired while it cannot be read. -func testAnUnreadableGrantFileRetiresNothing(t *testing.T) { - bound, logins := bindings(1) - w := newMovedWorld(t, bound) - w.a.inv.Active = []string{"mesh_ace_baserow", logins[0]} - w.bus.bind("ace", "app1", nil) - w.h.Receives = filepath.Join(t.TempDir(), "absent.json") - w.settled() - if len(w.a.removed) != 0 || w.h.r.waiting != nil { - t.Fatalf("retired %v, waiting %+v with the grant file unreadable", w.a.removed, w.h.r.waiting) - } -} - -// A login the grant file stops giving is not retired by the provider itself (novox/hq ADR 0570 clause 5): every -// retirement waits for the operator's yes on a provision's key. -func testALoginTheGrantFileStopsGivingIsRetired(t *testing.T) { - bound, _ := bindings(1) - w := newMovedWorld(t, bound) - w.bus.bind("ace", "app1", nil) - w.granting( - map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}}, - map[string]any{"as": "mesh_ace_letta", "node": "ace", "values": map[string]any{"name": "letta"}}, - ) - w.pass() - w.granting(map[string]any{"as": "mesh_ace_baserow", "node": "ace", "values": map[string]any{"name": "baserow"}}) - w.settled() - if len(w.a.removed) != 0 || len(w.a.deleted) != 0 { - t.Fatalf("retired %v, deleted %v, by itself", w.a.removed, w.a.deleted) - } -} - -// TestGrantsMoved holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). -func TestGrantsMoved(t *testing.T) { - t.Run("AConsumerOnlyTheGrantFileGivesIsAskedForAndMade", testAConsumerOnlyTheGrantFileGivesIsAskedForAndMade) - t.Run("ALoginAPlanNamesIsNotMadeFromTheGrantFile", testALoginAPlanNamesIsNotMadeFromTheGrantFile) - t.Run("AnUnreadableGrantFileRetiresNothing", testAnUnreadableGrantFileRetiresNothing) - t.Run("ALoginTheGrantFileStopsGivingIsRetired", testALoginTheGrantFileStopsGivingIsRetired) -} diff --git a/go/provider/harness/harness.go b/go/provider/harness/harness.go deleted file mode 100644 index 9e9dedc..0000000 --- a/go/provider/harness/harness.go +++ /dev/null @@ -1,572 +0,0 @@ -// Package harness is the reconcile loop and the retirement of a provider that keeps a resource per consumer; the -// module supplies an Adapter. -package harness - -// The reconcile loop every provider shares, as the TypeScript SDK's runProvisioner runs it -// (@novox/mesh-sdk/provisioner, 0.1.12), line for line in behaviour. It moved here from the postgres and keycloak -// providers, which each carried an identical copy (novox/hq ADR 0039: the loop is the SDK's, the adapter is the -// module's; issue 849). -// -// Read the contributions the mesh delivered; bring each consumer's resource into being through the -// adapter, under the login and password the mesh minted; retire what the mesh no longer asks for. -// **A provider creates the credential the mesh minted, and seals nothing (novox/hq ADR 0048).** -// -// **A provider that keeps failing a consumer says so on the bus (novox/hq ADR 0224).** A consumer -// whose create, check or secret has failed without one success in between for FailingAfter is -// announced as `provisioner.failing` — naming the consumer, its machine and the class of error — and -// again every SayAgainEvery while it lasts; the first success after that is `provisioner.recovered`. -// The controller keeps the newest per provider and consumer and `status` names it. On 2026-10-05 the -// identity provider failed every consumer 31,000 times in a day and said so only in its journal -// (novox/hq issue 179). -// -// **A consumer the mesh stops asking for is retired, not withdrawn, and deleted only by a person -// (novox/hq ADR 0230, the operator's model of 2026-10-06).** Retiring disables its access — reversibly — -// and marks its login and data "to delete" with when and why; nothing is deleted. Asked for again, the -// ordinary create re-enables it as it was. It is retired only once the same set has gone unasked in -// StablePasses consecutive passes and for StableFor, and a set larger than the bound waits for a -// person. retirement.go. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "os" - "strings" - "sync" - "time" -) - -// Provision is one consumer's resource to bring into being — everything the mesh derived and delivered. -type Provision struct { - // As is the login the mesh derived and gave the consumer to present. - As string - // Password is the one the mesh minted, read from the file the host unsealed. - Password string - // Values are what the consumer contributed (e.g. {"name": "letta", "extensions": ["vector"]}). - Values map[string]any - // Derived is what this provider's own definition derives for the consumer (novox/hq ADR 0201). - Derived map[string]any - // At is where the consumer is; Consumer is its node. - At string - Consumer string -} - -// Adapter is the per-service half. -type Adapter interface { - // Create brings the consumer into being under the mesh's login and password — and, for one that - // was retired, re-enables it as it was and clears its mark to delete. - Create(ctx context.Context, p Provision) error - // Retire disables the consumer's access, reversibly, and marks its login and data to delete with - // when and why. It deletes nothing (novox/hq ADR 0230). derived is what the mesh last derived, - // remembered here; nil for a consumer this process did not make. - Retire(ctx context.Context, as string, derived map[string]any, why string, at time.Time) error - // Holds says whether the backend still holds the consumer exactly as p says. Read-only. - Holds(ctx context.Context, p Provision) (bool, error) - // Inventory is what the backend holds that the mesh made: consumers active and retired, read from - // the backend itself so a restart forgets nothing. Read-only. - Inventory(ctx context.Context) (Inventory, error) - // Delete removes one retired consumer — its login and its data — for good. Only ever asked by a - // person, through `cleanup delete`; the harness has checked it is retired and not asked for. - Delete(ctx context.Context, r Retired) (freedBytes int64, err error) -} - -// Harness is the loop's settings and memory. -type Harness struct { - Resource string - Receives string - Adapter Adapter - Every time.Duration // 5s - VerifyEvery time.Duration // 60s - HoldsTimeout time.Duration // 30s - Log func(format string, args ...any) - Now func() time.Time - // Announce publishes one of the provider's standing events; nil announces nothing. Node is the - // machine this provider runs on, said in each. - Announce func(event string, body map[string]any) - Node string - // FailingAfter is how long a consumer fails without a success before it is announced (5m); - // SayAgainEvery is how often it is announced again while it lasts (15m), so a controller that - // missed the first hears the next, and a standing nobody repeats can be told from one that holds. - FailingAfter time.Duration - SayAgainEvery time.Duration - // StablePasses (5) is how many consecutive passes must see the same set no longer asked for, and - // StableFor (10m) how long it must have held since the first of them, before it is retired. RetireAtOnce (3) and RetireFraction (0.5) bound what is retired without a person: - // more consumers than RetireAtOnce, or — where more than one is held — a larger share of those held - // than RetireFraction, waits for `retire approve` (novox/hq ADR 0230). - StablePasses int - StableFor time.Duration - RetireAtOnce int - RetireFraction float64 - - // mu is held by a pass and by every tool a person asks, so the two never interleave. - mu sync.Mutex - verifiedAt time.Time - r retirement - applied map[string]appliedEntry - lost map[string]brake - waiting map[string]int - failing map[string]failure - trouble map[string]*standing - cleared map[string]bool - lastWarning string -} - -// standing is one consumer's unbroken run of failures: since when, how often, and the last error. -type standing struct { - node string - since time.Time - attempts int - class string - text string - saidAt time.Time -} - -// The events a provider's standing is announced as (novox/hq ADR 0224). The controller derives the -// permission to emit them for every module that receives contributions; no manifest lists them. -const ( - EventFailing = "provisioner.failing" - EventRecovered = "provisioner.recovered" -) - -// The classes of error a standing is announced with: what a person reading `status` needs to know -// before reading the journal. An adapter may say better (Classifier). -const ( - ClassCredentials = "credentials-rejected" - ClassUnreachable = "unreachable" - ClassSecret = "secret-unreadable" - ClassRefused = "refused" -) - -// Classifier is an adapter that can say what class an error of its own is. -type Classifier interface { - Class(err error) string -} - -type appliedEntry struct { - hash string - derived map[string]any - node string -} - -type brake struct { - times int - nextAt time.Time -} - -type failure struct { - text string - times int -} - -// The longest a consumer whose create keeps failing to satisfy holds waits between checks. -const maxBackoff = time.Hour - -// How many passes a secret may be unreadable before it stops being called a race (issue 225), and -// once said loudly, how often it is repeated. The same cadence quiets a create that keeps failing -// the same way. -const ( - patiently = 12 - loudlyEvery = 240 -) - -type contribution struct { - As string `json:"as"` - Secret string `json:"secret"` - Node string `json:"node"` - At string `json:"at"` - Values map[string]any `json:"values"` - Derived map[string]any `json:"derived"` -} - -func (h *Harness) init() { - if h.Every == 0 { - h.Every = 5 * time.Second - } - if h.VerifyEvery == 0 { - h.VerifyEvery = time.Minute - } - if h.HoldsTimeout == 0 { - h.HoldsTimeout = 30 * time.Second - } - if h.Now == nil { - h.Now = time.Now - } - if h.FailingAfter == 0 { - h.FailingAfter = 5 * time.Minute - } - if h.SayAgainEvery == 0 { - h.SayAgainEvery = 15 * time.Minute - } - if h.StablePasses == 0 { - h.StablePasses = 5 - } - if h.StableFor == 0 { - h.StableFor = StableFor - } - if h.RetireAtOnce == 0 { - h.RetireAtOnce = 3 - } - if h.RetireFraction == 0 { - h.RetireFraction = 0.5 - } - if h.Log == nil { - h.Log = func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) } - } - if h.applied == nil { - h.applied = map[string]appliedEntry{} - h.lost = map[string]brake{} - h.waiting = map[string]int{} - h.failing = map[string]failure{} - h.trouble = map[string]*standing{} - h.cleared = map[string]bool{} - h.r.retired = map[string]retiredEntry{} - } -} - -// Run reconciles until ctx ends. One consumer's failure never stops the others'. -func (h *Harness) Run(ctx context.Context) { - h.init() - for { - h.Reconcile(ctx) - select { - case <-ctx.Done(): - return - case <-time.After(h.Every): - } - } -} - -func (h *Harness) say(format string, args ...any) { - h.Log("[provisioner:"+h.Resource+"] "+format, args...) -} - -func (h *Harness) warn(why string) { - if why == h.lastWarning { - return - } - if why != "" { - h.say("%s; nothing applied or removed until it can be read", why) - } else { - h.say("contributions file readable again") - } - h.lastWarning = why -} - -// readContributions answers the consumers asked for, or nil when the file says nothing usable. -// **Nothing read is not nobody asking** (novox/hq issue 241): only a file that was read can retire anything. -func (h *Harness) readContributions() []contribution { - raw, err := os.ReadFile(h.Receives) - if err != nil { - h.warn(fmt.Sprintf("contributions file unreadable (%s): %v", h.Receives, err)) - return nil - } - var doc struct { - Requirement string `json:"requirement"` - Given json.RawMessage `json:"given"` - } - if err := json.Unmarshal(raw, &doc); err != nil { - h.warn(fmt.Sprintf("contributions file is not JSON (%s): %v", h.Receives, err)) - return nil - } - if doc.Requirement != "" && doc.Requirement != h.Resource { - h.warn(fmt.Sprintf("%s is for %s, not %s", h.Receives, doc.Requirement, h.Resource)) - return nil - } - var given []contribution - if len(doc.Given) == 0 || string(doc.Given) == "null" || json.Unmarshal(doc.Given, &given) != nil { - h.warn(fmt.Sprintf("%s has no given list", h.Receives)) - return nil - } - h.warn("") - out := []contribution{} - for _, g := range given { - // No `as` is not a credential grant: nothing to create for it. - if g.As != "" && g.Secret != "" { - out = append(out, g) - } - } - return out -} - -func hashOf(as, password string, values, derived map[string]any) string { - // Derived is in the hash: a provider that renames what it derives gave a different resource. - b, _ := json.Marshal([]any{as, password, orEmpty(values), orEmpty(derived)}) - return string(b) -} - -func orEmpty(m map[string]any) map[string]any { - if m == nil { - return map[string]any{} - } - return m -} - -// Reconcile is one pass. -func (h *Harness) Reconcile(ctx context.Context) { - h.mu.Lock() - defer h.mu.Unlock() - h.init() - given := h.readContributions() - if given == nil { - // Not a result: the passes that must agree before anything is retired start again. - h.r.key, h.r.count = "", 0 - return - } - want := map[string]bool{} - for _, g := range given { - want[g.As] = true - } - h.seed(ctx, want) - h.apply(ctx, given) - - // Retire what the mesh has stably stopped asking for — within the bound, or with a person. - h.retireUnasked(ctx, want) - h.r.lastWant = want - for as := range h.failing { - if !want[as] { - delete(h.failing, as) - } - } - // A consumer the mesh stopped asking for that this provider holds nothing for is no longer failed by - // anyone: said, so a standing the controller keeps for it is cleared rather than left naming a - // consumer that is gone. One still held is said recovered when it is retired. - for as := range h.trouble { - if _, held := h.applied[as]; !want[as] && !held { - h.recovered(as, "no longer asked for") - } - } -} - -// apply makes each consumer the grant file gives, and checks every VerifyEvery that the backend still holds it. The -// caller holds h.mu. One consumer's failure never stops the others'. -func (h *Harness) apply(ctx context.Context, given []contribution) { - verifying := h.Now().Sub(h.verifiedAt) >= h.VerifyEvery - if verifying { - h.verifiedAt = h.Now() - } - - for _, g := range given { - raw, err := os.ReadFile(g.Secret) - if err != nil { - // A secret the host has not written yet is a race on the first pass; past a minute it is - // a person's to look at, and said so (novox/hq issue 225). - n := h.waiting[g.As] + 1 - h.waiting[g.As] = n - if n <= patiently { - h.say("%s: secret not readable yet (%s): %v", g.As, g.Secret, err) - } else if n == patiently+1 || n%loudlyEvery == 0 { - h.say("%s: CANNOT READ the secret after %d attempts (%s): %v. This is not a race any more — "+ - "nothing has been provisioned for this consumer and nothing will be until somebody looks. "+ - "Check who owns the file and who this process runs as (novox/hq issue 225)", g.As, n, g.Secret, err) - } - h.failed(g.As, g.Node, ClassSecret, fmt.Sprintf("secret not readable (%s): %v", g.Secret, err)) - continue - } - delete(h.waiting, g.As) - password := strings.TrimSuffix(string(raw), "\n") - p := Provision{As: g.As, Password: password, Values: orEmpty(g.Values), Derived: orEmpty(g.Derived), At: g.At, Consumer: g.Node} - hash := hashOf(g.As, password, g.Values, g.Derived) - - reapplying := 0 - if was, ok := h.applied[g.As]; ok && was.hash == hash { - if !verifying { - continue - } - b, braked := h.lost[g.As] - if braked && h.Now().Before(b.nextAt) { - continue - } - hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout) - held, err := h.Adapter.Holds(hctx, p) - timedOut := errors.Is(hctx.Err(), context.DeadlineExceeded) - cancel() - if err != nil { - // Unable to ask is not evidence of loss. A backend that timed out will time out for - // the next consumer too, so the rest of this pass is not asked. - text := scrub(err, password) - h.say("%s: could not check the backend, will ask again: %s", g.As, text) - h.failed(g.As, g.Node, h.classOf(err, text), text) - if timedOut { - verifying = false - } - continue - } - if held { - delete(h.lost, g.As) - h.succeeded(g.As) - continue - } - reapplying = b.times + 1 - if reapplying == 1 { - h.say("%s: the backend no longer holds it; applying again", g.As) - } else { - h.say("%s: still not held after being applied again (%d times in a row) — create does not "+ - "produce what holds checks; applying again", g.As, reapplying) - } - } - if err := h.Adapter.Create(ctx, p); err != nil { - text := scrub(err, password) - f := h.failing[g.As] - if f.text != text { - f = failure{text: text} - } - f.times++ - h.failing[g.As] = f - // Said each time it changes, and while it stays the same, as rarely as a lost secret. - if f.times == 1 || f.times%loudlyEvery == 0 { - h.say("%s: create failed, will retry: %s", g.As, text) - } - h.failed(g.As, g.Node, h.classOf(err, text), text) - if reapplying > 0 { - h.lost[g.As] = brake{times: reapplying - 1} - } - continue - } - if f, was := h.failing[g.As]; was { - h.say("%s: created, after %d failed attempt(s)", g.As, f.times) - delete(h.failing, g.As) - } - h.succeeded(g.As) - h.applied[g.As] = appliedEntry{hash: hash, derived: p.Derived, node: g.Node} - h.reenabled(g.As, g.Node) - if reapplying == 0 { - delete(h.lost, g.As) - } else { - wait := h.VerifyEvery << (reapplying - 1) - if wait > maxBackoff || wait <= 0 { - wait = maxBackoff - } - h.lost[g.As] = brake{times: reapplying, nextAt: h.Now().Add(wait)} - if reapplying > 1 { - h.say("%s: next check in %s", g.As, wait.Round(time.Second)) - } - } - } -} - -// failed counts one more failure in a consumer's unbroken run, and announces the run once it has -// lasted FailingAfter — then again every SayAgainEvery while it lasts. -func (h *Harness) failed(as, node, class, text string) { - now := h.Now() - s := h.trouble[as] - if s == nil { - s = &standing{since: now} - h.trouble[as] = s - } - s.node, s.class, s.text = node, class, text - s.attempts++ - if now.Sub(s.since) < h.FailingAfter { - return - } - if !s.saidAt.IsZero() && now.Sub(s.saidAt) < h.SayAgainEvery { - return - } - first := s.saidAt.IsZero() - s.saidAt = now - if first { - h.say("%s: FAILING for %s (%d attempts, %s): %s. Announced as %s; `status` names it until it "+ - "succeeds (novox/hq ADR 0224)", as, now.Sub(s.since).Round(time.Second), s.attempts, class, text, EventFailing) - } - h.announce(EventFailing, map[string]any{ - "provider": h.Resource, "provider-node": h.Node, - "consumer": as, "node": node, - "class": class, "error": clip(text), - "since": s.since.UTC().Format(time.RFC3339), "attempts": s.attempts, - }) -} - -// succeeded ends a consumer's run of failures; one that was announced is announced recovered. -// -// **And the first success for a consumer since this process started is announced too**, failing or -// not: a provider that announced a failure and was restarted has forgotten it, and without this the -// controller would name the consumer failing for ever after it recovered unheard. -func (h *Harness) succeeded(as string) { - if h.trouble[as] == nil && !h.cleared[as] { - h.cleared[as] = true - h.announce(EventRecovered, map[string]any{ - "provider": h.Resource, "provider-node": h.Node, "consumer": as, "why": "first-success", - }) - return - } - h.cleared[as] = true - h.recovered(as, "") -} - -func (h *Harness) recovered(as, why string) { - s := h.trouble[as] - if s == nil { - return - } - delete(h.trouble, as) - if s.saidAt.IsZero() { - return // never announced, so there is nothing to take back - } - if why == "" { - h.say("%s: recovered after %s and %d failed attempt(s)", as, h.Now().Sub(s.since).Round(time.Second), s.attempts) - } - body := map[string]any{ - "provider": h.Resource, "provider-node": h.Node, "consumer": as, "node": s.node, - "since": s.since.UTC().Format(time.RFC3339), "attempts": s.attempts, - } - if why != "" { - body["why"] = why - } - h.announce(EventRecovered, body) -} - -func (h *Harness) announce(event string, body map[string]any) { - if h.Announce != nil { - h.Announce(event, body) - } -} - -// classOf is an error's class: the adapter's word when it has one, else read from the text. -func (h *Harness) classOf(err error, text string) string { - if c, ok := h.Adapter.(Classifier); ok { - if class := c.Class(err); class != "" { - return class - } - } - return ClassOf(text) -} - -// ClassOf reads an error's class from its text — the words the backends the mesh runs use. -func ClassOf(text string) string { - t := strings.ToLower(text) - for _, w := range []string{"invalid_grant", "invalid user credentials", "password authentication failed", - "authentication failed", "unauthorized", " 401"} { - if strings.Contains(t, w) { - return ClassCredentials - } - } - for _, w := range []string{"connection refused", "no such host", "i/o timeout", "deadline exceeded", - "connection reset", "network is unreachable", "no route to host", "eof"} { - if strings.Contains(t, w) { - return ClassUnreachable - } - } - return ClassRefused -} - -// clip keeps an announced error to what belongs in a status line. -func clip(text string) string { - const most = 300 - if len(text) <= most { - return text - } - return text[:most] + "…" -} - -// scrub is an error's text with the consumer's password removed, raw and URL-encoded. -func scrub(err error, password string) string { - text := err.Error() - if password == "" { - return text - } - for _, form := range []string{password, url.QueryEscape(password), url.PathEscape(password)} { - text = strings.ReplaceAll(text, form, "***") - } - return text -} diff --git a/go/provider/harness/harness_test.go b/go/provider/harness/harness_test.go deleted file mode 100644 index 2e84e6d..0000000 --- a/go/provider/harness/harness_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package harness - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -type world struct { - t *testing.T - dir string - receives string - now time.Time - h *Harness - a *recorder - said []string -} - -func newWorld(t *testing.T) *world { - w := &world{t: t, dir: t.TempDir(), now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), a: &recorder{held: true}} - w.receives = filepath.Join(w.dir, "mesh.json") - w.h = &Harness{Resource: "postgres-database", Receives: w.receives, Adapter: w.a, - Now: func() time.Time { return w.now }, - Log: func(f string, a ...any) { w.said = append(w.said, f) }} - return w -} - -func (w *world) give(given ...map[string]any) { - for _, g := range given { - secret := filepath.Join(w.dir, g["as"].(string)+".secret") - if err := os.WriteFile(secret, []byte("pw-"+g["as"].(string)+"\n"), 0o600); err != nil { - w.t.Fatal(err) - } - g["secret"] = secret - } - if given == nil { - given = []map[string]any{} - } - raw, _ := json.Marshal(map[string]any{"requirement": "postgres-database", "given": given}) - if err := os.WriteFile(w.receives, raw, 0o600); err != nil { - w.t.Fatal(err) - } -} - -func testAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword(t *testing.T) { - w := newWorld(t) - w.give(map[string]any{"as": "mesh_ace_letta", "node": "ace", "values": map[string]any{"name": "letta"}}) - w.h.Reconcile(ctx) - w.h.Reconcile(ctx) - if len(w.a.created) != 1 { - t.Fatalf("created %d times", len(w.a.created)) - } - p := w.a.created[0] - if p.As != "mesh_ace_letta" || p.Password != "pw-mesh_ace_letta" || p.Consumer != "ace" { - t.Fatalf("%+v", p) - } -} - -func testAConsumerNoLongerAskedForIsRetiredOnceStable(t *testing.T) { - w := newWorld(t) - w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"}) - w.h.Reconcile(ctx) - w.give(map[string]any{"as": "a"}) - w.settle() // five passes and ten minutes - if strings.Join(w.a.removed, ",") != "b" { - t.Fatal(w.a.removed) - } - // Only a file that says nobody asks retires the last one. - w.give() - w.settle() - if strings.Join(w.a.removed, ",") != "b,a" { - t.Fatal(w.a.removed) - } -} - -func testNothingReadIsNotNobodyAsking(t *testing.T) { - for name, content := range map[string]string{ - "unreadable": "", - "not JSON": "{", - "no given": `{"requirement": "postgres-database"}`, - "another": `{"requirement": "mssql-database", "given": []}`, - } { - t.Run(name, func(t *testing.T) { - w := newWorld(t) - w.give(map[string]any{"as": "a"}) - w.h.Reconcile(ctx) - if content == "" { - os.Remove(w.receives) - } else { - os.WriteFile(w.receives, []byte(content), 0o600) - } - w.h.Reconcile(ctx) - if len(w.a.removed) != 0 { - t.Fatalf("retired %v on a file it could not use", w.a.removed) - } - }) - } -} - -func testALostConsumerIsAppliedAgainAndBraked(t *testing.T) { - w := newWorld(t) - w.give(map[string]any{"as": "a"}) - w.h.Reconcile(ctx) - w.a.held = false - w.now = w.now.Add(2 * time.Minute) - w.h.Reconcile(ctx) - if len(w.a.created) != 2 { - t.Fatalf("created %d times", len(w.a.created)) - } - // Still not held a minute later: applied again, then braked for two minutes. - w.now = w.now.Add(61 * time.Second) - w.h.Reconcile(ctx) - w.now = w.now.Add(61 * time.Second) - w.h.Reconcile(ctx) - if len(w.a.created) != 3 { - t.Fatalf("not braked: created %d times", len(w.a.created)) - } -} - -func testAFailingCreateIsRetriedAndSaidOnce(t *testing.T) { - w := newWorld(t) - w.a.failing = &pgErr{"extension \"nope\" refused, password pw-a"} - w.give(map[string]any{"as": "a"}) - for i := 0; i < 5; i++ { - w.h.Reconcile(ctx) - } - n := 0 - for _, s := range w.said { - if strings.Contains(s, "create failed") { - n++ - } - } - if n != 1 { - t.Fatalf("said %d times", n) - } - w.a.failing = nil - w.h.Reconcile(ctx) - if len(w.a.created) != 1 { - t.Fatal("not retried") - } -} - -type pgErr struct{ s string } - -func (e *pgErr) Error() string { return e.s } - -func testScrubRemovesThePassword(t *testing.T) { - if got := scrub(&pgErr{"bad pw a/b c and a%2Fb+c"}, "a/b c"); strings.Contains(got, "a/b c") || strings.Contains(got, "a%2Fb+c") { - t.Fatal(got) - } -} - -// TestHarness holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). -func TestHarness(t *testing.T) { - t.Run("AConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword", testAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword) - t.Run("AConsumerNoLongerAskedForIsRetiredOnceStable", testAConsumerNoLongerAskedForIsRetiredOnceStable) - t.Run("NothingReadIsNotNobodyAsking", testNothingReadIsNotNobodyAsking) - t.Run("ALostConsumerIsAppliedAgainAndBraked", testALostConsumerIsAppliedAgainAndBraked) - t.Run("AFailingCreateIsRetriedAndSaidOnce", testAFailingCreateIsRetriedAndSaidOnce) - t.Run("ScrubRemovesThePassword", testScrubRemovesThePassword) -} diff --git a/go/provider/harness/moved.go b/go/provider/harness/moved.go deleted file mode 100644 index 820431b..0000000 --- a/go/provider/harness/moved.go +++ /dev/null @@ -1,312 +0,0 @@ -package harness - -// A provider handed to the provisioner (novox/hq ADR 0373, ADR 0349 clause 12, issues 780 and 805): where its -// consumers file is set, it runs on the provider helper (package provider) instead of the harness reading the -// controller's grant files. Moved is that path, shared by every provider that keeps a resource per consumer; a module -// says only what differs, in a MovedSpec. -// -// The helper learns who binds the provider from the plans, composes each consumer's entry and reads its credential -// through ConsumersFile, the file this machine's provisioner client fills. It leaves the rest to the two hooks -// here: Make brings the consumer's resource into being exactly as the harness's adapter does, and Gone leaves -// retirement to the harness's guards (retirement.go): the resource is disabled and marked to delete, never removed. -// -// **A consumer not moved is still the grant file's** (issue 937, ADR 0349 clause 17), for a provider whose spec says -// WithGrants: only a moved consumer's plan names its provider, so the controller still gives every other consumer -// through the grant file (MESH_RECEIVES) until R1 (issue 682). Each login it gives is asked for, and one no plan -// names is made and checked by the harness's grant pass exactly as on the grant path; a login a plan names is the -// helper's alone. A grant file that cannot be read is not nobody asking (issue 241): nothing is retired until it is -// read again. - -import ( - "context" - "errors" - "fmt" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provisions" -) - -// MovedSpec is what differs per provider on the moved path. -type MovedSpec struct { - // Module and Provision are the provider module's name and what it provides, as its manifest says. - Module, Provision string - // Offer is what it serves: its credential form and served values. - Offer provider.Offer - // Login is the identity a consumer is made under at the provider's end (postgres: the entry's User; keycloak: its - // As); an entry with none is not made. - Login func(e provider.Entry) string - // Values are the values the adapter is handed for a consumer; the entry's contribution when not set. - Values func(e provider.Entry) map[string]any - // Noun names what Login is in words ("login", "client id"), Made what is made ("login", "client"), and Backend - // the service in "no longer holds it" ("the backend", "Keycloak"). - Noun, Made, Backend string - // WithGrants makes the logins the grant file gives, that no plan names, asked for and made by the harness's grant - // pass (issue 937). - WithGrants bool -} - -// Moved is a provider on the provider helper, with the harness's own memory and retirement behind it: what is held, -// what is counted unasked, what waits for a person (retirement.go). The harness is never Run here; the helper's Make -// and Gone and the loop below drive the same state, so the guards are the harness's, not a copy. -type Moved struct { - h *Harness - spec MovedSpec - - // FirstRetry is the first wait before a watch asked too early is asked again (issue 943); a second when zero. - FirstRetry time.Duration - - mu sync.Mutex // guards plans and loaded - plans map[string][]byte - loaded bool - verified map[string]time.Time -} - -func newMoved(h *Harness, spec MovedSpec) *Moved { - h.mu.Lock() - h.init() - h.mu.Unlock() - return &Moved{h: h, spec: spec, plans: map[string][]byte{}, verified: map[string]time.Time{}} -} - -// Make brings one consumer's resource into being under its login and the vault's credential, as the harness does: -// at once when it is new or changed, and otherwise only when the backend no longer holds it, which is asked every -// VerifyEvery. -func (m *Moved) Make(ctx context.Context, c provider.Consumer) error { - h, e := m.h, c.Entry - as := m.spec.Login(e) - if as == "" { - return fmt.Errorf("%s has no %s to make", e.Key(), m.spec.Noun) - } - if len(c.Credential) == 0 { - return fmt.Errorf("%s has no credential to make its %s with", e.Key(), m.spec.Made) - } - h.mu.Lock() - defer h.mu.Unlock() - password := string(c.Credential) - values := orEmpty(e.Contributes) - if m.spec.Values != nil { - values = m.spec.Values(e) - } - p := Provision{As: as, Password: password, Values: values, Consumer: e.Consumer} - hash := hashOf(as, password, p.Values, nil) - if was, ok := h.applied[as]; ok && was.hash == hash { - if h.Now().Sub(m.verified[as]) < h.VerifyEvery { - return nil - } - m.verified[as] = h.Now() - hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout) - held, err := h.Adapter.Holds(hctx, p) - cancel() - if err != nil { - text := scrub(err, password) - h.failed(as, e.Consumer, h.classOf(err, text), text) - return errors.New(text) - } - if held { - h.succeeded(as) - return nil - } - h.say("%s: %s no longer holds it; applying again", as, m.spec.Backend) - } - if err := h.Adapter.Create(ctx, p); err != nil { - text := scrub(err, password) - h.failed(as, e.Consumer, h.classOf(err, text), text) - return errors.New(text) - } - h.succeeded(as) - h.applied[as] = appliedEntry{hash: hash, node: e.Consumer} - m.verified[as] = h.Now() - h.reenabled(as, e.Consumer) - return nil -} - -// Gone is the helper letting a consumer go, which it does only on a retiring provision the operator said yes to (novox/hq -// ADR 0570 clause 5): its login is retired at once, through the adapter, its resource and data kept and marked to -// delete, deleted only by a person's `cleanup delete`. A consumer another plan still binds under the same login loses -// nothing. -func (m *Moved) Gone(ctx context.Context, e provider.Entry) error { - h := m.h - as := m.spec.Login(e) - if as == "" { - return nil // refused: nothing was made for it - } - want, _ := m.asked(h.Node) - h.mu.Lock() - defer h.mu.Unlock() - if want[as] { - return nil // another holder of the same login still binds: nothing to retire - } - if _, held := h.applied[as]; !held { - return nil - } - if done := h.retire(ctx, []string{as}, "the operator approved its retirement ("+e.Key()+")", nil); len(done) != 1 { - return fmt.Errorf("%s was not retired; it is tried again at the next pass", as) - } - return nil -} - -// asked is the logins the seat's plans bind the provider for (novox/hq ADR 0570 clause 2), and whether they could all be -// read: a plan that cannot be read, or no read yet, is not an answer (novox/hq issue 241). -func (m *Moved) asked(node string) (map[string]bool, bool) { - m.mu.Lock() - defer m.mu.Unlock() - if !m.loaded || len(m.plans) == 0 { - return nil, false - } - want := map[string]bool{} - for key, raw := range m.plans { - pl, err := provisions.DecodePlan(raw) - if err != nil || pl.Node+"."+pl.Module != key { - return nil, false - } - for _, b := range pl.Plan.Provisions { - if b.Provider == node && b.ProviderModule == m.spec.Module && b.Provision == m.spec.Provision { - as := b.As - if as == "" { - as = provider.ConsumerIdentity(pl.Node, pl.Module) // a plan that names none - } - want[as] = true - } - } - } - return want, true -} - -// granted is what the grant file gives that no plan names (issue 937), and every login it gives; and whether it -// could be read. The caller holds h.mu. With no grant file named there is nothing given, and that is a read. -func (m *Moved) granted(planned map[string]bool) ([]contribution, map[string]bool, bool) { - h := m.h - if h.Receives == "" { - return nil, map[string]bool{}, true - } - given := h.readContributions() - if given == nil { - return nil, nil, false - } - var own []contribution - logins := map[string]bool{} - for _, g := range given { - logins[g.As] = true - if !planned[g.As] { - own = append(own, g) - } - } - return own, logins, true -} - -// retire is one pass over what the plans bind and the grant file gives now: the logins only the grant file gives are -// made and checked, as the grant path does. It retires nothing (ADR 0570 clause 5). -func (m *Moved) retire(ctx context.Context) { - h := m.h - want, ok := m.asked(h.Node) - h.mu.Lock() - defer h.mu.Unlock() - var given []contribution - if ok && m.spec.WithGrants { - var granted map[string]bool - given, granted, ok = m.granted(want) - for as := range granted { - want[as] = true - } - } - if !ok { - // Not a result: the passes that must agree before anything is retired start again. - h.r.key, h.r.count = "", 0 - return - } - h.seed(ctx, want) - h.apply(ctx, given) - // No login is retired for going unasked (novox/hq ADR 0570 clause 5): only Gone, on the operator's yes. - h.r.lastWant = want - for as := range h.failing { - if !want[as] { - delete(h.failing, as) - } - } - for as := range h.trouble { - if _, held := h.applied[as]; !want[as] && !held { - h.recovered(as, "no longer asked for") - } - } -} - -// watch keeps the plans as the bus holds them, from the first read on. -func (m *Moved) watch(ctx context.Context, bus provider.Bus) error { - err := bus.Watch(ctx, provisions.PlansBucket, func(key string, value []byte) { - m.mu.Lock() - defer m.mu.Unlock() - if value == nil { - delete(m.plans, key) - } else { - m.plans[key] = append([]byte(nil), value...) - } - }) - if err == nil { - m.mu.Lock() - m.loaded = true - m.mu.Unlock() - } - return err -} - -// Run answers the consumers through the helper and retires through the harness's rules, until ctx ends. -// -// The bus is reached through the runtime that launched this bundle, which answers only once Serve runs, and Run starts -// beside Serve: a watch asked too early is asked again, after a wait that doubles from FirstRetry up to a minute, -// until it is answered or ctx ends, so a start that loses the race never leaves the consumers unanswered (issue 943). -func (m *Moved) Run(ctx context.Context, p *provider.Provider, bus provider.Bus) error { - wait := m.FirstRetry - if wait <= 0 { - wait = time.Second - } - for tries := 1; ; tries++ { - err := m.watch(ctx, bus) - if err == nil { - break - } - if tries == 4 || tries%20 == 0 { - m.h.say("the plans cannot be watched yet (%d tries), asking again: %v", tries, err) - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(wait): - } - wait = min(wait*2, time.Minute) - } - go func() { - for { - m.retire(ctx) - select { - case <-ctx.Done(): - return - case <-time.After(m.h.Every): - } - } - }() - return p.Run(ctx) -} - -// NewMoved is a provider handed to the provisioner: the helper answering its consumers, and the harness (never run) -// whose retirement and tools it shares. consumersFile is where its machine's client fills its consumers. Run the -// result with Moved.Run, passing the provider and the bus returned with it. -func NewMoved(consumersFile string, h *Harness, spec MovedSpec) (*Moved, *provider.Provider, provider.Bus, error) { - if consumersFile == "" { - return nil, nil, nil, errors.New("no consumers' file") - } - run := newMoved(h, spec) - bus := provider.StateBus(spec.Module) - return run, provider.New(provider.Options{ - Node: h.Node, - Module: spec.Module, - Offers: map[string]provider.Offer{spec.Provision: spec.Offer}, - Bus: bus, - Vault: provider.ConsumersFile{Paths: []string{consumersFile}}, - Make: run.Make, - Gone: run.Gone, - Every: h.Every, - Logf: func(f string, a ...any) { h.say(f, a...) }, - }), bus, nil -} diff --git a/go/provider/harness/moved_test.go b/go/provider/harness/moved_test.go deleted file mode 100644 index 92ae2b1..0000000 --- a/go/provider/harness/moved_test.go +++ /dev/null @@ -1,476 +0,0 @@ -package harness - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "sync" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provisions" -) - -const ( - ProvisionName = "postgres-database" - servedPort = 5432 -) - -// postgresSpec is what a postgres-like provider says: a login per consumer, and the grant file's logins. -var postgresSpec = MovedSpec{Module: "postgres", Provision: ProvisionName, - Offer: provider.Offer{Credential: provider.PerConsumer, Serves: map[string]any{"port": servedPort}}, - Login: func(e provider.Entry) string { return e.User }, - Noun: "login", Made: "login", Backend: "the backend", WithGrants: true} - -// bucketBus is the helper's bus over in-memory buckets. -type bucketBus struct { - mu sync.Mutex - buckets map[string]map[string][]byte -} - -func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { - b.mu.Lock() - defer b.mu.Unlock() - for k, v := range b.buckets[bucket] { - handle(k, v) - } - return nil -} - -func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { - b.mu.Lock() - defer b.mu.Unlock() - if b.buckets[bucket] == nil { - b.buckets[bucket] = map[string][]byte{} - } - b.buckets[bucket][key] = append([]byte(nil), value...) - return nil -} - -func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { - b.mu.Lock() - defer b.mu.Unlock() - delete(b.buckets[bucket], key) - return nil -} - -// putPlan is a consumer's plan on the seat mesh-provisioner, of these plan entries, each provision's key provided (novox/hq -// ADR 0570 clauses 1, 2): what the helper binds by. -func (b *bucketBus) putPlan(node, module string, entries ...map[string]any) { - raw, _ := json.Marshal(entries) - var ps []catalogue.PlanProvision - if err := json.Unmarshal(raw, &ps); err != nil { - panic(err) - } - for _, pp := range ps { - pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node}, - Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided} - key, _ := pr.Key() - value, err := provisions.Encode(pr) - if err != nil { - panic(err) - } - _ = b.Put(context.Background(), provisions.ProvisionsBucket, key, value) - } - plan, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}}) - if err != nil { - panic(err) - } - _ = b.Put(context.Background(), provisions.PlansBucket, node+"."+module, plan) -} - -// retireOn is a consumer's provision retiring with the operator's yes (ADR 0570 clause 5), its plan gone: the one way -// a provider lets it go. -func (b *bucketBus) retireOn(node, module, provides string) { - pr := provisions.Provision{Provision: ProvisionName, Consumer: provisions.End{Module: module, Node: node}, - Provider: provisions.End{Module: provides, Node: "novox"}, State: provisions.Retiring, Why: module + " left", - Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}} - key, _ := pr.Key() - value, _ := provisions.Encode(pr) - _ = b.Delete(context.Background(), provisions.PlansBucket, node+"."+module) - _ = b.Put(context.Background(), provisions.ProvisionsBucket, key, value) -} - -// bind is one consumer's assignment, whose plan names this machine's postgres as the provider and carries what the -// consumer contributes (issue 776). -func (b *bucketBus) bind(node, module string, contributes map[string]any) { - b.bindNamed("postgres", node, module, contributes, nil) -} - -// bindNamed is bind to a provider module, with the names the controller composed for the consumer (issue 792). -func (b *bucketBus) bindNamed(provides, node, module string, contributes map[string]any, names map[string]string) { - b.putPlan(node, module, map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": provides, - "contributes": contributes, "names": names}) -} - -// consumersFile writes the file the machine's client fills: one credential per consumer. -func consumersFile(t *testing.T, bound map[[2]string]string) string { - var list []provider.ConsumerCredential - for k, password := range bound { - list = append(list, provider.ConsumerCredential{Consumer: k[0], Module: k[1], - Credential: provider.PairName("novox", ProvisionName, k[0], k[1], ""), Value: password}) - } - raw, _ := json.Marshal(list) - at := filepath.Join(t.TempDir(), "consumers.json") - if err := os.WriteFile(at, raw, 0o600); err != nil { - t.Fatal(err) - } - return at -} - -// movedWorld is postgres on the helper over an in-memory bus, with a recorder for a server and a clock to move. -type movedWorld struct { - t *testing.T - a *recorder - bus *bucketBus - h *Harness - spec MovedSpec - run *Moved - p *provider.Provider - now time.Time - file string -} - -func newMovedWorld(t *testing.T, bound map[[2]string]string) *movedWorld { - return newMovedWorldOf(t, bound, postgresSpec) -} - -func newMovedWorldOf(t *testing.T, bound map[[2]string]string, spec MovedSpec) *movedWorld { - w := &movedWorld{t: t, spec: spec, a: &recorder{}, bus: &bucketBus{buckets: map[string]map[string][]byte{}}, - now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} - w.file = consumersFile(t, bound) - w.h = &Harness{Resource: ProvisionName, Adapter: w.a, Node: "novox", Now: func() time.Time { return w.now }, - Log: func(string, ...any) {}} - var bus provider.Bus - w.run, w.p, bus, _ = NewMoved(w.file, w.h, spec) - _ = bus - if err := w.run.watch(ctx, w.bus); err != nil { - t.Fatal(err) - } - return w -} - -// pass is one helper pass and one retirement pass, five seconds after the last. -func (w *movedWorld) pass() []provider.Outcome { - w.now = w.now.Add(5 * time.Second) - // the plans as the bus holds them now, as the watch would hand them - w.run.plans = map[string][]byte{} - for k, v := range w.bus.buckets[provisions.PlansBucket] { - w.run.plans[k] = v - } - // the helper reads its buckets from the in-memory bus - w.p = provider.New(provider.Options{Node: "novox", Module: w.spec.Module, Bus: w.bus, - Vault: provider.ConsumersFile{Paths: []string{w.file}}, - Offers: map[string]provider.Offer{ProvisionName: w.spec.Offer}, - Make: w.run.Make, Gone: w.run.Gone, Logf: func(string, ...any) {}}) - out, err := w.p.Pass(ctx) - if err != nil { - w.t.Fatal(err) - } - w.run.retire(ctx) - return out -} - -func bindings(n int) (map[[2]string]string, []string) { - bound := map[[2]string]string{} - var logins []string - for i := 1; i <= n; i++ { - module := fmt.Sprintf("app%d", i) - bound[[2]string{"ace", module}] = "pw-" + module - logins = append(logins, provider.ConsumerIdentity("ace", module)) - } - return bound, logins -} - -// The consumers' file makes the same calls on the adapter as the equivalent grant files do (the harness). -func testTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) { - letta := provider.ConsumerIdentity("ace", "letta") - plain := provider.ConsumerIdentity("ace", "plain") - - // Today: the grant files, through the harness. - g := newWorld(t) - g.give( - map[string]any{"as": letta, "node": "ace", "values": map[string]any{"name": "letta", "extensions": []any{"vector"}}}, - map[string]any{"as": plain, "node": "ace", "values": map[string]any{"name": "plain"}}, - ) - g.h.Reconcile(ctx) - - // The consumers' file, through the provider helper. - w := newMovedWorld(t, map[[2]string]string{{"ace", "letta"}: "pw-" + letta, {"ace", "plain"}: "pw-" + plain}) - w.bus.bind("ace", "letta", map[string]any{"name": "letta", "extensions": []any{"vector"}}) - w.bus.bind("ace", "plain", map[string]any{"name": "plain"}) - for _, o := range w.pass() { - if o.Status != provider.Answered { - t.Fatalf("%+v", o) - } - } - byAs := map[string]Provision{} - for _, c := range w.a.created { - byAs[c.As] = c - } - if len(w.a.created) != 2 { - t.Fatalf("created %d, want 2", len(w.a.created)) - } - for _, want := range g.a.created { - c := byAs[want.As] - if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer || - !reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) { - t.Fatalf("made %+v, the grant files make %+v", c, want) - } - } - // Unchanged, and held: nothing is made again, within a minute or after it. - w.a.held = true - w.pass() - w.now = w.now.Add(2 * time.Minute) - w.pass() - if len(w.a.created) != 2 { - t.Fatalf("an unchanged pass created again: %d", len(w.a.created)) - } - // No longer held: made again. - w.a.held = false - w.now = w.now.Add(2 * time.Minute) - w.pass() - if len(w.a.created) != 4 { - t.Fatalf("not made again: %d", len(w.a.created)) - } -} - -// A consumer whose plan goes retires nothing by itself, however long (novox/hq ADR 0570 clause 5): its login is retired, -// never dropped, and its entry removed only once its provision is retiring with the operator's yes. -func testAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { - bound, logins := bindings(2) - w := newMovedWorld(t, bound) - w.bus.bind("ace", "app1", nil) - w.bus.bind("ace", "app2", nil) - w.pass() - _ = w.bus.Delete(ctx, provisions.PlansBucket, "ace.app2") - for i := 0; i < 8; i++ { - w.pass() - } - w.now = w.now.Add(30 * time.Minute) - w.pass() - if len(w.a.removed) != 0 || w.h.r.waiting != nil { - t.Fatalf("retired %v, waiting %v, with no warrant", w.a.removed, w.h.r.waiting) - } - if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept { - t.Fatal("the entry went with no warrant") - } - w.bus.retireOn("ace", "app2", "postgres") - w.pass() - if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 { - t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted) - } - if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; kept { - t.Fatal("the entry stayed after the login was retired") - } -} - -// A plan list that was never read is not nobody asking. -func testNoPlansReadRetiresNothing(t *testing.T) { - bound, _ := bindings(1) - w := newMovedWorld(t, bound) - w.bus.bind("ace", "app1", nil) - w.pass() - w.bus.Delete(ctx, provisions.PlansBucket, "ace.app1") - for i := 0; i < 8; i++ { - w.pass() - } - w.now = w.now.Add(20 * time.Minute) - w.pass() - if len(w.a.removed) != 0 { - t.Fatalf("retired %v with no plans read", w.a.removed) - } -} - -// A consumer whose credential is not in the file yet is waited for: nothing is made for it. -func testAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { - w := newMovedWorld(t, map[[2]string]string{}) - w.bus.bind("ace", "letta", map[string]any{"name": "letta"}) - outcomes := w.pass() - if len(outcomes) != 1 || outcomes[0].Status != provider.Waiting || len(w.a.created) != 0 { - t.Fatalf("%+v, created %v", outcomes, w.a.created) - } -} - -// Where the file is not there, postgres takes the grant directory, as before; where it is, the helper. -// The grant path restarts itself when the consumers file is first filled. -// A consumer whose module has a slug keeps the login its plan names, the slug first (issue 788): the adapter makes it -// under that login, and the retirement counts it as asked for, so a pass retires nothing. -func testASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) { - agent := provider.ConsumerIdentity("ace", "agent") - w := newMovedWorld(t, map[[2]string]string{{"ace", "claude-code"}: "pw-agent"}) - w.bus.putPlan("ace", "claude-code", map[string]any{"provision": ProvisionName, "provider": "novox", - "provider-module": "postgres", "as": agent}) - for _, o := range w.pass() { - if o.Status != provider.Answered { - t.Fatalf("%+v", o) - } - } - if len(w.a.created) != 1 || w.a.created[0].As != agent { - t.Fatalf("made %+v, want one under %s", w.a.created, agent) - } - for i := 0; i < 8; i++ { - w.pass() - } - w.now = w.now.Add(15 * time.Minute) - w.pass() - w.pass() - if len(w.a.removed) != 0 { - t.Fatalf("retired %v: the plan's login was counted unasked", w.a.removed) - } -} - -// A provider whose spec names the plan's `as` as the login and no grant file (the keycloak shape) makes the consumer -// under that name with the values its spec composes, and a grant file that cannot be read does not hold up its -// retirement pass. -func testASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile(t *testing.T) { - a := &recorder{} - bus := &bucketBus{buckets: map[string]map[string][]byte{}} - now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) - h := &Harness{Resource: ProvisionName, Adapter: a, Node: "novox", Now: func() time.Time { return now }, - Receives: filepath.Join(t.TempDir(), "unreadable-or-missing.json"), Log: func(string, ...any) {}} - spec := MovedSpec{Module: "keycloak", Provision: ProvisionName, - Offer: provider.Offer{Credential: provider.PerConsumer}, - Login: func(e provider.Entry) string { return e.As }, Noun: "client id", Made: "client", Backend: "Keycloak", - Values: func(e provider.Entry) map[string]any { return map[string]any{"label": e.Contributes["label"]} }} - file := consumersFile(t, map[[2]string]string{{"ace", "wiki"}: "pw-wiki"}) - run, _, _, err := NewMoved(file, h, spec) - if err != nil { - t.Fatal(err) - } - bus.putPlan("ace", "wiki", map[string]any{"provision": ProvisionName, "provider": "novox", - "provider-module": "keycloak", "as": "wiki-client", "contributes": map[string]any{"label": "Wiki"}}) - if err := run.watch(ctx, bus); err != nil { - t.Fatal(err) - } - p := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus, - Vault: provider.ConsumersFile{Paths: []string{file}}, - Offers: map[string]provider.Offer{ProvisionName: spec.Offer}, Make: run.Make, Gone: run.Gone, - Logf: func(string, ...any) {}}) - out, err := p.Pass(ctx) - if err != nil || len(out) != 1 || out[0].Status != provider.Answered { - t.Fatalf("%v %+v", err, out) - } - run.retire(ctx) - if len(a.created) != 1 || a.created[0].As != "wiki-client" || a.created[0].Password != "pw-wiki" || - a.created[0].Values["label"] != "Wiki" { - t.Fatalf("made %+v", a.created) - } -} - -const testIssuer = "https://id.example.org/realms/Novox" - -var ( - grafanaContributes = map[string]any{"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth"} - grafanaNames = map[string]string{"name": "grafana.example.org", "internal-name": "grafana.home.internal"} -) - -// keycloakSpec is what a keycloak-like provider says: the client id is the plan's `as`, the values are what the -// consumer contributes with the controller's names over them, and no grant file is consulted. -var keycloakSpec = MovedSpec{Module: "keycloak", Provision: ProvisionName, - Offer: provider.Offer{Credential: provider.PerConsumer, Serves: map[string]any{"issuer": testIssuer, - "token-path": "/protocol/openid-connect/token"}}, - Login: func(e provider.Entry) string { return e.As }, - Values: func(e provider.Entry) map[string]any { - values := map[string]any{} - for k, v := range e.Contributes { - values[k] = v - } - for k, name := range e.Names { - values[k] = name - } - return values - }, - Noun: "client id", Made: "client", Backend: "Keycloak"} - -// The consumers' file makes the same call on the adapter as the equivalent grant file does (the harness), for a -// provider of the keycloak shape: the same client id, secret and values; and the entry tells the consumer what the -// provider serves. -func testTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape(t *testing.T) { - grafana := provider.ConsumerIdentity("home", "grafana") - values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]} - for k, v := range grafanaContributes { - values[k] = v - } - g := newWorld(t) - g.give(map[string]any{"as": grafana, "node": "home", "values": values}) - g.h.Reconcile(ctx) - - w := newMovedWorldOf(t, map[[2]string]string{{"home", "grafana"}: "pw-" + grafana}, keycloakSpec) - w.bus.bindNamed("keycloak", "home", "grafana", grafanaContributes, grafanaNames) - for _, o := range w.pass() { - if o.Status != provider.Answered { - t.Fatalf("%+v", o) - } - } - if len(w.a.created) != 1 || len(g.a.created) != 1 { - t.Fatalf("created %d through the helper, %d through the harness, want 1 each", len(w.a.created), len(g.a.created)) - } - c, want := w.a.created[0], g.a.created[0] - if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer || - !reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) { - t.Fatalf("made %+v, the grant file makes %+v", c, want) - } - var entry provider.Entry - if err := json.Unmarshal(w.bus.buckets[provider.BucketOf("keycloak")]["home.grafana."+ProvisionName], &entry); err != nil { - t.Fatal(err) - } - if entry.Values["issuer"] != testIssuer || entry.Values["token-path"] != "/protocol/openid-connect/token" { - t.Fatalf("served %v", entry.Values) - } - w.a.held = true - w.pass() - w.now = w.now.Add(2 * time.Minute) - w.pass() - if len(w.a.created) != 1 { - t.Fatalf("an unchanged pass created again: %d", len(w.a.created)) - } - w.a.held = false - w.now = w.now.Add(2 * time.Minute) - w.pass() - if len(w.a.created) != 2 { - t.Fatalf("not made again: %d", len(w.a.created)) - } -} - -// A consumer whose plan names its client id (a slugged module) keeps it, and the retirement counts it as asked for. -func testAConsumerNamedByItsPlanKeepsItsClientId(t *testing.T) { - agent := provider.ConsumerIdentity("home", "dashboard") - w := newMovedWorldOf(t, map[[2]string]string{{"home", "grafana-2"}: "pw"}, keycloakSpec) - w.bus.putPlan("home", "grafana-2", map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "keycloak", - "as": agent, "contributes": grafanaContributes, "names": grafanaNames}) - for _, o := range w.pass() { - if o.Status != provider.Answered { - t.Fatalf("%+v", o) - } - } - if len(w.a.created) != 1 || w.a.created[0].As != agent { - t.Fatalf("made %+v, want one under %s", w.a.created, agent) - } - for i := 0; i < 8; i++ { - w.pass() - } - w.now = w.now.Add(15 * time.Minute) - w.pass() - w.pass() - if len(w.a.removed) != 0 { - t.Fatalf("retired %v: the plan's client id was counted unasked", w.a.removed) - } -} - -// TestMoved holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). -func TestMoved(t *testing.T) { - t.Run("TheConsumersFileMakesWhatTheGrantFilesMake", testTheConsumersFileMakesWhatTheGrantFilesMake) - t.Run("AConsumerMissingFromOneReadRetiresNothing", testAConsumerMissingFromOneReadRetiresNothing) - t.Run("NoPlansReadRetiresNothing", testNoPlansReadRetiresNothing) - t.Run("AConsumerWithoutACredentialIsWaitedFor", testAConsumerWithoutACredentialIsWaitedFor) - t.Run("ASluggedConsumerKeepsTheLoginItsPlanNames", testASluggedConsumerKeepsTheLoginItsPlanNames) - t.Run("ASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile", testASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile) - t.Run("TheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape", testTheConsumersFileMakesWhatTheGrantFileMakesForTheKeycloakShape) - t.Run("AConsumerNamedByItsPlanKeepsItsClientId", testAConsumerNamedByItsPlanKeepsItsClientId) -} diff --git a/go/provider/harness/retirement.go b/go/provider/harness/retirement.go deleted file mode 100644 index 180213a..0000000 --- a/go/provider/harness/retirement.go +++ /dev/null @@ -1,626 +0,0 @@ -package harness - -// What becomes of a consumer the mesh no longer asks for (novox/hq ADR 0230 — the operator's model, -// decided 2026-10-06; it replaces ADR 0229's withdrawal brake, which let one consumer go every hour). -// -// A consumer — a login, a client, a key, and the data behind it — is in one of three states: -// -// 1. ACTIVE. -// 2. RETIRED. The mesh stopped asking for it: its access is disabled, reversibly, and its login and -// data are marked "to delete" with when and why. Nothing is deleted. Asked for again, the ordinary -// create re-enables it at once, as it was. -// 3. DELETED, only through `cleanup delete`, a person's act, which this provider executes because it -// owns its backend. -// -// **Stable removals.** A consumer is retired only once the same set of consumers has gone unasked BOTH -// in StablePasses (5) consecutive passes that read the file AND for at least StableFor (10 minutes) -// since the first pass that saw it. Five passes alone are twenty-five seconds — shorter than a -// controller restart, a store reconnecting or a file half written. A pass that could not read the file -// is not a result and starts both again; so does a different set. Additions and changes are never -// delayed. -// -// **Too many is a person.** A stable set of more than RetireAtOnce (3), or — where more than one is -// held — of more than RetireFraction (half) of those held, retires nothing: it WAITS, said in the -// journal and announced `provisioner.retirement` `waiting` (again every SayAgainEvery), which the -// controller raises as an urgent condition, until `retire approve ` or `retire reject`. -// An unassignment the controller made itself is no exception: many changes at once means a person is -// at work, and a person confirms once. On 2026-10-04 one misread file withdrew seven consumers at once -// (issue 241); under this rule that is a question, not an act. -// -// **The backend remembers, not this process.** What is retired, since when and why is read from the -// backend's own mark (Adapter.Inventory), so a restart forgets nothing; a consumer the backend holds -// active that the mesh no longer asks for is retired by the same rules after a restart as before one. -// A consumer found disabled without the mark — withdrawn before this record — is ADOPTED as retired: -// marked, said, announced `adopted`, and its clock starts then. -// -// **A rejection lives as long as this process.** Rejected, the set is kept active and not asked about -// again while it stays the same set; a restart asks again — loudly, never silently. - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" -) - -// EventRetirement is the one event a provider says about retirement, its `change` saying what -// happened. The controller derives the permission to emit it for every module that receives -// contributions, as it does the standing events (novox/hq ADR 0224, 0230). -const EventRetirement = "provisioner.retirement" - -// The changes EventRetirement carries. -const ( - ChangeWaiting = "waiting" // a stable set over the bound waits for a person - ChangeSettled = "settled" // a waiting or rejected set ended without being retired - ChangeApproved = "approved" // a person approved the waiting (or rejected) set - ChangeRejected = "rejected" // a person kept the waiting set active - ChangeRetired = "retired" // consumers disabled and marked to delete - ChangeReenabled = "reenabled" // a retired consumer asked for again, enabled as it was - ChangeDeleted = "deleted" // a retired consumer deleted, by a person - ChangeAdopted = "adopted" // found disabled without the mark, now retired on record -) - -// StableFor is the least time the same unasked set must hold before it is retired (novox/hq ADR 0230): -// longer than a controller restart, a store reconnecting or a file half written. -const StableFor = 10 * time.Minute - -// KindConsumer is a retired consumer. A backend may list other things set aside for deletion under a -// word of its own (postgres: a database renamed aside). -const KindConsumer = "consumer" - -// Retired is one thing a provider holds retired, as its backend's mark says. -type Retired struct { - Consumer string - // Node is the consumer's machine, where the mark records it. - Node string - // RetiredAt is when it was retired; zero for one found disabled without the mesh's mark. - RetiredAt time.Time - Why string - // SizeBytes is what deleting it would free; -1 when the backend cannot say. - SizeBytes int64 - Kind string -} - -// Inventory is what a backend holds that the mesh made. -type Inventory struct { - Active []string - Retired []Retired - // Sizes is what each active consumer keeps on the backend, in bytes, where the backend can say: what - // the controller tells an empty replacement of a consumer's data by (novox/hq ADR 0233). - Sizes map[string]int64 -} - -// retirement is the loop's memory of the sets it is counting, waiting on and was told to keep. -type retirement struct { - key string // the unasked set, joined - count int // consecutive passes that saw it - firstSeen time.Time - waiting *waitingSet - rejected *rejectedSet - retired map[string]retiredEntry - lastWant map[string]bool - seeded bool - seedSaid string - // noneSaid is whether this process has said that nothing waits: an earlier process may have left a set - // waiting that this one never knew (issue 937). - noneSaid bool -} - -type waitingSet struct { - set []string - since time.Time - saidAt time.Time - held int -} - -type rejectedSet struct { - set []string - by, why string - at time.Time -} - -type retiredEntry struct { - node string - at time.Time - why string -} - -// seed reads what the backend holds, once per process: an active consumer the mesh no longer asks -// for is held, so it is retired by the same rules as one this process made; one found disabled -// without the mark is adopted as retired. -func (h *Harness) seed(ctx context.Context, want map[string]bool) { - if h.r.seeded { - return - } - inv, err := h.Adapter.Inventory(ctx) - if err != nil { - if said := err.Error(); said != h.r.seedSaid { - h.say("cannot list what the backend holds (%v); a consumer the mesh stopped asking for while this "+ - "provider was down is not retired until it can", err) - h.r.seedSaid = said - } - return - } - h.r.seeded = true - for _, as := range inv.Active { - if _, held := h.applied[as]; !held && !want[as] { - // No hash: asked for again, it is applied again, which is harmless and marks it. - h.applied[as] = appliedEntry{} - h.say("%s: held by the backend and no longer asked for; retired once that holds for %d passes "+ - "and %s (novox/hq ADR 0230)", as, h.StablePasses, h.StableFor) - } - } - now := h.Now() - for _, r := range inv.Retired { - if r.Kind != "" && r.Kind != KindConsumer { - continue - } - if !r.RetiredAt.IsZero() { - h.r.retired[r.Consumer] = retiredEntry{node: r.Node, at: r.RetiredAt, why: r.Why} - continue - } - if want[r.Consumer] { - continue // asked for: this pass's create enables it - } - why := "found disabled without the mesh's mark — withdrawn before retirement was recorded " + - "(novox/hq ADR 0230); retired as found" - if err := h.Adapter.Retire(ctx, r.Consumer, nil, why, now); err != nil { - h.say("%s: found disabled and could not be marked retired, will try at the next start: %v", r.Consumer, err) - continue - } - h.r.retired[r.Consumer] = retiredEntry{node: r.Node, at: now, why: why} - h.say("%s: ADOPTED as retired — %s", r.Consumer, why) - h.announce(EventRetirement, h.retirementBody(ChangeAdopted, []map[string]any{ - {"consumer": r.Consumer, "node": r.Node, "retired_at": stamp(now), "why": why, "size_bytes": r.SizeBytes}, - }, map[string]any{"why": why})) - } -} - -// retireUnasked counts the passes that saw the same set no longer asked for and, once it is stable, -// retires it — or, past the bound, waits for a person. -func (h *Harness) retireUnasked(ctx context.Context, want map[string]bool) { - var unasked []string - for as := range h.applied { - if !want[as] { - unasked = append(unasked, as) - } - } - sort.Strings(unasked) - key := strings.Join(unasked, "\x00") - now := h.Now() - switch { - case len(unasked) == 0: - h.settle("every consumer held is asked for again") - h.r.key, h.r.count = "", 0 - return - case key != h.r.key: - if h.r.waiting != nil || h.r.rejected != nil { - h.settle("the set the mesh no longer asks for changed") - } - h.r.key, h.r.count, h.r.firstSeen = key, 1, now - h.say("no longer asked for: %s; retired once the same set holds for %d passes and %s", - strings.Join(unasked, ", "), h.StablePasses, h.StableFor) - default: - h.r.count++ - } - if h.r.rejected != nil || h.r.count < h.StablePasses || now.Sub(h.r.firstSeen) < h.StableFor { - return - } - if h.overTheBound(len(unasked), len(h.applied)) { - h.wait(unasked) - return - } - why := fmt.Sprintf("the mesh stopped asking for it: the same result in %d consecutive passes over %s, from %s", - h.r.count, now.Sub(h.r.firstSeen).Round(time.Second), stamp(h.r.firstSeen)) - h.retire(ctx, unasked, why, nil) -} - -// overTheBound says retiring n of the held consumers at once needs a person. -func (h *Harness) overTheBound(n, held int) bool { - if n == 0 { - return false - } - return n > h.RetireAtOnce || (held > 1 && float64(n) > h.RetireFraction*float64(held)) -} - -func (h *Harness) bound(held int) string { - return fmt.Sprintf("more than %d at once, or more than %.0f%% of the %d held", h.RetireAtOnce, - h.RetireFraction*100, held) -} - -// wait holds a stable set over the bound for a person, said once and announced again every -// SayAgainEvery so a controller that missed the first hears the next. -func (h *Harness) wait(set []string) { - now := h.Now() - w := h.r.waiting - if w == nil { - w = &waitingSet{set: set, since: now, held: len(h.applied)} - h.r.waiting = w - h.say("RETIREMENT WAITS FOR A PERSON: the mesh no longer asks for %d of the %d consumer(s) this provider "+ - "holds (%s), %s. Nothing is retired; each stays active until `retire approve %s ` or "+ - "`retire reject` (novox/hq ADR 0230)", len(set), w.held, strings.Join(set, ", "), h.bound(w.held), h.Node) - } else if now.Sub(w.saidAt) < h.SayAgainEvery { - return - } - w.saidAt = now - h.announce(EventRetirement, h.retirementBody(ChangeWaiting, h.named(set), map[string]any{ - "held": w.held, "bound": h.bound(w.held), "since": stamp(w.since), - })) -} - -// settle ends a waiting or rejected set that the mesh's own answer made moot. -func (h *Harness) settle(why string) { - var set []string - switch { - case h.r.waiting != nil: - set = h.r.waiting.set - case h.r.rejected != nil: - set = h.r.rejected.set - case h.r.noneSaid: - return - default: - // A restart forgets a set an earlier process waited on, and so would never say it ended: the - // controller's condition would ask a person to approve what nobody waits on any more (issue 937). - // The first time this process finds every consumer held asked for, it says so once, with no set. - h.r.noneSaid = true - h.say("nothing waits for a person: %s", why) - h.announce(EventRetirement, h.retirementBody(ChangeSettled, []map[string]any{}, map[string]any{"why": why})) - return - } - h.r.waiting, h.r.rejected = nil, nil - h.say("retirement of %s settled without retiring: %s", strings.Join(set, ", "), why) - h.announce(EventRetirement, h.retirementBody(ChangeSettled, h.named(set), map[string]any{"why": why})) -} - -// retire disables and marks each consumer of set; one that fails stays held and is tried again once -// its set is stable again. -func (h *Harness) retire(ctx context.Context, set []string, why string, extra map[string]any) []string { - now := h.Now() - held := len(h.applied) - var done []string - var said []map[string]any - for _, as := range set { - was, ok := h.applied[as] - if !ok { - continue - } - if err := h.Adapter.Retire(ctx, as, was.derived, why, now); err != nil { - h.say("%s: retiring failed, will try again: %v", as, err) - continue - } - delete(h.applied, as) - delete(h.lost, as) - delete(h.failing, as) - h.r.retired[as] = retiredEntry{node: was.node, at: now, why: why} - h.recovered(as, "retired") - h.say("%s: RETIRED — its access disabled and its data kept, marked to delete (%s). Asked for again "+ - "it is re-enabled as it was; it is deleted only by `cleanup delete` (novox/hq ADR 0230)", as, why) - done = append(done, as) - said = append(said, map[string]any{"consumer": as, "node": was.node, "retired_at": stamp(now), "why": why}) - } - h.r.key, h.r.count, h.r.waiting, h.r.rejected = "", 0, nil, nil - if len(done) > 0 { - body := map[string]any{"held": held, "why": why} - for k, v := range extra { - body[k] = v - } - h.announce(EventRetirement, h.retirementBody(ChangeRetired, said, body)) - } - return done -} - -// reenabled says a retired consumer was asked for again and its create enabled it. -func (h *Harness) reenabled(as, node string) { - e, was := h.r.retired[as] - if !was { - return - } - delete(h.r.retired, as) - h.say("%s: asked for again — re-enabled as it was, its mark to delete cleared (retired %s: %s)", as, - stamp(e.at), e.why) - h.announce(EventRetirement, h.retirementBody(ChangeReenabled, []map[string]any{ - {"consumer": as, "node": node, "retired_at": stamp(e.at), "why": e.why}, - }, nil)) -} - -// Approve retires exactly the set waiting (or the set rejected) — a person's confirmation. -func (h *Harness) Approve(ctx context.Context, consumers []string, why, by, via string) ([]string, error) { - h.mu.Lock() - defer h.mu.Unlock() - h.init() - if strings.TrimSpace(why) == "" { - return nil, errors.New("approve: say why") - } - set := sorted(consumers) - var held []string - switch { - case h.r.waiting != nil && same(set, h.r.waiting.set): - held = h.r.waiting.set - case h.r.rejected != nil && same(set, h.r.rejected.set): - held = h.r.rejected.set - default: - return nil, fmt.Errorf("approve: %s is not the set waiting here — %s", orNone(set), h.waitingWords()) - } - h.say("retirement of %s APPROVED by %s: %s", strings.Join(held, ", "), orSomebody(by), why) - h.announce(EventRetirement, h.retirementBody(ChangeApproved, h.named(held), - map[string]any{"why": why, "by": by, "via": via})) - reason := fmt.Sprintf("the mesh stopped asking for it; approved by %s: %s", orSomebody(by), why) - return h.retire(ctx, held, reason, map[string]any{"by": by, "via": via}), nil -} - -// Reject keeps the waiting set active; it is not asked about again while it stays the same set. -func (h *Harness) Reject(consumers []string, why, by, via string) ([]string, error) { - h.mu.Lock() - defer h.mu.Unlock() - h.init() - if strings.TrimSpace(why) == "" { - return nil, errors.New("reject: say why") - } - set := sorted(consumers) - if h.r.waiting == nil || !same(set, h.r.waiting.set) { - return nil, fmt.Errorf("reject: %s is not the set waiting here — %s", orNone(set), h.waitingWords()) - } - h.r.rejected = &rejectedSet{set: h.r.waiting.set, by: by, why: why, at: h.Now()} - h.r.waiting = nil - h.say("retirement of %s REJECTED by %s: %s. Kept active, and not asked about again while the mesh goes on "+ - "not asking for exactly these (until this provider restarts)", strings.Join(set, ", "), orSomebody(by), why) - h.announce(EventRetirement, h.retirementBody(ChangeRejected, h.named(set), - map[string]any{"why": why, "by": by, "via": via})) - return set, nil -} - -// DeleteRetired deletes one retired consumer, by a person's word: never an active one, never one the -// mesh asks for. -func (h *Harness) DeleteRetired(ctx context.Context, consumer, why, by, via string) (int64, error) { - h.mu.Lock() - defer h.mu.Unlock() - h.init() - if strings.TrimSpace(why) == "" { - return 0, errors.New("delete: say why") - } - if h.r.lastWant[consumer] { - return 0, fmt.Errorf("delete: the mesh asks for %s — it is not retired", consumer) - } - if _, held := h.applied[consumer]; held { - return 0, fmt.Errorf("delete: %s is active here — only a retired consumer is deleted", consumer) - } - inv, err := h.Adapter.Inventory(ctx) - if err != nil { - return 0, fmt.Errorf("delete: what the backend holds cannot be read, so nothing is deleted: %w", err) - } - var found *Retired - for i := range inv.Retired { - if inv.Retired[i].Consumer == consumer { - found = &inv.Retired[i] - } - } - if found == nil { - return 0, fmt.Errorf("delete: %s is not retired here — only a retired consumer is deleted", consumer) - } - freed, err := h.Adapter.Delete(ctx, *found) - if err != nil { - return 0, err - } - delete(h.r.retired, consumer) - h.say("%s: DELETED by %s: %s (retired %s: %s; %d bytes freed)", consumer, orSomebody(by), why, - stampOr(found.RetiredAt), found.Why, freed) - h.announce(EventRetirement, h.retirementBody(ChangeDeleted, []map[string]any{{ - "consumer": consumer, "node": found.Node, "retired_at": stampOr(found.RetiredAt), "why": found.Why, - "size_bytes": found.SizeBytes, "kind": kindOf(*found), - }}, map[string]any{"why": why, "by": by, "via": via, "freed_bytes": freed})) - return freed, nil -} - -// Retirement is what a person (and the controller's `cleanup list` and its probe) asks: what is -// held, waiting, rejected and retired here. -func (h *Harness) Retirement(ctx context.Context) (map[string]any, error) { - h.mu.Lock() - defer h.mu.Unlock() - h.init() - inv, err := h.Adapter.Inventory(ctx) - if err != nil { - return nil, err - } - var held []string - for as := range h.applied { - held = append(held, as) - } - sort.Strings(held) - retired := []map[string]any{} - for _, r := range inv.Retired { - retired = append(retired, map[string]any{"consumer": r.Consumer, "node": r.Node, - "retired_at": stampOr(r.RetiredAt), "why": r.Why, "size_bytes": r.SizeBytes, "kind": kindOf(r)}) - } - sizes := map[string]int64{} - for _, as := range held { - if size, ok := inv.Sizes[as]; ok && size >= 0 { - sizes[as] = size - } - } - out := map[string]any{"resource": h.Resource, "node": h.Node, "held": orEmptyList(held), "held_sizes": sizes, - "stable_passes": h.StablePasses, "stable_for_seconds": int(h.StableFor.Seconds()), "bound": h.bound(len(h.applied)), "waiting": nil, "rejected": nil, - "retired": retired} - if w := h.r.waiting; w != nil { - out["waiting"] = map[string]any{"consumers": h.named(w.set), "since": stamp(w.since), "held": w.held} - } - if r := h.r.rejected; r != nil { - out["rejected"] = map[string]any{"consumers": h.named(r.set), "by": r.by, "why": r.why, "at": stamp(r.at)} - } - return out, nil -} - -func (h *Harness) waitingWords() string { - switch { - case h.r.waiting != nil: - return "waiting: " + strings.Join(h.r.waiting.set, ", ") - case h.r.rejected != nil: - return "nothing waits; rejected and kept: " + strings.Join(h.r.rejected.set, ", ") - } - return "nothing waits for a person here" -} - -// named is a set with each consumer's machine, as the events and the tools say it. -func (h *Harness) named(set []string) []map[string]any { - out := make([]map[string]any, 0, len(set)) - for _, as := range set { - out = append(out, map[string]any{"consumer": as, "node": h.applied[as].node}) - } - return out -} - -func (h *Harness) retirementBody(change string, consumers []map[string]any, extra map[string]any) map[string]any { - body := map[string]any{"provider": h.Resource, "provider-node": h.Node, "change": change, - "consumers": consumers, "at": stamp(h.Now())} - for k, v := range extra { - body[k] = v - } - return body -} - -// RetirementTools are the four tools every provider serves for retirement, the same names in every -// module: a caller asks them on the provider's machine, with the operator's answer named in why and by. -func RetirementTools(h *Harness) []stdio.Tool { - if h == nil { - return nil - } - ask := func() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 2*time.Minute) - } - who := map[string]any{ - "why": map[string]any{"type": "string", "description": "why — required, kept in the hand-act log"}, - "by": map[string]any{"type": "string", "description": "who decided"}, - "via": map[string]any{"type": "string", "description": "the part or session that asks, when not the caller itself"}, - } - withSet := map[string]any{"consumers": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "the set, exactly as provisioner_retirement names it"}} - for k, v := range who { - withSet[k] = v - } - withOne := map[string]any{ - "consumer": map[string]any{"type": "string", "description": "the retired consumer to delete"}, - "confirm": map[string]any{"type": "string", "description": "the consumer's name again, to say this is meant"}, - } - for k, v := range who { - withOne[k] = v - } - return []stdio.Tool{ - {Name: "provisioner_retirement", - Description: "What this provider holds, what waits for a person to approve its retirement, what was rejected, " + - "and every retired consumer with when, why and its size (novox/hq ADR 0230).", - Run: func(map[string]any) (any, error) { - ctx, cancel := ask() - defer cancel() - return h.Retirement(ctx) - }}, - {Name: "provisioner_retire_approve", - Description: "Retire exactly the set waiting for a person (or the set rejected earlier): access disabled, data " + - "kept and marked to delete. It asks nobody: the operator's answer on the operator channel to the provisioner's " + - "ask (novox/hq ADR 0570 §5) is named in why and by, which the hand-act log keeps.", - Input: withSet, - Run: func(args map[string]any) (any, error) { - ctx, cancel := ask() - defer cancel() - done, err := h.Approve(ctx, strs(args["consumers"]), argString(args, "why"), argString(args, "by"), argString(args, "via")) - if err != nil { - return nil, err - } - return map[string]any{"retired": orEmptyList(done)}, nil - }}, - {Name: "provisioner_retire_reject", - Description: "Keep the set waiting for a person active. It asks nobody: the operator's answer on the operator channel " + - "(novox/hq ADR 0570 §5) is named in why and by, which the hand-act log keeps.", - Input: withSet, - Run: func(args map[string]any) (any, error) { - kept, err := h.Reject(strs(args["consumers"]), argString(args, "why"), argString(args, "by"), argString(args, "via")) - if err != nil { - return nil, err - } - return map[string]any{"kept": kept}, nil - }}, - {Name: "provisioner_delete", - Description: "Delete one RETIRED consumer — its login and its data — for good. Refused for anything active or " + - "asked for. It asks nobody: ask the operator on the operator channel first, and name that answer in why and " + - "by, which the hand-act log keeps (novox/hq ADR 0604 §8).", - Input: withOne, - Run: func(args map[string]any) (any, error) { - consumer := argString(args, "consumer") - if consumer == "" || argString(args, "confirm") != consumer { - return nil, errors.New("delete: name the consumer, and repeat it in confirm") - } - ctx, cancel := ask() - defer cancel() - freed, err := h.DeleteRetired(ctx, consumer, argString(args, "why"), argString(args, "by"), argString(args, "via")) - if err != nil { - return nil, err - } - return map[string]any{"deleted": consumer, "freed_bytes": freed}, nil - }}, - } -} - -func strs(v any) []string { - list, _ := v.([]any) - out := []string{} - for _, x := range list { - if s, ok := x.(string); ok && s != "" { - out = append(out, s) - } - } - return out -} - -func sorted(list []string) []string { - out := append([]string(nil), list...) - sort.Strings(out) - return out -} - -func same(a, b []string) bool { - return strings.Join(sorted(a), "\x00") == strings.Join(sorted(b), "\x00") -} - -func orNone(set []string) string { - if len(set) == 0 { - return "an empty set" - } - return strings.Join(set, ", ") -} - -func orSomebody(by string) string { - if by == "" { - return "a person" - } - return by -} - -func orEmptyList(list []string) []string { - if list == nil { - return []string{} - } - return list -} - -func kindOf(r Retired) string { - if r.Kind == "" { - return KindConsumer - } - return r.Kind -} - -func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339) } - -func stampOr(t time.Time) string { - if t.IsZero() { - return "" - } - return stamp(t) -} - -func argString(args map[string]any, key string) string { - s, _ := args[key].(string) - return s -} diff --git a/go/provider/harness/retirement_test.go b/go/provider/harness/retirement_test.go deleted file mode 100644 index 4fd29e2..0000000 --- a/go/provider/harness/retirement_test.go +++ /dev/null @@ -1,596 +0,0 @@ -package harness - -// Retirement (novox/hq ADR 0230), as the shared loop does it: a consumer the mesh stops asking for is -// retired only after the same result in five consecutive passes, too many at once wait for a person, -// asked for again it is re-enabled, and only a person deletes. The same file in every Go provider. - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "testing" - "time" -) - -// recorder is a backend that remembers what it holds, active and retired, as a real one's mark does. -type recorder struct { - created []Provision - removed []string // retired, in order - deleted []string - held bool - failing error - holdsErr error - retErr error - inv Inventory - invErr error -} - -func (r *recorder) Create(_ context.Context, p Provision) error { - if r.failing != nil { - return r.failing - } - r.created = append(r.created, p) - r.inv.Retired = withoutRetired(r.inv.Retired, p.As) - if !listHas(r.inv.Active, p.As) { - r.inv.Active = append(r.inv.Active, p.As) - } - return nil -} - -func (r *recorder) Retire(_ context.Context, as string, _ map[string]any, why string, at time.Time) error { - if r.retErr != nil { - return r.retErr - } - r.removed = append(r.removed, as) - r.inv.Active = without(r.inv.Active, as) - r.inv.Retired = append(withoutRetired(r.inv.Retired, as), - Retired{Consumer: as, RetiredAt: at, Why: why, SizeBytes: 42, Kind: KindConsumer}) - return nil -} - -func (r *recorder) Holds(context.Context, Provision) (bool, error) { - if r.holdsErr != nil { - return false, r.holdsErr - } - return r.held, nil -} - -func (r *recorder) Inventory(context.Context) (Inventory, error) { return r.inv, r.invErr } - -func (r *recorder) Delete(_ context.Context, x Retired) (int64, error) { - r.deleted = append(r.deleted, x.Consumer) - r.inv.Retired = withoutRetired(r.inv.Retired, x.Consumer) - return x.SizeBytes, nil -} - -func listHas(list []string, s string) bool { - for _, x := range list { - if x == s { - return true - } - } - return false -} - -func without(list []string, s string) []string { - var out []string - for _, x := range list { - if x != s { - out = append(out, x) - } - } - return out -} - -func withoutRetired(list []Retired, s string) []Retired { - var out []Retired - for _, x := range list { - if x.Consumer != s { - out = append(out, x) - } - } - return out -} - -// holding gives the provider n consumers c1…cn and applies them. -func holding(w *world, n int) []map[string]any { - var given []map[string]any - for i := 1; i <= n; i++ { - given = append(given, map[string]any{"as": fmt.Sprintf("c%d", i), "node": "anchor"}) - } - w.give(given...) - w.h.Reconcile(ctx) - return given -} - -// pass is one reconcile five seconds after the last. -func (w *world) pass() { - w.now = w.now.Add(5 * time.Second) - w.h.Reconcile(ctx) -} - -func retirements(said []announced) []string { - var out []string - for _, a := range said { - if a.event == EventRetirement { - out = append(out, a.body["change"].(string)) - } - } - return out -} - -func noneWaits(a announced) bool { - c, _ := a.body["consumers"].([]map[string]any) - return a.body["change"] == ChangeSettled && len(c) == 0 -} - -func lastRetirement(t *testing.T, said []announced) map[string]any { - t.Helper() - for i := len(said) - 1; i >= 0; i-- { - if said[i].event == EventRetirement { - return said[i].body - } - } - t.Fatal("nothing about retirement was announced") - return nil -} - -func namesOf(body map[string]any) string { - var out []string - for _, c := range body["consumers"].([]map[string]any) { - out = append(out, c["consumer"].(string)) - } - return strings.Join(out, ",") -} - -// settle passes every five seconds until the same answer has held for StableFor, and one pass more. -func (w *world) settle() { w.passes(StableFor + 5*time.Second) } - -func testATransientEmptyListForFourPassesRetiresNothing(t *testing.T) { - w, said := standingWorld(t) - given := holding(w, 1) - w.give() - for i := 0; i < 4; i++ { - w.pass() - } - w.give(given...) - w.pass() - if len(w.a.removed) != 0 || len(retirements(*said)) != 0 { - t.Fatalf("four passes retired %v and said %v", w.a.removed, retirements(*said)) - } -} - -// Five identical passes are twenty-five seconds — shorter than a controller restart. Both must hold: -// five passes AND ten minutes of the same answer (novox/hq ADR 0230). -func testFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo(t *testing.T) { - w, said := standingWorld(t) - holding(w, 1) - w.give() - for i := 0; i < 5; i++ { - w.pass() - } - if len(w.a.removed) != 0 || len(retirements(*said)) != 0 { - t.Fatalf("five passes in 25 s retired %v", w.a.removed) - } - w.passes(StableFor - 30*time.Second) - if len(w.a.removed) != 0 { - t.Fatalf("retired before the set held %s: %v", StableFor, w.a.removed) - } - w.passes(time.Minute) - if strings.Join(w.a.removed, ",") != "c1" { - t.Fatalf("the same set held %s and was not retired: %v", StableFor, w.a.removed) - } - // Ten minutes in one slow pass are not five passes. - w2 := newWorld(t) - holding(w2, 1) - w2.give() - w2.pass() - w2.now = w2.now.Add(StableFor) - w2.pass() - if len(w2.a.removed) != 0 { - t.Fatalf("two passes ten minutes apart retired %v", w2.a.removed) - } - w2.passes(15 * time.Second) - if len(w2.a.removed) != 1 { - t.Fatalf("five passes over ten minutes did not retire: %v", w2.a.removed) - } -} - -func testAStableSetIsRetiredAndAskedAgainReEnables(t *testing.T) { - w, said := standingWorld(t) - given := holding(w, 3) - w.give(given[:2]...) - w.settle() - if strings.Join(w.a.removed, ",") != "c3" { - t.Fatalf("retired %v", w.a.removed) - } - body := lastRetirement(t, *said) - if body["change"] != ChangeRetired || namesOf(body) != "c3" || body["held"] != 3 || body["provider-node"] != "anchor" || - !strings.Contains(body["why"].(string), "consecutive passes over 10m") { - t.Fatalf("%v", body) - } - if len(w.a.inv.Retired) != 1 || w.a.inv.Retired[0].Consumer != "c3" { - t.Fatalf("the backend does not hold it retired: %+v", w.a.inv) - } - // Asked for again: the ordinary create, on the first pass, and said. - created := len(w.a.created) - w.give(given...) - w.pass() - if len(w.a.created) != created+1 || w.a.created[created].As != "c3" { - t.Fatalf("not created again: %v", w.a.created) - } - if body := lastRetirement(t, *said); body["change"] != ChangeReenabled || namesOf(body) != "c3" { - t.Fatalf("%v", body) - } - if len(w.a.inv.Retired) != 0 { - t.Fatalf("still marked retired: %+v", w.a.inv.Retired) - } -} - -func testAnUnreadablePassStartsTheCountAgain(t *testing.T) { - w := newWorld(t) - holding(w, 1) - w.give() - w.passes(StableFor - time.Minute) - os.WriteFile(w.receives, []byte("{"), 0o600) - w.pass() - w.give() - w.passes(StableFor - time.Minute) - if len(w.a.removed) != 0 { - t.Fatalf("an unreadable pass counted: %v", w.a.removed) - } - w.passes(2 * time.Minute) - if len(w.a.removed) != 1 { - t.Fatalf("not retired after ten minutes of readable passes: %v", w.a.removed) - } -} - -func testADifferentSetStartsTheCountAgain(t *testing.T) { - w := newWorld(t) - given := holding(w, 5) - w.give(given[:4]...) - w.passes(StableFor - time.Minute) - w.give(given[:3]...) - w.passes(StableFor - time.Minute) - if len(w.a.removed) != 0 { - t.Fatalf("a changed set kept its count: %v", w.a.removed) - } - w.passes(2 * time.Minute) - if strings.Join(w.a.removed, ",") != "c4,c5" { - t.Fatalf("%v", w.a.removed) - } -} - -func testAdditionsAndChangesAreNeverDelayed(t *testing.T) { - w := newWorld(t) - holding(w, 1) - w.give(map[string]any{"as": "c1", "node": "anchor"}, map[string]any{"as": "c2"}) - w.pass() - if len(w.a.created) != 2 || w.a.created[1].As != "c2" { - t.Fatalf("an addition waited: %v", w.a.created) - } - w.give(map[string]any{"as": "c1", "node": "anchor", "values": map[string]any{"name": "rotated"}}, map[string]any{"as": "c2"}) - w.pass() - if len(w.a.created) != 3 || w.a.created[2].As != "c1" { - t.Fatalf("a change waited: %v", w.a.created) - } -} - -func testTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet(t *testing.T) { - w, said := standingWorld(t) - holding(w, 4) - w.give() - w.passes(15 * time.Minute) - if len(w.a.removed) != 0 { - t.Fatalf("four of four were retired without a person: %v", w.a.removed) - } - if got := strings.Join(retirements(*said), ","); got != ChangeWaiting { - t.Fatalf("said %q, want one waiting", got) - } - body := lastRetirement(t, *said) - if namesOf(body) != "c1,c2,c3,c4" || body["held"] != 4 || body["bound"] == "" { - t.Fatalf("%v", body) - } - if !strings.Contains(strings.Join(w.said, "\n"), "RETIREMENT WAITS FOR A PERSON") { - t.Fatal("the wait was not said") - } - // Said again every quarter of an hour while it waits. - w.passes(11 * time.Minute) - if got := strings.Join(retirements(*said), ","); got != "waiting,waiting" { - t.Fatalf("%q", got) - } - - if _, err := w.h.Approve(ctx, []string{"c1", "c2"}, "tidy", "operator", "mesh-controller"); err == nil { - t.Fatal("approved a set that is not the one waiting") - } - if _, err := w.h.Approve(ctx, []string{"c4", "c3", "c2", "c1"}, "", "operator", ""); err == nil { - t.Fatal("approved without a why") - } - done, err := w.h.Approve(ctx, []string{"c4", "c3", "c2", "c1"}, "the old machine is gone", "operator", "mesh-controller") - if err != nil || strings.Join(done, ",") != "c1,c2,c3,c4" || strings.Join(w.a.removed, ",") != "c1,c2,c3,c4" { - t.Fatal(done, err, w.a.removed) - } - changes := retirements(*said) - if strings.Join(changes[len(changes)-2:], ",") != "approved,retired" { - t.Fatalf("%v", changes) - } - if b := lastRetirement(t, *said); b["by"] != "operator" || b["via"] != "mesh-controller" || - !strings.Contains(b["why"].(string), "the old machine is gone") { - t.Fatalf("%v", b) - } - // Nothing more waits. - w.passes(time.Minute) - if r, _ := w.h.Retirement(ctx); r["waiting"] != nil || len(r["retired"].([]map[string]any)) != 4 { - t.Fatalf("%v", r) - } -} - -func testRejectedIsKeptAndNotAskedAgainUntilTheSetChanges(t *testing.T) { - w, said := standingWorld(t) - given := holding(w, 4) - w.give() - w.settle() - if _, err := w.h.Reject([]string{"c1"}, "no", "operator", ""); err == nil { - t.Fatal("rejected a set that is not the one waiting") - } - kept, err := w.h.Reject([]string{"c1", "c2", "c3", "c4"}, "a person is moving them", "operator", "mesh-controller") - if err != nil || len(kept) != 4 { - t.Fatal(kept, err) - } - w.passes(time.Hour) - if len(w.a.removed) != 0 { - t.Fatalf("a rejected set was retired: %v", w.a.removed) - } - if got := strings.Join(retirements(*said), ","); got != "waiting,rejected" { - t.Fatalf("asked again after a rejection: %q", got) - } - r, _ := w.h.Retirement(ctx) - if r["rejected"] == nil || r["waiting"] != nil { - t.Fatalf("%v", r) - } - // One of them asked for again: a different answer, so the rejection is settled and counting - // starts over — three of four is over the bound again, and waits again. - w.give(given[0]) - w.pass() - if got := strings.Join(retirements(*said), ","); got != "waiting,rejected,settled" { - t.Fatalf("%q", got) - } - w.settle() - if got := strings.Join(retirements(*said), ","); got != "waiting,rejected,settled,waiting" { - t.Fatalf("%q", got) - } - // A rejected set can still be approved later. - w2, _ := standingWorld(t) - holding(w2, 4) - w2.give() - w2.settle() - w2.h.Reject([]string{"c1", "c2", "c3", "c4"}, "wait", "operator", "") - if done, err := w2.h.Approve(ctx, []string{"c1", "c2", "c3", "c4"}, "now", "operator", ""); err != nil || len(done) != 4 { - t.Fatal(done, err) - } -} - -func testAWaitingSetAskedForAgainSettles(t *testing.T) { - w, said := standingWorld(t) - given := holding(w, 4) - w.give() - w.settle() - w.give(given...) - w.pass() - if got := strings.Join(retirements(*said), ","); got != "waiting,settled" || len(w.a.removed) != 0 { - t.Fatalf("%q %v", got, w.a.removed) - } - if _, err := w.h.Approve(ctx, []string{"c1", "c2", "c3", "c4"}, "late", "operator", ""); err == nil { - t.Fatal("approved a set that no longer waits") - } -} - -func testDeleteRemovesOnlyThatRetiredConsumer(t *testing.T) { - w, said := standingWorld(t) - given := holding(w, 5) - w.give(given[:3]...) - w.settle() - if strings.Join(w.a.removed, ",") != "c4,c5" { - t.Fatalf("%v", w.a.removed) - } - if _, err := w.h.DeleteRetired(ctx, "c1", "tidy", "operator", ""); err == nil { - t.Fatal("deleted an active consumer") - } - if _, err := w.h.DeleteRetired(ctx, "c5", "", "operator", ""); err == nil { - t.Fatal("deleted without a why") - } - if _, err := w.h.DeleteRetired(ctx, "nobody", "tidy", "operator", ""); err == nil { - t.Fatal("deleted something not retired") - } - freed, err := w.h.DeleteRetired(ctx, "c5", "the experiment is over", "operator", "mesh-controller") - if err != nil || freed != 42 || strings.Join(w.a.deleted, ",") != "c5" { - t.Fatal(freed, err, w.a.deleted) - } - if b := lastRetirement(t, *said); b["change"] != ChangeDeleted || namesOf(b) != "c5" || b["freed_bytes"] != int64(42) { - t.Fatalf("%v", b) - } - r, _ := w.h.Retirement(ctx) - if left := r["retired"].([]map[string]any); len(left) != 1 || left[0]["consumer"] != "c4" { - t.Fatalf("%v", r) - } - // Retired and asked for again before anybody deleted it: refused, it is the mesh's again. - w.give(given[:4]...) - w.pass() - if _, err := w.h.DeleteRetired(ctx, "c4", "tidy", "operator", ""); err == nil { - t.Fatal("deleted a consumer the mesh asks for") - } -} - -func testTheBound(t *testing.T) { - h := &Harness{} - h.init() - for _, c := range []struct{ n, held int }{{1, 1}, {1, 2}, {1, 3}, {3, 7}, {3, 6}, {2, 5}} { - if h.overTheBound(c.n, c.held) { - t.Errorf("%d of %d waits for a person", c.n, c.held) - } - } - for _, c := range []struct{ n, held int }{{2, 2}, {2, 3}, {4, 7}, {4, 10}, {7, 7}} { - if !h.overTheBound(c.n, c.held) { - t.Errorf("%d of %d is retired without a person", c.n, c.held) - } - } -} - -func testARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled(t *testing.T) { - w, said := standingWorld(t) - w.a.inv = Inventory{Active: []string{"kept", "orphan"}, Retired: []Retired{ - {Consumer: "locked-before", SizeBytes: 7, Kind: KindConsumer}, - {Consumer: "old_deleted_20261005", RetiredAt: time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC), Kind: "set-aside-database"}, - }} - w.give(map[string]any{"as": "kept"}) - w.h.Reconcile(ctx) - if b := lastRetirement(t, *said); b["change"] != ChangeAdopted || namesOf(b) != "locked-before" { - t.Fatalf("%v", b) - } - if strings.Join(w.a.removed, ",") != "locked-before" { - t.Fatalf("adopting did not mark it: %v", w.a.removed) - } - w.settle() - if strings.Join(w.a.removed, ",") != "locked-before,orphan" { - t.Fatalf("an orphan the backend holds was not retired: %v", w.a.removed) - } -} - -func testABackendThatCannotBeListedIsSaidAndAskedAgain(t *testing.T) { - w := newWorld(t) - w.a.invErr = errors.New("connection refused") - holding(w, 1) - if !strings.Contains(strings.Join(w.said, "\n"), "cannot list what the backend holds") { - t.Fatal(w.said) - } - w.a.invErr = nil - w.a.inv = Inventory{Active: []string{"c1", "orphan"}} - w.pass() - w.settle() - if strings.Join(w.a.removed, ",") != "orphan" { - t.Fatalf("%v", w.a.removed) - } -} - -func testTheToolsAnswer(t *testing.T) { - w, _ := standingWorld(t) - holding(w, 4) - w.give() - w.settle() - tools := map[string]func(map[string]any) (any, error){} - for _, tool := range RetirementTools(w.h) { - tools[tool.Name] = tool.Run - // L3: the delete, approve and reject name no removed controller verb, and say where the operator answers (ADR 0604 §8). - if tool.Name != "provisioner_retirement" && (strings.Contains(tool.Description, "controller") || !strings.Contains(tool.Description, "operator channel")) { - t.Errorf("%s says %q", tool.Name, tool.Description) - } - } - if len(tools) != 4 { - t.Fatalf("%d tools", len(tools)) - } - got, err := tools["provisioner_retirement"](nil) - if err != nil || got.(map[string]any)["waiting"] == nil { - t.Fatal(got, err) - } - set := []any{"c1", "c2", "c3", "c4"} - if _, err := tools["provisioner_retire_approve"](map[string]any{"consumers": set}); err == nil { - t.Fatal("approved without a why") - } - if _, err := tools["provisioner_retire_approve"](map[string]any{"consumers": set, "why": "gone", "by": "operator"}); err != nil { - t.Fatal(err) - } - if _, err := tools["provisioner_delete"](map[string]any{"consumer": "c1", "why": "gone"}); err == nil { - t.Fatal("deleted without confirm") - } - if _, err := tools["provisioner_delete"](map[string]any{"consumer": "c1", "confirm": "c1", "why": "gone"}); err != nil { - t.Fatal(err) - } - if strings.Join(w.a.deleted, ",") != "c1" { - t.Fatal(w.a.deleted) - } -} - -func testAFailingConsumerRetiredIsSaidRecovered(t *testing.T) { - w, said := standingWorld(t) - given := holding(w, 2) - w.a.held = false - w.a.failing = errors.New("boom") - w.passes(7 * time.Minute) - w.give(given[0]) - w.settle() - recovered := false - for _, a := range *said { - if a.event == EventRecovered && a.body["consumer"] == "c2" && a.body["why"] == "retired" { - recovered = true - } - } - if !recovered { - t.Fatalf("%v", *said) - } -} - -// everyWord is a world that keeps every word the provider announces. -func everyWord(t *testing.T) (*world, *[]announced) { - w := newWorld(t) - var said []announced - w.h.Announce = func(e string, b map[string]any) { said = append(said, announced{e, b}) } - w.h.Node = "anchor" - return w, &said -} - -// A restarted provider that finds every consumer it holds asked for says once that nothing waits, so the controller -// clears a waiting condition an earlier process raised and this one never knew (issue 937); it says it no more. -func testARestartedProviderSaysOnceThatNothingWaits(t *testing.T) { - w, said := everyWord(t) - holding(w, 2) - w.pass() - w.pass() - var words []announced - for _, a := range *said { - if a.event == EventRetirement { - words = append(words, a) - } - } - if len(words) != 1 || !noneWaits(words[0]) || words[0].body["provider-node"] != w.h.Node { - t.Fatalf("said %+v, want one settled with no set", words) - } -} - -// A provider whose own set waits does not say that nothing waits. -func testAProviderThatWaitsDoesNotSayNothingWaits(t *testing.T) { - w, said := everyWord(t) - w.a.inv.Active = []string{"c1", "c2", "c3", "c4", "c5"} - w.give(map[string]any{"as": "c1", "node": "anchor"}) - w.settle() - for _, a := range *said { - if a.event == EventRetirement && noneWaits(a) { - t.Fatalf("said nothing waits while %v waited", w.h.r.waiting) - } - } - if w.h.r.waiting == nil { - t.Fatal("nothing waited: the test does not hold the case") - } -} - -// TestRetirement holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). -func TestRetirement(t *testing.T) { - t.Run("ATransientEmptyListForFourPassesRetiresNothing", testATransientEmptyListForFourPassesRetiresNothing) - t.Run("FivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo", testFivePassesInTwentyFiveSecondsRetireNothingTenMinutesDo) - t.Run("AStableSetIsRetiredAndAskedAgainReEnables", testAStableSetIsRetiredAndAskedAgainReEnables) - t.Run("AnUnreadablePassStartsTheCountAgain", testAnUnreadablePassStartsTheCountAgain) - t.Run("ADifferentSetStartsTheCountAgain", testADifferentSetStartsTheCountAgain) - t.Run("AdditionsAndChangesAreNeverDelayed", testAdditionsAndChangesAreNeverDelayed) - t.Run("TooManyWaitsForAPersonAndApproveRetiresExactlyThatSet", testTooManyWaitsForAPersonAndApproveRetiresExactlyThatSet) - t.Run("RejectedIsKeptAndNotAskedAgainUntilTheSetChanges", testRejectedIsKeptAndNotAskedAgainUntilTheSetChanges) - t.Run("AWaitingSetAskedForAgainSettles", testAWaitingSetAskedForAgainSettles) - t.Run("DeleteRemovesOnlyThatRetiredConsumer", testDeleteRemovesOnlyThatRetiredConsumer) - t.Run("TheBound", testTheBound) - t.Run("ARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled", testARestartRetiresWhatTheBackendHoldsUnaskedAndAdoptsWhatItFindsDisabled) - t.Run("ABackendThatCannotBeListedIsSaidAndAskedAgain", testABackendThatCannotBeListedIsSaidAndAskedAgain) - t.Run("TheToolsAnswer", testTheToolsAnswer) - t.Run("AFailingConsumerRetiredIsSaidRecovered", testAFailingConsumerRetiredIsSaidRecovered) - t.Run("ARestartedProviderSaysOnceThatNothingWaits", testARestartedProviderSaysOnceThatNothingWaits) - t.Run("AProviderThatWaitsDoesNotSayNothingWaits", testAProviderThatWaitsDoesNotSayNothingWaits) -} diff --git a/go/provider/harness/standing_test.go b/go/provider/harness/standing_test.go deleted file mode 100644 index 44bd548..0000000 --- a/go/provider/harness/standing_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package harness - -// A provider that keeps failing a consumer says so on the bus (novox/hq ADR 0224): not on the first -// failure, which may be a restart; after FailingAfter of failures with no success between; again -// every SayAgainEvery while it lasts; and recovered on the first success, or when the consumer goes. - -import ( - "errors" - "os" - "strings" - "testing" - "time" -) - -type announced struct { - event string - body map[string]any -} - -func standingWorld(t *testing.T) (*world, *[]announced) { - w := newWorld(t) - var said []announced - w.h.Announce = func(e string, b map[string]any) { - // The first success since start, and the first word that nothing waits (issue 937), are their own - // tests'; every other test reads past them. - if c, _ := b["consumers"].([]map[string]any); b["why"] != "first-success" && - !(e == EventRetirement && b["change"] == ChangeSettled && len(c) == 0) { - said = append(said, announced{e, b}) - } - } - w.h.Node = "anchor" - return w, &said -} - -// passes reconciles every five seconds for d, as Run would. -func (w *world) passes(d time.Duration) { - for end := w.now.Add(d); w.now.Before(end); w.now = w.now.Add(5 * time.Second) { - w.h.Reconcile(ctx) - } -} - -func events(said []announced) string { - var out []string - for _, a := range said { - out = append(out, a.event) - } - return strings.Join(out, ",") -} - -func testAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass(t *testing.T) { - w, said := standingWorld(t) - w.a.failing = errors.New(`token request failed: 401 {"error":"invalid_grant","error_description":"Invalid user credentials"}`) - w.give(map[string]any{"as": "mesh_home_grafana", "node": "home-server"}) - - w.passes(4 * time.Minute) - if len(*said) != 0 { - t.Fatalf("announced before FailingAfter: %v", events(*said)) - } - w.passes(2 * time.Minute) - if events(*said) != EventFailing { - t.Fatalf("want one %s, got %q", EventFailing, events(*said)) - } - b := (*said)[0].body - if b["consumer"] != "mesh_home_grafana" || b["node"] != "home-server" || b["class"] != ClassCredentials || - b["provider"] != "postgres-database" || b["provider-node"] != "anchor" || b["attempts"].(int) < 60 { - t.Fatalf("%v", b) - } - - // Said again while it lasts, not every pass. - w.passes(14 * time.Minute) - if events(*said) != EventFailing { - t.Fatalf("repeated too soon: %q", events(*said)) - } - w.passes(2 * time.Minute) - if events(*said) != EventFailing+","+EventFailing { - t.Fatalf("not repeated: %q", events(*said)) - } - - // The first success takes it back. - w.a.failing = nil - w.passes(5 * time.Second) - if events(*said) != EventFailing+","+EventFailing+","+EventRecovered { - t.Fatalf("no recovery: %q", events(*said)) - } - if (*said)[2].body["consumer"] != "mesh_home_grafana" { - t.Fatal((*said)[2].body) - } -} - -func testOneSuccessBetweenFailuresStartsTheRunAgain(t *testing.T) { - w, said := standingWorld(t) - w.a.failing = errors.New("connection refused") - w.give(map[string]any{"as": "a"}) - w.passes(4 * time.Minute) - w.a.failing = nil - w.passes(5 * time.Second) - w.give(map[string]any{"as": "a", "values": map[string]any{"name": "changed"}}) - w.a.failing = errors.New("connection refused") - w.passes(4 * time.Minute) - if len(*said) != 0 { - t.Fatalf("two runs of four minutes are not one of eight: %q", events(*said)) - } -} - -// The check that failed for a day on 2026-10-05: clients already made, every minute's check refused -// at the token. A check that cannot be asked is a failure too. -func testACheckThatKeepsFailingIsAFailureToo(t *testing.T) { - w, said := standingWorld(t) - w.give(map[string]any{"as": "a"}) - w.h.Reconcile(ctx) - w.a.holdsErr = errors.New("401 invalid_grant") - w.passes(7 * time.Minute) - if events(*said) != EventFailing || (*said)[0].body["class"] != ClassCredentials { - t.Fatalf("%q %v", events(*said), *said) - } - w.a.holdsErr = nil - w.passes(time.Minute + 5*time.Second) - if events(*said) != EventFailing+","+EventRecovered { - t.Fatalf("%q", events(*said)) - } -} - -func testAnUnreadableSecretIsAnnouncedAsSuch(t *testing.T) { - w, said := standingWorld(t) - w.give(map[string]any{"as": "a"}) - os.Remove(w.dir + "/a.secret") - w.passes(6 * time.Minute) - if events(*said) != EventFailing || (*said)[0].body["class"] != ClassSecret { - t.Fatalf("%q %v", events(*said), *said) - } -} - -func testAConsumerNoLongerAskedForIsNoLongerFailing(t *testing.T) { - w, said := standingWorld(t) - w.a.failing = errors.New("boom") - w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"}) - w.passes(6 * time.Minute) - if events(*said) != EventFailing+","+EventFailing { - t.Fatalf("%q", events(*said)) - } - w.give(map[string]any{"as": "a"}) - w.passes(5 * time.Second) - last := (*said)[len(*said)-1] - if last.event != EventRecovered || last.body["consumer"] != "b" || last.body["why"] != "no longer asked for" { - t.Fatalf("%v", *said) - } -} - -func testAnErrorIsClassedByItsWords(t *testing.T) { - for text, want := range map[string]string{ - `Keycloak token request failed: 401 {"error":"invalid_grant"}`: ClassCredentials, - `FATAL: password authentication failed for user "postgres"`: ClassCredentials, - `dial tcp 127.0.0.1:5432: connect: connection refused`: ClassUnreachable, - `context deadline exceeded`: ClassUnreachable, - `extension "nope" is not available`: ClassRefused, - } { - if got := ClassOf(text); got != want { - t.Errorf("%s: %s, want %s", text, got, want) - } - } -} - -func testAnAdapterThatClassesItsOwnErrorsIsBelieved(t *testing.T) { - w, said := standingWorld(t) - w.h.Adapter = classing{w.a} - w.a.failing = errors.New("anything") - w.give(map[string]any{"as": "a"}) - w.passes(6 * time.Minute) - if (*said)[0].body["class"] != "its-own" { - t.Fatal((*said)[0].body) - } -} - -type classing struct{ *recorder } - -func (classing) Class(error) string { return "its-own" } - -// A provider restarted after announcing a failure has forgotten it; its first success for each -// consumer is announced, so the controller clears what it kept rather than naming it for ever. -func testTheFirstSuccessSinceStartIsAnnouncedOnce(t *testing.T) { - w := newWorld(t) - var said []announced - w.h.Announce = func(e string, b map[string]any) { - if e != EventRetirement { - said = append(said, announced{e, b}) - } - } - w.give(map[string]any{"as": "a"}) - w.passes(3 * time.Minute) - if events(said) != EventRecovered || said[0].body["why"] != "first-success" || said[0].body["consumer"] != "a" { - t.Fatalf("%v", said) - } -} - -// TestStanding holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests). -func TestStanding(t *testing.T) { - t.Run("AConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass", testAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass) - t.Run("OneSuccessBetweenFailuresStartsTheRunAgain", testOneSuccessBetweenFailuresStartsTheRunAgain) - t.Run("ACheckThatKeepsFailingIsAFailureToo", testACheckThatKeepsFailingIsAFailureToo) - t.Run("AnUnreadableSecretIsAnnouncedAsSuch", testAnUnreadableSecretIsAnnouncedAsSuch) - t.Run("AConsumerNoLongerAskedForIsNoLongerFailing", testAConsumerNoLongerAskedForIsNoLongerFailing) - t.Run("AnErrorIsClassedByItsWords", testAnErrorIsClassedByItsWords) - t.Run("AnAdapterThatClassesItsOwnErrorsIsBelieved", testAnAdapterThatClassesItsOwnErrorsIsBelieved) - t.Run("TheFirstSuccessSinceStartIsAnnouncedOnce", testTheFirstSuccessSinceStartIsAnnouncedOnce) -} diff --git a/go/provider/provider.go b/go/provider/provider.go deleted file mode 100644 index 8871ca0..0000000 --- a/go/provider/provider.go +++ /dev/null @@ -1,858 +0,0 @@ -// Package provider is a providing module's half of a binding: it answers the provider's consumers itself, over the -// bus (novox/hq ADR 0349 clause 12, issue 678): the provider module, not the controller, makes each bound -// consumer's entry. -// -// **Who binds the provider.** Each node's provisioner client records the needs of the modules handed to it from -// their plans, and the controller names in each plan, per required provision and local name, the provider that -// resolution chose: `provisioning.provisions` in `.` of the assignments mirror (issue 760). A recorded -// need names no provider (issue 677's note 7), so the plan's entry is where a bind is seen, as the vault sees it -// (mesh-vault pair.go). A consumer binds this provider when an entry names this machine and this module, and -// unbinds when the entry, or the assignment, goes. A consumer still filled the old way has no plan and is not seen -// here; the controller answers it as before (ADR 0349 clause 17). -// -// **One entry per consumer, in the provider's own bucket.** For each bind the helper composes the consumer's -// served values from the provider's offer (its config already composed into it, ADR 0202 §7) and the consumer's -// identity (`${consumer:as}`, `${consumer:as:dns}`, ADR 0202 §1), and writes one entry, `.. -// [.]` of `_bound`: the one statement both ends read. It never carries a -// secret. It names the credential the vault keeps for the pair, which the consumer's client asks the vault for. -// -// **The credential is the vault's** (ADR 0349 clause 8). The helper mints nothing and reads nothing of the -// provider's own secrets: it reads the value from the vault and hands it to the provider's adapter, which makes the -// login its software keeps. An offer whose software holds one credential (ADR 0158) is the vault's one value per -// provider and provision, `provision//`, with the provider's one user name; an offer with -// a login per consumer (ADR 0048, ADR 0158 §11, the default) is each consumer's own value, -// `pair////[/]`, with the consumer's identity as its login (issue -// 677's notes 12 and 13). A credential the vault does not hold yet is waited for: nothing is made and no entry is -// written, so a consumer is never told of a login its provider has not made. A provider in the tool runner never asks -// the vault itself: its Vault is ConsumersFile, the file its machine's provisioner client fills (ADR 0373). -// -// **A multi-holder consumer gets no derived value** (ADR 0202 §10 as ADR 0349 clause 12 changes it): a consumer -// bound under a local name keeps several holders of the provision, each with its own login, while what the -// provider derives names one; its entry refuses it, naming both ends, and its client reports it. -// -// The helper speaks to the bus and the vault through the two interfaces below, so a provider wires it to its -// runtime and a test to a stand-in. Standard library only. -package provider - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "regexp" - "sort" - "strings" - "sync" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/provisions" -) - -// BucketOf is a providing module's own bucket of entries, one per consumer: `_bound`, the module's state -// `bound`. -func BucketOf(module string) string { return module + "_bound" } - -// Bus is what the helper needs of the bus: a key-value watch and the provider's writes to its own bucket. -type Bus interface { - // Watch hands handle every current key and value of bucket, then each change as it comes, a removed key with a - // nil value; it returns once the current values are handed, and the watch lasts until ctx ends. - Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error - Put(ctx context.Context, bucket, key string, value []byte) error - Delete(ctx context.Context, bucket, key string) error -} - -// ConditionalBus is a Bus that can also delete a key only while it still holds what the caller read. The helper uses -// it when the Bus offers it, so a pass that lets a consumer go never removes an entry another instance of the -// provider wrote after the pass read it; a Bus without it deletes unconditionally. DeleteIf returns an error that -// is stdio.ErrChanged when the key holds something else now. -type ConditionalBus interface { - Bus - DeleteIf(ctx context.Context, bucket, key string, held []byte) error -} - -// Vault is how the provider reads a credential the vault keeps, by its name, opened. -type Vault interface { - Value(ctx context.Context, name string) ([]byte, error) -} - -// CredentialForm is which credential an offer's consumers present. -type CredentialForm int - -const ( - // PerConsumer is a login of each consumer's own (ADR 0048, ADR 0158 §11): the default. - PerConsumer CredentialForm = iota - // Shared is the one credential the provider's software holds, shared with every consumer (ADR 0158). - Shared - // NoCredential is an offer whose consumers present none. - NoCredential -) - -// Offer is what the provider serves one provision with. -type Offer struct { - // Serves are the values served to each consumer, the provider's config already composed into them; a string - // may name the consumer's identity, `${consumer:as}` or `${consumer:as:dns}`. - Serves map[string]any - // Credential is the form of the credential its consumers present. A consumer's plan entry that names a - // credential form of its own wins over it (the plan's `credential-form`). - Credential CredentialForm - // User is the provider's one user name, for a Shared credential. - User string -} - -// Entry is one consumer's entry in the provider's bucket: the one statement both ends read. Never a secret. -type Entry struct { - Provider string `json:"provider"` - ProviderModule string `json:"provider-module"` - Provision string `json:"provision"` - Consumer string `json:"consumer"` - Module string `json:"module"` - Local string `json:"local,omitempty"` - // As is the consumer's identity, the mesh's (ADR 0202 §1). - As string `json:"as,omitempty"` - // User is the login the consumer presents: its identity, or the provider's one user name for a shared credential. - User string `json:"user,omitempty"` - // Values are the served values, composed for this consumer: what `${bound::}` reads. - Values map[string]any `json:"values,omitempty"` - // VaultName is the name under which the vault keeps this consumer's credential, which the consumer's client asks - // the vault for: a name, never the credential. Its key is not "credential" because the runtime's state guard - // refuses any key ending in that word, whatever the value (issue 945). - VaultName string `json:"vault-name,omitempty"` - // Refused, when set, is why the consumer is not served, naming both ends. - Refused string `json:"refused,omitempty"` - // State is the provider's outcome for the consumer, which the holder of mesh-provisioner reads to set the - // provision's key (novox/hq ADR 0570 clause 3): provided, failed with Why, or waits with what is awaited. Empty from - // a provider before it. - State string `json:"state,omitempty"` - Why string `json:"why,omitempty"` - // Contributes is what the consumer's module tells its provider about this provision (a database's name, its - // extensions), from its plan's entry (issue 776). Handed to Make and Gone; never written to the bucket. - Contributes map[string]any `json:"-"` - // At and From are where the consumer's plan says the provider is and which machine it is (issue 788): the values - // today's binding file gives that consumer. Written into Values as `at` and `from`; never alone to the bucket. - At, From string `json:"-"` - // Names are the names the controller composed for this consumer on this provision (issue 792): its own name, its - // internal name, and one of each per local name it contributes under, keyed as its binding file names them. - // Written into Values over anything the provider serves under the key; never alone to the bucket. - Names map[string]string `json:"-"` - // CredentialForm and Name are from the consumer's plan entry (issue 825): the form of the credential the - // controller chose for this bind (`shared`, `pair`, `issued` or `given`) and the holder's name. When set, they - // win over the offer's form. Never written to the bucket. - CredentialForm, Name string `json:"-"` -} - -// Key is the entry's key in the provider's bucket: `..`, and `.` for a bind under a -// local name. -func (e Entry) Key() string { - k := e.Consumer + "." + e.Module + "." + e.Provision - if e.Local != "" { - k += "." + e.Local - } - return k -} - -// Consumer is what the provider's adapter makes: the consumer's entry and the credential the vault keeps for it. -type Consumer struct { - Entry Entry - Credential []byte -} - -// Options are the provider's. -type Options struct { - // Node and Module are this provider: the machine it runs on and the module serving. - Node, Module string - // Offers are the provisions it serves, by name. - Offers map[string]Offer - Bus Bus - Vault Vault - // Make brings a consumer into being in the provider's software under the vault's credential. Asked on every - // pass for every consumer answered, so it must leave one that is already as asked alone; a rotated credential - // reaches the software this way. - Make func(ctx context.Context, c Consumer) error - // Gone, when set, hears that a consumer no longer binds the provider, before its entry is removed; an error - // keeps the entry, and it is heard again on the next pass. Retiring what was kept for it is the provider's - // (ADR 0230). - Gone func(ctx context.Context, e Entry) error - // Identity is the consumer's identity for an entry whose plan names none; ConsumerIdentity of the node and the - // module when not set. - Identity func(node, module string) string - // Every is how often a pass runs without a change watched; a minute when not set. - Every time.Duration - // Logf says what a pass did that differs from the last; the standard log when not set. - Logf func(format string, args ...any) -} - -// Provider answers its consumers. -type Provider struct { - opts Options - - mu sync.Mutex - entries map[string][]byte - // plans and provisions are the seat mesh-provisioner's states (ADR 0570 clauses 1, 2), by key. - plans map[string][]byte - provisions map[string][]byte - said map[string]string - poke chan struct{} -} - -// New is a provider answering its consumers as opts say. -func New(opts Options) *Provider { - if opts.Identity == nil { - opts.Identity = ConsumerIdentity - } - if opts.Every <= 0 { - opts.Every = time.Minute - } - if opts.Logf == nil { - opts.Logf = func(f string, a ...any) { fmt.Printf("[provider] "+f+"\n", a...) } - } - return &Provider{opts: opts, entries: map[string][]byte{}, - plans: map[string][]byte{}, provisions: map[string][]byte{}, said: map[string]string{}, poke: make(chan struct{}, 1)} -} - -// ErrNotReady is what a Make returns, wrapped or alone, when the provider cannot make a consumer's provision yet but -// will without anyone's hand (its keys not read since a restart): the entry waits and keeps serving what it served. -// Any other error is a failure, and its entry serves nothing. -var ErrNotReady = errors.New("not ready yet") - -// What a pass did for one bind. -const ( - Answered = "answered" - Refused = "refused" - Waiting = "waiting" - Failed = "failed" - Removed = "removed" - // Superseded is an entry no bind asks for any more that another instance of the provider wrote after this pass - // read it: it is left standing. - Superseded = "superseded" -) - -// Outcome is what a pass did for one consumer's entry. -type Outcome struct { - Key string `json:"key"` - Status string `json:"status"` - Why string `json:"why,omitempty"` -} - -// Run watches the binds and the provider's own entries, and passes once at start, on every change and every -// Every, until ctx ends. -func (p *Provider) Run(ctx context.Context) error { - if err := p.watch(ctx, BucketOf(p.opts.Module), p.entries); err != nil { - return fmt.Errorf("watching %s: %w", BucketOf(p.opts.Module), err) - } - for _, bucket := range []string{provisions.PlansBucket, provisions.ProvisionsBucket} { - into := p.plans - if bucket == provisions.ProvisionsBucket { - into = p.provisions - } - if err := p.watch(ctx, bucket, into); err != nil { - return fmt.Errorf("watching %s: %w", bucket, err) - } - } - tick := time.NewTicker(p.opts.Every) - defer tick.Stop() - for { - p.mu.Lock() - in := Inputs{Entries: clone(p.entries), Plans: clone(p.plans), Provisions: clone(p.provisions)} - p.mu.Unlock() - if _, err := p.passIn(ctx, in); err != nil { - p.say("pass", err.Error()) - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-p.poke: - case <-tick.C: - } - } -} - -func (p *Provider) watch(ctx context.Context, bucket string, into map[string][]byte) error { - return p.opts.Bus.Watch(ctx, bucket, func(key string, value []byte) { - p.mu.Lock() - if value == nil { - delete(into, key) - } else { - into[key] = append([]byte(nil), value...) - } - p.mu.Unlock() - if bucket != BucketOf(p.opts.Module) { - select { - case p.poke <- struct{}{}: - default: - } - } - }) -} - -// Pass reads the binds and the provider's entries from the bus and answers once: each bind of this provider gets -// its entry, and each entry of this provider no bind asks for is removed. An assignment that cannot be read, or that -// is not the one its key names, is skipped and said (an outcome of status Failed under the assignment's key, and a -// line of the log), and the entries of its consumer are left as they are, since a bind missed would read as an -// unbind; the pass goes on for the rest. Run passes by itself; Pass is for a provider that passes when it chooses. -// -// Over StateBus each call to Pass leaves two watches open for the life of the process, silenced once the call has -// ended (see StateBus): a provider that passes often should use Run, which watches once. -func (p *Provider) Pass(ctx context.Context) ([]Outcome, error) { - watchCtx, cancel := context.WithCancel(ctx) - defer cancel() - var mu sync.Mutex - read := func(bucket string) (map[string][]byte, error) { - got := map[string][]byte{} - err := p.opts.Bus.Watch(watchCtx, bucket, func(key string, value []byte) { - mu.Lock() - defer mu.Unlock() - if value == nil { - delete(got, key) - } else { - got[key] = append([]byte(nil), value...) - } - }) - mu.Lock() - defer mu.Unlock() - return clone(got), err - } - entries, err := read(BucketOf(p.opts.Module)) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", BucketOf(p.opts.Module), err) - } - plans, err := read(provisions.PlansBucket) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", provisions.PlansBucket, err) - } - provs, err := read(provisions.ProvisionsBucket) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", provisions.ProvisionsBucket, err) - } - return p.passIn(ctx, Inputs{Entries: entries, Plans: plans, Provisions: provs}) -} - -// Inputs are what one pass reads: the provider's own entries, and the seat mesh-provisioner's plans and provisions -// (novox/hq ADR 0570 clauses 1, 2), each by key. The controller's assignments are read no more (clause 2). -type Inputs struct { - Entries, Plans, Provisions map[string][]byte -} - -func (p *Provider) passIn(ctx context.Context, in Inputs) ([]Outcome, error) { - entries := in.Entries - var binds []Entry - var out []Outcome - unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged - // Every consumer is bound by its plan and its provision keys (ADR 0570 clauses 2, 3), and an entry no plan names is - // never let go by the provider itself (clause 5): only a retiring key with the operator's yes retires it. - seat := map[string]bool{} - gated := map[string]provisions.Provision{} - for _, key := range sortedKeys(in.Plans) { - plan, err := provisions.DecodePlan(in.Plans[key]) - if err != nil || plan.Node+"."+plan.Module != key { - o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the plan %s is not readable, so who it binds is not "+ - "known; it is skipped, and its entries are left as they are", key)} - out = append(out, o) - p.say(key, o.Status+" "+o.Why) - unknown[key], seat[key] = true, true - continue - } - seat[key] = true - for _, b := range plan.Plan.Provisions { - if b.Provider != p.opts.Node || b.ProviderModule != p.opts.Module { - continue - } - e := Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, Provision: b.Provision, Consumer: plan.Node, - Module: plan.Module, Local: b.Local, Contributes: b.Contributes, At: b.At, From: b.From, As: b.As, - Names: b.Names, CredentialForm: b.CredentialForm, Name: b.Name} - binds = append(binds, e) - } - } - for _, key := range sortedKeys(in.Provisions) { - if pr, err := provisions.Decode(in.Provisions[key]); err == nil && pr.Provider.Node == p.opts.Node && - pr.Provider.Module == p.opts.Module { - gated[key] = pr - } - } - // **A key no plan names is bound from the entry it carries** (issue 1126): the seat keys a consumer's provisions - // from its wanted and its newest builds, and keeps the plan at its wanted build alone, so a requirement the newest - // build adds has a key before any plan names it, and the want that waits for it waits on this provider. The plan - // wins where both say it. A key with no entry is said, never left without a word. - planned := map[string]bool{} - for _, b := range binds { - planned[b.Key()] = true - } - for _, key := range sortedKeys(gated) { - pr := gated[key] - if planned[key] || pr.State == provisions.Retiring || unknown[consumerOf(key)] { - continue - } - if pr.Plan == nil { - o := Outcome{Key: key, Status: Waiting, Why: "asked, but no plan names it and its key carries no plan entry, " + - "so whom it binds is not known; mesh-provisioner composes the entry"} - out = append(out, o) - p.say(key, o.Status+" "+o.Why) - continue - } - b := *pr.Plan - binds = append(binds, Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, Provision: pr.Provision, - Consumer: pr.Consumer.Node, Module: pr.Consumer.Module, Local: pr.Local, Contributes: b.Contributes, At: b.At, - From: b.From, As: b.As, Names: b.Names, CredentialForm: b.CredentialForm, Name: b.Name}) - } - want := map[string]bool{} - for _, b := range binds { - key := b.Key() - want[key] = true - if why, ok := p.ready(key, gated, in.Provisions); !ok { - o := Outcome{Key: key, Status: Waiting, Why: why} - out = append(out, o) - p.say(key, o.Status+" "+o.Why) - continue - } - o := p.answer(ctx, b, entries[key]) - out = append(out, o) - p.say(key, o.Status+" "+o.Why) - } - // ADR 0570 clause 5: a retiring key the operator said yes to is let go; one asked and not answered, or declined, - // keeps its entry. - for _, key := range sortedKeys(gated) { - if pr := gated[key]; pr.State == provisions.Retiring && pr.Warrant != nil && pr.Warrant.Option == "yes" { - want[key] = false - if _, held := entries[key]; held { - out = append(out, p.letGo(ctx, key, entries[key])...) - } - } - } - return out, nil -} - -// consumerOf is the `.` an entry's key starts with: its first two parts. -func consumerOf(key string) string { - parts := strings.SplitN(key, ".", 3) - if len(parts) < 3 { - return key - } - return parts[0] + "." + parts[1] -} - -// remove deletes an entry this instance read as held, and only while it is still as read when the Bus can say so. -func (p *Provider) remove(ctx context.Context, key string, held []byte) error { - if c, ok := p.opts.Bus.(ConditionalBus); ok { - return c.DeleteIf(ctx, BucketOf(p.opts.Module), key, held) - } - return p.opts.Bus.Delete(ctx, BucketOf(p.opts.Module), key) -} - -// answer makes one bind's entry and writes it when it differs from what the bucket holds. -func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome { - key := e.Key() - offer, offered := p.opts.Offers[e.Provision] - switch { - case !offered: - e.Refused = fmt.Sprintf("%s on %s is bound to %s on %s for %s, which %s does not serve", e.Module, - e.Consumer, e.ProviderModule, e.Provider, e.Provision, e.ProviderModule) - case e.Local != "" && derives(offer.Serves): - e.Refused = fmt.Sprintf("%s on %s keeps several holders of %s (this one is %q), and %s on %s derives %s for "+ - "each consumer from its login: each holder has a login of its own and a consumer is told one value per "+ - "requirement, so the two ends would name different things (novox/hq ADR 0202 §10, ADR 0349 clause 12)", - e.Module, e.Consumer, e.Provision, e.Local, e.ProviderModule, e.Provider, - strings.Join(derivedKeys(offer.Serves), ", ")) - } - if e.Refused != "" { - e.State, e.Why = provisions.Failed, e.Refused - return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) - } - - // The plan's identity is the controller's, slug first; Identity only answers for an entry without one. - if e.As == "" { - e.As = p.opts.Identity(e.Consumer, e.Module) - } - values, err := ServedTo(offer.Serves, e.As) - if err != nil { - e.Refused = fmt.Sprintf("%s on %s serving %s to %s on %s: %v", e.ProviderModule, e.Provider, e.Provision, - e.Module, e.Consumer, err) - e.As = "" - e.State, e.Why = provisions.Failed, e.Refused - return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) - } - if e.At != "" || e.From != "" { - if values == nil { - values = map[string]any{} - } - if e.At != "" { - values["at"] = e.At - } - if e.From != "" { - values["from"] = e.From - } - } - if len(e.Names) > 0 { - if values == nil { - values = map[string]any{} - } - for key, name := range e.Names { - values[key] = name - } - } - e.Values = values - var credential []byte - // The plan's form wins; the offer's is the default. - // An offer that declares no credential has none whatever the plan says: the controller writes "pair" for it. - form := e.CredentialForm - if offer.Credential == NoCredential { - form = "" - } else if form == "" { - form = map[CredentialForm]string{Shared: "shared", PerConsumer: "pair"}[offer.Credential] - } - switch form { - case "shared": - e.User = offer.User - e.VaultName = SharedName(e.Provider, e.Provision) - case "pair": - e.User = e.As - e.VaultName = PairName(e.Provider, e.Provision, e.Consumer, e.Module, e.Local) - case "issued", "given": - // A per-holder value, as mesh-vault names it (perholder.go). - e.User = e.As - e.VaultName = HolderName(e.Provider, e.Provision, e.Consumer, e.Module) - } - if e.VaultName != "" { - credential, err = p.opts.Vault.Value(ctx, e.VaultName) - if err != nil { - return Outcome{Key: key, Status: Waiting, Why: fmt.Sprintf("the vault's %s: %v", e.VaultName, err)} - } - } - if p.opts.Make != nil { - if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); errors.Is(err, ErrNotReady) { - // **Not ready is no failure** (the regression of 2026-10-30: gitea after a restart, its keys not read yet, - // stopped serving every consumer): the entry says waits with why, and keeps what the last entry served, so a - // consumer goes on with it until the provider can make it again. - why := "the provider is not ready to make it yet: " + redact(err.Error(), credential) - waiting := e - var prev Entry - if len(have) > 0 && json.Unmarshal(have, &prev) == nil && prev.State == provisions.Provided { - waiting.Values, waiting.VaultName, waiting.User = prev.Values, prev.VaultName, prev.User - } else { - waiting.Values, waiting.VaultName, waiting.User = nil, "", "" - } - waiting.State, waiting.Why = provisions.Waits, why - p.write(ctx, waiting, have, Outcome{Key: key, Status: Waiting, Why: why}) - return Outcome{Key: key, Status: Waiting, Why: why} - } else if err != nil { - why := "the provider could not make it: " + redact(err.Error(), credential) - // Said in its entry, which the holder of mesh-provisioner reads (ADR 0570 clause 3), with nothing served. - failed := e - failed.Values, failed.VaultName, failed.User, failed.State, failed.Why = nil, "", "", provisions.Failed, why - p.write(ctx, failed, have, Outcome{Key: key, Status: Failed, Why: why}) - return Outcome{Key: key, Status: Failed, Why: why} - } - } - e.State = provisions.Provided - return p.write(ctx, e, have, Outcome{Key: key, Status: Answered}) -} - -// redact takes a credential out of text in the forms it can come back in: as it is, in base64 (standard and URL, -// padded and not) and as a JSON string says it (escapes included). -func redact(text string, credential []byte) string { - if len(credential) == 0 { - return text - } - quoted, _ := json.Marshal(string(credential)) - forms := []string{string(credential), base64.StdEncoding.EncodeToString(credential), - base64.RawStdEncoding.EncodeToString(credential), base64.URLEncoding.EncodeToString(credential), - base64.RawURLEncoding.EncodeToString(credential), string(quoted[1 : len(quoted)-1])} - sort.SliceStable(forms, func(i, j int) bool { return len(forms[i]) > len(forms[j]) }) - for _, f := range forms { - text = strings.ReplaceAll(text, f, "") - } - return text -} - -func (p *Provider) write(ctx context.Context, e Entry, have []byte, o Outcome) Outcome { - body, err := json.Marshal(e) - if err != nil { - return Outcome{Key: o.Key, Status: Failed, Why: err.Error()} - } - if sameJSON(have, body) { - return o - } - if err := p.opts.Bus.Put(ctx, BucketOf(p.opts.Module), o.Key, body); err != nil { - return Outcome{Key: o.Key, Status: Failed, Why: "its entry could not be written: " + err.Error()} - } - return o -} - -// say logs what a key's pass said when it differs from the last. -func (p *Provider) say(key, what string) { - p.mu.Lock() - same := p.said[key] == what - p.said[key] = what - p.mu.Unlock() - if !same { - p.opts.Logf("%s: %s", key, strings.TrimSpace(what)) - } -} - -// SharedName is the vault's one value per provider and provision (ADR 0158, issue 677). -func SharedName(provider, provision string) string { - return provisions.VaultName(provisions.FormShared, provisions.Provision{Provision: provision, Provider: provisions.End{Node: provider}}) -} - -// HolderName is the vault's value of one holder's own credential, issued or given for it (mesh-vault perholder.go). -func HolderName(provider, provision, node, module string) string { - return provisions.VaultName(provisions.FormIssued, provisions.Provision{Provision: provision, - Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}}) -} - -// PairName is the vault's value of one consumer's own login (ADR 0048, ADR 0158 §11, issue 677's note 12). -func PairName(provider, provision, node, module, local string) string { - return provisions.VaultName(provisions.FormPair, provisions.Provision{Provision: provision, Local: local, - Provider: provisions.End{Node: provider}, Consumer: provisions.End{Node: node, Module: module}}) -} - -// identityUnusable is every character a consumer's identity does not hold (mesh-controller internal/catalogue -// identity.go, the one derivation). -var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) - -// ConsumerIdentity is what one module on one machine is called wherever it authenticates: `mesh__`, -// lower case, anything but letters, digits and `_` written `_` (mesh-controller ConsumerIdentity). module is the -// identity source: the module's slug when it declares one, otherwise its name (ADR 0049). -func ConsumerIdentity(node, module string) string { - clean := func(s string) string { - return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_") - } - return "mesh_" + clean(node) + "_" + clean(module) -} - -// consumerFact is `${consumer:}` or `${consumer::}`. -var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) - -// ServedTo fills an offer's served values for one consumer whose identity is as (ADR 0202 §1): `${consumer:as}` as -// it is, `${consumer:as:dns}` with `-` for `_`. Only strings carry placeholders; any other fact or alphabet is -// refused, never left standing. -func ServedTo(serves map[string]any, as string) (map[string]any, error) { - if serves == nil { - return nil, nil - } - out := make(map[string]any, len(serves)) - for _, key := range sortedKeys(serves) { - text, ok := serves[key].(string) - if !ok || !strings.Contains(text, "${consumer:") { - out[key] = serves[key] - continue - } - var failed error - out[key] = consumerFact.ReplaceAllStringFunc(text, func(match string) string { - parts := consumerFact.FindStringSubmatch(match) - switch { - case parts[1] == "as" && parts[2] == "": - return as - case parts[1] == "as" && parts[2] == "dns": - return strings.ReplaceAll(as, "_", "-") - } - if failed == nil { - failed = fmt.Errorf("the value served as %q says %s, and the mesh states only ${consumer:as} and "+ - "${consumer:as:dns}", key, match) - } - return match - }) - if failed != nil { - return nil, failed - } - } - return out, nil -} - -func derives(serves map[string]any) bool { return len(derivedKeys(serves)) > 0 } - -func derivedKeys(serves map[string]any) []string { - var out []string - for _, k := range sortedKeys(serves) { - if text, ok := serves[k].(string); ok && strings.Contains(text, "${consumer:") { - out = append(out, k) - } - } - return out -} - -func clone(m map[string][]byte) map[string][]byte { - out := make(map[string][]byte, len(m)) - for k, v := range m { - out[k] = v - } - return out -} - -func sameJSON(a, b []byte) bool { - if a == nil || b == nil { - return false - } - var x, y any - if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { - return false - } - ja, _ := json.Marshal(x) - jb, _ := json.Marshal(y) - return string(ja) == string(jb) -} - -func sortedKeys[V any](m map[string]V) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// StateBus is the Bus of a provider that runs as a module bundle over the runtime's stdio: the provider's own bucket, -// BucketOf(module), is the module's state `bound`, and provisions.PlansBucket and provisions.ProvisionsBucket are the -// states it reads, `mesh-provisioner.plans` and `mesh-provisioner.provisions` (each declared in the module's manifest). -// Any other bucket is refused. -// -// A stdio state watch lasts as long as the process and cannot be ended (stdio.KeptState.Watch), so Watch honours the -// Bus contract by silencing instead: once ctx has ended the handler is never called again, though the runtime keeps -// sending changes that are dropped. A Pass, which watches twice per call, is therefore correct, only the dropped -// changes of its two spent watches remain. -func StateBus(module string) Bus { return stateBus{module: module} } - -type stateBus struct{ module string } - -func (b stateBus) state(bucket string) (stdio.KeptState, error) { - switch bucket { - case BucketOf(b.module): - return stdio.State("bound"), nil - case provisions.PlansBucket: - return stdio.State(provisions.PlansRead), nil - case provisions.ProvisionsBucket: - return stdio.State(provisions.ProvisionsRead), nil - } - return stdio.KeptState{}, fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket) -} - -func (b stateBus) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error { - s, err := b.state(bucket) - if err != nil { - return err - } - return s.Watch("", func(ch stdio.StateChange) error { - if ctx.Err() != nil { - return nil - } - if ch.Op == "delete" { - handle(ch.Key, nil) - } else { - handle(ch.Key, ch.Value) - } - return nil - }) -} - -func (b stateBus) Put(_ context.Context, bucket, key string, value []byte) error { - s, err := b.state(bucket) - if err != nil { - return err - } - _, err = s.Put(key, json.RawMessage(value)) - return err -} - -// DeleteIf removes the key only while it still holds what the caller read; it reads the key first, so another -// writer's change in that short while can still be lost. -func (b stateBus) DeleteIf(_ context.Context, bucket, key string, held []byte) error { - s, err := b.state(bucket) - if err != nil { - return err - } - cur, err := s.Get(key) - if err != nil { - return err - } - if cur == nil { - return nil - } - if !sameJSON(cur.Value, held) { - return fmt.Errorf("%s is not as it was read: %w", key, stdio.ErrChanged) - } - return s.Delete(key) -} - -func (b stateBus) Delete(_ context.Context, bucket, key string) error { - s, err := b.state(bucket) - if err != nil { - return err - } - return s.Delete(key) -} - -// Standing is whether an assignment's value is a standing assignment: false when `ended` is set (the module was -// taken off its node, novox/hq ADR 0408 clause 4) or `state` is not empty (a pending one). Such a value is read -// exactly as a removed key. Every other field, known or not, changes nothing (issue 1098). -func Standing(value []byte) bool { - var v struct { - Ended json.RawMessage `json:"ended"` - State json.RawMessage `json:"state"` - } - if json.Unmarshal(value, &v) != nil { - return true // not decided here: the parse that follows refuses it - } - return !fieldSet(v.Ended) && !fieldSet(v.State) -} - -// fieldSet is whether a field carries something: not absent, null or empty. -func fieldSet(f json.RawMessage) bool { - s := strings.TrimSpace(string(f)) - return s != "" && s != "null" && s != `""` -} - -// letGo lets a consumer's entry go: the provider hears it gone (Gone, which retires what it kept for the consumer), and -// the entry is removed while it is still as read. -func (p *Provider) letGo(ctx context.Context, key string, held []byte) []Outcome { - var e Entry - if err := json.Unmarshal(held, &e); err != nil || e.Provider != p.opts.Node || e.ProviderModule != p.opts.Module { - return nil // another machine's instance of this provider wrote it, or nothing of this helper's - } - said := func(o Outcome) []Outcome { - p.say(key, o.Status+" "+o.Why) - return []Outcome{o} - } - if p.opts.Gone != nil { - if err := p.opts.Gone(ctx, e); err != nil { - return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " + err.Error()}) - } - } - if err := p.remove(ctx, key, held); err != nil { - if errors.Is(err, stdio.ErrChanged) { - return said(Outcome{Key: key, Status: Superseded, Why: "another instance of the provider wrote its entry " + - "since this pass read it, so it stands"}) - } - return said(Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " + err.Error()}) - } - p.say(key, Removed) - p.mu.Lock() - delete(p.said, key) - p.mu.Unlock() - return []Outcome{{Key: key, Status: Removed}} -} - -// ready says whether the provision under key may be made now (novox/hq ADR 0570 clause 3): its key is on the seat, it is -// asked or provided, and every provision it depends on is provided. Otherwise why it waits, naming the key or the -// dependency. -func (p *Provider) ready(key string, mine map[string]provisions.Provision, all map[string][]byte) (string, bool) { - pr, ok := mine[key] - if !ok { - return "mesh-provisioner holds no key " + key + " naming this provider yet", false - } - if pr.State != provisions.Asked && pr.State != provisions.Provided { - return "its key is " + pr.State + ": " + pr.Why, false - } - for _, d := range pr.DependsOn { - dep, err := provisions.Decode(all[d]) - if err != nil { - return "it depends on " + d + ", which mesh-provisioner holds no readable key of", false - } - if dep.State != provisions.Provided { - return "it depends on " + d + ", which is " + dep.State, false - } - } - return "", true -} diff --git a/go/provider/provider_test.go b/go/provider/provider_test.go deleted file mode 100644 index da8d7f4..0000000 --- a/go/provider/provider_test.go +++ /dev/null @@ -1,1002 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "reflect" - "slices" - "strings" - "sync" - "testing" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provisions" -) - -// standIn is a stand-in bus: buckets of keys and values, and watches that hand the current values first and then -// every change, as the bus's key-value watch does. -type standIn struct { - mu sync.Mutex - buckets map[string]map[string][]byte - watchers map[string][]func(string, []byte) -} - -func newStandIn() *standIn { - return &standIn{buckets: map[string]map[string][]byte{}, watchers: map[string][]func(string, []byte){}} -} - -func (s *standIn) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error { - s.mu.Lock() - current := map[string][]byte{} - for k, v := range s.buckets[bucket] { - current[k] = v - } - s.watchers[bucket] = append(s.watchers[bucket], handle) - s.mu.Unlock() - for _, k := range sortedKeys(current) { - handle(k, current[k]) - } - return nil -} - -func (s *standIn) Put(_ context.Context, bucket, key string, value []byte) error { - s.mu.Lock() - if s.buckets[bucket] == nil { - s.buckets[bucket] = map[string][]byte{} - } - s.buckets[bucket][key] = append([]byte(nil), value...) - ws := slices.Clone(s.watchers[bucket]) - s.mu.Unlock() - for _, w := range ws { - w(key, value) - } - return nil -} - -func (s *standIn) Delete(_ context.Context, bucket, key string) error { - s.mu.Lock() - delete(s.buckets[bucket], key) - ws := slices.Clone(s.watchers[bucket]) - s.mu.Unlock() - for _, w := range ws { - w(key, nil) - } - return nil -} - -// DeleteIf removes the key only while it still holds what the caller read. -func (s *standIn) DeleteIf(ctx context.Context, bucket, key string, held []byte) error { - s.mu.Lock() - cur := s.buckets[bucket][key] - s.mu.Unlock() - if !bytes.Equal(cur, held) { - return fmt.Errorf("%s: %w", key, stdio.ErrChanged) - } - return s.Delete(ctx, bucket, key) -} - -func (s *standIn) get(bucket, key string) []byte { - s.mu.Lock() - defer s.mu.Unlock() - return s.buckets[bucket][key] -} - -func (s *standIn) keys(bucket string) []string { - s.mu.Lock() - defer s.mu.Unlock() - return sortedKeys(s.buckets[bucket]) -} - -// everything is every value the bus holds, for a test that no secret is in a bucket. -func (s *standIn) everything() []byte { - s.mu.Lock() - defer s.mu.Unlock() - var all []byte - for _, b := range s.buckets { - for k, v := range b { - all = append(all, k...) - all = append(all, v...) - } - } - return all -} - -// vault is a stand-in vault: a value per name, or nothing yet. -type vault struct { - mu sync.Mutex - values map[string]string - asked []string -} - -func (v *vault) Value(_ context.Context, name string) ([]byte, error) { - v.mu.Lock() - defer v.mu.Unlock() - v.asked = append(v.asked, name) - val, ok := v.values[name] - if !ok { - return nil, fmt.Errorf("the vault holds no %s yet", name) - } - return []byte(val), nil -} - -// maker records what the provider's adapter was asked to make, with the credential it was handed. -type maker struct { - mu sync.Mutex - made map[string]string - // contributed is what each consumer's entry carried of its plan's contribution. - contributed map[string]map[string]any - // as is the identity each consumer's entry was made under. - as map[string]string - gone []string - fail error - calls int -} - -func (m *maker) Make(_ context.Context, c Consumer) error { - m.mu.Lock() - defer m.mu.Unlock() - m.calls++ - if m.fail != nil { - return m.fail - } - if m.made == nil { - m.made = map[string]string{} - } - m.made[c.Entry.Key()] = string(c.Credential) - if m.contributed == nil { - m.contributed = map[string]map[string]any{} - } - m.contributed[c.Entry.Key()] = c.Entry.Contributes - if m.as == nil { - m.as = map[string]string{} - } - m.as[c.Entry.Key()] = c.Entry.As - return nil -} - -func (m *maker) Gone(_ context.Context, e Entry) error { - m.mu.Lock() - defer m.mu.Unlock() - m.gone = append(m.gone, e.Key()) - return nil -} - -// assign puts a consumer's plan with its provisions on the seat mesh-provisioner, each provision's key provided, as its -// holder writes them (novox/hq ADR 0570 clauses 1, 2): what a provider binds by. -func assign(t *testing.T, bus *standIn, node, module string, binds ...map[string]string) { - t.Helper() - var ps []catalogue.PlanProvision - for _, b := range binds { - raw, _ := json.Marshal(b) - var pp catalogue.PlanProvision - if err := json.Unmarshal(raw, &pp); err != nil { - t.Fatal(err) - } - ps = append(ps, pp) - } - planned(t, bus, node, module, ps...) -} - -// planned puts a consumer's plan of these provisions on the seat, each provision's key provided. -func planned(t *testing.T, bus *standIn, node, module string, ps ...catalogue.PlanProvision) { - t.Helper() - for _, pp := range ps { - pr := provisions.Provision{Provision: pp.Provision, Local: pp.Local, Consumer: provisions.End{Module: module, Node: node}, - Provider: provisions.End{Module: pp.ProviderModule, Node: pp.Provider}, State: provisions.Provided} - key, err := pr.Key() - if err != nil { - t.Fatal(err) - } - value, err := provisions.Encode(pr) - if err != nil { - t.Fatal(err) - } - _ = bus.Put(context.Background(), provisions.ProvisionsBucket, key, value) - } - raw, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: catalogue.Plan{Provisions: ps}}) - if err != nil { - t.Fatal(err) - } - if err := bus.Put(context.Background(), provisions.PlansBucket, node+"."+module, raw); err != nil { - t.Fatal(err) - } -} - -// retire marks a consumer's provision retiring, with the operator's yes (novox/hq ADR 0570 clause 5): the one way a -// provider lets a consumer go. -func retire(t *testing.T, bus *standIn, node, module, provision, provider, providerModule string) { - t.Helper() - pr := provisions.Provision{Provision: provision, Consumer: provisions.End{Module: module, Node: node}, - Provider: provisions.End{Module: providerModule, Node: provider}, State: provisions.Retiring, Why: module + " left " + node, - Warrant: &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}} - key, _ := pr.Key() - raw, err := provisions.Encode(pr) - if err != nil { - t.Fatal(err) - } - _ = bus.Delete(context.Background(), provisions.PlansBucket, node+"."+module) - _ = bus.Put(context.Background(), provisions.ProvisionsBucket, key, raw) -} - -func bind(provision, local, provider, providerModule string) map[string]string { - b := map[string]string{"provision": provision, "provider": provider, "provider-module": providerModule} - if local != "" { - b["local"] = local - } - return b -} - -func entryOf(t *testing.T, bus *standIn, key string) Entry { - t.Helper() - raw := bus.get(BucketOf("postgres"), key) - if raw == nil { - t.Fatalf("no entry %s in %s; it holds %v", key, BucketOf("postgres"), bus.keys(BucketOf("postgres"))) - } - var e Entry - if err := json.Unmarshal(raw, &e); err != nil { - t.Fatalf("entry %s: %v", key, err) - } - return e -} - -// postgres is a provider of postgres-database on novox with a login per consumer (ADR 0048, ADR 0158 §11), its -// served values derived from each consumer's identity (ADR 0202 §1). -func postgres(bus *standIn, v *vault, m *maker) *Provider { - return New(Options{ - Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Gone: m.Gone, - Offers: map[string]Offer{ - "postgres-database": {Credential: PerConsumer, Serves: map[string]any{ - "host": "postgres.novox.mesh", "port": 5432, "database": "${consumer:as}"}}, - }, - Logf: func(string, ...any) {}, - }) -} - -// A provider answers each consumer that binds it with an entry of its own in its own bucket: composed from its -// served values and the consumer's identity, naming the credential the vault keeps for that consumer, and never -// the credential itself; its adapter is handed each consumer's own value read from the vault. -func TestEachConsumerGetsAnEntryOfItsOwnAndTheVaultsValue(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{ - "pair/novox/postgres-database/ace/letta": "letta-secret-1", - "pair/novox/postgres-database/g14/umami": "umami-secret-2", - }} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) - p := postgres(bus, v, m) - out, err := p.Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database", - "g14.umami.postgres-database"}) { - t.Fatalf("entries %v", got) - } - e := entryOf(t, bus, "ace.letta.postgres-database") - if e.Provider != "novox" || e.ProviderModule != "postgres" || e.Consumer != "ace" || e.Module != "letta" || - e.As != "mesh_ace_letta" || e.User != "mesh_ace_letta" || - e.VaultName != "pair/novox/postgres-database/ace/letta" || e.Refused != "" { - t.Fatalf("entry %+v", e) - } - if e.Values["database"] != "mesh_ace_letta" || e.Values["host"] != "postgres.novox.mesh" || - e.Values["port"] != float64(5432) { - t.Fatalf("values %v", e.Values) - } - if m.made["ace.letta.postgres-database"] != "letta-secret-1" || - m.made["g14.umami.postgres-database"] != "umami-secret-2" { - t.Fatalf("made %v", m.made) - } - if bytes.Contains(bus.everything(), []byte("secret-")) { - t.Fatal("a credential is in a bucket") - } - for _, o := range out { - if o.Status != Answered { - t.Fatalf("outcome %+v", o) - } - } -} - -// A provider whose offer shares one credential (ADR 0158) names the vault's one value for the provision, and its -// one user name, to every consumer. -func TestASharedCredentialIsTheVaultsOneValueAndTheProvidersOneUser(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"provision/novox/sonarr-api": "one-value"}} - assign(t, bus, "ace", "overseerr", bind("sonarr-api", "", "novox", "sonarr")) - assign(t, bus, "g14", "homepage", bind("sonarr-api", "", "novox", "sonarr")) - p := New(Options{Node: "novox", Module: "sonarr", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, - Offers: map[string]Offer{"sonarr-api": {Credential: Shared, User: "sonarr", - Serves: map[string]any{"url": "http://sonarr.novox.mesh:8989"}}}}) - if _, err := p.Pass(context.Background()); err != nil { - t.Fatal(err) - } - for _, key := range []string{"ace.overseerr.sonarr-api", "g14.homepage.sonarr-api"} { - var e Entry - if err := json.Unmarshal(bus.get(BucketOf("sonarr"), key), &e); err != nil { - t.Fatalf("%s: %v", key, err) - } - if e.VaultName != "provision/novox/sonarr-api" || e.User != "sonarr" || e.Values["url"] == nil { - t.Fatalf("%s: %+v", key, e) - } - if m.made[key] != "one-value" { - t.Fatalf("%s made with %q", key, m.made[key]) - } - } - if bytes.Contains(bus.everything(), []byte("one-value")) { - t.Fatal("the shared credential is in a bucket") - } -} - -// A consumer that keeps several holders of one provision is served no derived value: its entry refuses it, naming -// both ends, so its client can report it (ADR 0349 clause 12, ADR 0202 §10); nothing is made for it. -func TestAMultiHolderConsumerIsRefusedADerivedValueNamingBothEnds(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta/archive": "x-secret"}} - assign(t, bus, "ace", "letta", bind("postgres-database", "archive", "novox", "postgres")) - out, err := postgres(bus, v, m).Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - e := entryOf(t, bus, "ace.letta.postgres-database.archive") - for _, says := range []string{"letta on ace", "postgres on novox", "postgres-database", "archive"} { - if !strings.Contains(e.Refused, says) { - t.Fatalf("the refusal does not name %q: %s", says, e.Refused) - } - } - if e.Values != nil || e.VaultName != "" { - t.Fatalf("a refused entry carries values: %+v", e) - } - if m.calls != 0 { - t.Fatal("the adapter made a refused consumer") - } - if len(out) != 1 || out[0].Status != Refused { - t.Fatalf("outcomes %+v", out) - } -} - -// A consumer bound under a local name is served when the offer derives nothing from it: its credential is its own -// pair row under that local name. -func TestALocalNameIsServedWhenNothingIsDerived(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/redis-cache/ace/letta/queue": "q-secret"}} - assign(t, bus, "ace", "letta", bind("redis-cache", "queue", "novox", "redis")) - p := New(Options{Node: "novox", Module: "redis", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, - Offers: map[string]Offer{"redis-cache": {Credential: PerConsumer, Serves: map[string]any{"port": 6379}}}}) - if _, err := p.Pass(context.Background()); err != nil { - t.Fatal(err) - } - var e Entry - if err := json.Unmarshal(bus.get(BucketOf("redis"), "ace.letta.redis-cache.queue"), &e); err != nil { - t.Fatal(err) - } - if e.Refused != "" || e.VaultName != "pair/novox/redis-cache/ace/letta/queue" || e.Local != "queue" { - t.Fatalf("entry %+v", e) - } - if m.made["ace.letta.redis-cache.queue"] != "q-secret" { - t.Fatalf("made %v", m.made) - } -} - -// A consumer that unbinds loses its entry and the adapter hears it is gone; an entry another machine's instance of -// the same provider wrote is not this one's to remove. -func TestAnUnbindRemovesTheEntryAndLeavesAnotherProvidersAlone(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - other, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database", - Consumer: "g14", Module: "gitea"}) - _ = bus.Put(context.Background(), BucketOf("postgres"), "g14.gitea.postgres-database", other) - p := postgres(bus, v, m) - if _, err := p.Pass(context.Background()); err != nil { - t.Fatal(err) - } - retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres") - out, err := p.Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"g14.gitea.postgres-database"}) { - t.Fatalf("entries left %v", got) - } - if !slices.Equal(m.gone, []string{"ace.letta.postgres-database"}) { - t.Fatalf("gone %v", m.gone) - } - if len(out) != 1 || out[0].Status != Removed { - t.Fatalf("outcomes %+v", out) - } -} - -// A bind whose credential the vault does not hold yet is waited for: nothing is made and no entry is written, so a -// consumer is never told of a login its provider has not made; once the vault holds it, it is answered. -func TestACredentialTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - p := postgres(bus, v, m) - out, err := p.Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(out) != 1 || out[0].Status != Waiting || !strings.Contains(out[0].Why, "pair/novox/postgres-database/ace/letta") { - t.Fatalf("outcomes %+v", out) - } - if bus.keys(BucketOf("postgres")) != nil || m.calls != 0 { - t.Fatal("a consumer was answered before its credential was there") - } - v.values["pair/novox/postgres-database/ace/letta"] = "late" - if _, err := p.Pass(context.Background()); err != nil { - t.Fatal(err) - } - if entryOf(t, bus, "ace.letta.postgres-database").Refused != "" || m.made["ace.letta.postgres-database"] != "late" { - t.Fatal("not answered once the vault held the value") - } -} - -// Only the binds naming this provider, on this machine, are its own; a bind naming it for a provision it does not -// offer is refused, naming both ends. -func TestOnlyBindsNamingThisProviderAreAnswered(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres"), - bind("mongodb-database", "", "novox", "postgres")) - assign(t, bus, "ace", "other", bind("postgres-database", "", "novox", "mssql")) - out, err := postgres(bus, v, m).Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.mongodb-database"}) { - t.Fatalf("entries %v", got) - } - e := entryOf(t, bus, "ace.letta.mongodb-database") - if !strings.Contains(e.Refused, "postgres on novox") || !strings.Contains(e.Refused, "letta on ace") { - t.Fatalf("refusal %q", e.Refused) - } - if len(out) != 1 || m.calls != 0 { - t.Fatalf("outcomes %+v, %d made", out, m.calls) - } -} - -// A plan that cannot be read is skipped and said, by its key and why; the rest of the pass goes on (issue 825, -// finding 3). Its consumer's own entry is left alone, since a bind missed would read as an unbind. -func TestAnUnreadableAssignmentIsSkippedAndSaidAndTheRestGoesOn(t *testing.T) { - for name, bad := range map[string]string{"not json": "{not json", - "another key": `{"v":1,"node":"g14","module":"umami","plan":{"templates":{"source":"","digest":""},"files":null}}`} { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s", - "pair/novox/postgres-database/g14/umami": "u"}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - p := postgres(bus, v, m) - if _, err := p.Pass(context.Background()); err != nil { - t.Fatal(err) - } - _ = bus.Put(context.Background(), provisions.PlansBucket, "ace.letta", []byte(bad)) - assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) - var logged []string - p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } - out, err := p.Pass(context.Background()) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if bus.get(BucketOf("postgres"), "g14.umami.postgres-database") == nil { - t.Fatalf("%s: the good consumer was not answered", name) - } - if bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil || len(m.gone) != 0 { - t.Fatalf("%s: the entry of the unreadable assignment's consumer was removed", name) - } - said := false - for _, o := range out { - if o.Key == "ace.letta" && o.Status == Failed && o.Why != "" { - said = true - } - } - if !said || !strings.Contains(strings.Join(logged, "\n"), "ace.letta") { - t.Fatalf("%s: the skipped assignment is not said: %+v, %q", name, out, logged) - } - } -} - -// An adapter that fails is said with the credential taken out of its words, and its entry says it failed, serving -// nothing (novox/hq ADR 0570 clause 3: the provider's outcome is its entry, which the holder of mesh-provisioner reads). -func TestAFailedMakeIsSaidWithoutTheCredential(t *testing.T) { - bus, m := newStandIn(), &maker{fail: errors.New(`password "hunter2-secret" rejected`)} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "hunter2-secret"}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - var logged []string - p := postgres(bus, v, m) - p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } - out, err := p.Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(out) != 1 || out[0].Status != Failed || strings.Contains(out[0].Why, "hunter2") { - t.Fatalf("outcomes %+v", out) - } - if strings.Contains(strings.Join(logged, "\n"), "hunter2") || len(logged) == 0 { - t.Fatalf("logged %q", logged) - } - var e Entry - raw := bus.get(BucketOf("postgres"), "ace.letta.postgres-database") - if err := json.Unmarshal(raw, &e); err != nil || e.State != "failed" || e.Values != nil || e.VaultName != "" || - strings.Contains(string(raw), "hunter2") { - t.Fatalf("the failed entry is %s", raw) - } - - // A provider not ready yet (gitea after a restart, its keys not read; the regression of 2026-10-30) is no failure: - // a first entry waits serving nothing, and once made, a later not ready keeps serving what it served. - m.fail = fmt.Errorf("keys not read yet: %w", ErrNotReady) - _ = bus.Delete(context.Background(), BucketOf("postgres"), "ace.letta.postgres-database") - if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting { - t.Fatalf("not ready at first: %+v", out) - } - raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database") - if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || !strings.Contains(e.Why, "keys not read yet") || e.Values != nil { - t.Fatalf("the first entry not ready is %s", raw) - } - m.fail = nil - if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Answered { - t.Fatalf("made: %+v", out) - } - var made Entry - _ = json.Unmarshal(bus.get(BucketOf("postgres"), "ace.letta.postgres-database"), &made) - m.fail = ErrNotReady - if out, _ := p.Pass(context.Background()); len(out) != 1 || out[0].Status != Waiting { - t.Fatalf("not ready after a restart: %+v", out) - } - raw = bus.get(BucketOf("postgres"), "ace.letta.postgres-database") - if err := json.Unmarshal(raw, &e); err != nil || e.State != "waits" || e.VaultName != made.VaultName || e.User != made.User || - fmt.Sprint(e.Values) != fmt.Sprint(made.Values) || len(made.Values) == 0 { - t.Fatalf("not ready after it served, the entry is %s, it served %+v", raw, made) - } -} - -// Run watches the binds on the stand-in bus: a consumer that binds is answered, and one that unbinds is removed, -// without a pass asked by hand. -func TestRunAnswersBindsAndUnbindsAsTheyAreWatched(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} - p := postgres(bus, v, m) - p.opts.Every = time.Hour - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- p.Run(ctx) }() - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") != nil }) - retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres") - waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil }) - cancel() - if err := <-done; err != nil && !errors.Is(err, context.Canceled) { - t.Fatal(err) - } -} - -func waitFor(t *testing.T, ok func() bool) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for !ok() { - if time.Now().After(deadline) { - t.Fatal("not within 3s") - } - time.Sleep(5 * time.Millisecond) - } -} - -// The consumer's identity is the mesh's (ADR 0202 §1): mesh__, lower case, `_` for anything else, -// and its DNS form writes `-` for `_`. -func TestTheIdentityIsTheMeshsAndItsDNSForm(t *testing.T) { - if got := ConsumerIdentity("Home.Server", "my-app"); got != "mesh_home_server_my_app" { - t.Fatal(got) - } - got, err := ServedTo(map[string]any{"bucket": "b-${consumer:as:dns}", "n": 3}, "mesh_ace_letta") - if err != nil || got["bucket"] != "b-mesh-ace-letta" || got["n"] != 3 { - t.Fatalf("%v %v", got, err) - } - if _, err := ServedTo(map[string]any{"x": "${consumer:node}"}, "mesh_ace_letta"); err == nil { - t.Fatal("a fact the mesh does not state was filled") - } -} - -// A consumer's plan entry may carry what its module contributes to the provider (issue 776): the helper hands it to -// the provider's adapter with the consumer, and does not write it to the bucket. -func TestAConsumersContributionIsHandedToTheProviderPerConsumer(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{ - "pair/novox/postgres-database/ace/nextcloud": "a", "pair/novox/postgres-database/g14/umami": "b"}} - var pp catalogue.PlanProvision - if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+ - `"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}`), &pp); err != nil { - t.Fatal(err) - } - planned(t, bus, "ace", "nextcloud", pp) - assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) - if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { - t.Fatal(err) - } - want := map[string]any{"name": "nextcloud", "extensions": []any{"pg_trgm"}} - if got := m.contributed["ace.nextcloud.postgres-database"]; !reflect.DeepEqual(got, want) { - t.Fatalf("the adapter was handed the contribution %v, want %v", got, want) - } - if got := m.contributed["g14.umami.postgres-database"]; got != nil { - t.Fatalf("a consumer contributing nothing was handed %v", got) - } - if bytes.Contains(bus.get(BucketOf("postgres"), "ace.nextcloud.postgres-database"), []byte("pg_trgm")) { - t.Fatal("a contribution is in the provider's bucket") - } -} - -// A consumer's plan entry carries where its provider is, which machine, and who the consumer is (issue 788): the -// entry's values carry `at` and `from` as the binding file gives them, and its `as` is the plan's, so a module with a -// slug keeps the login it has today. An entry of a plan without them is as it was. -func TestAConsumersEntryCarriesAtFromAndTheAsOfItsPlan(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{ - "pair/novox/postgres-database/ace/claude-code": "a", "pair/novox/postgres-database/g14/umami": "b"}} - var pp catalogue.PlanProvision - if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+ - `"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}`), &pp); err != nil { - t.Fatal(err) - } - planned(t, bus, "ace", "claude-code", pp) - assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) - if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { - t.Fatal(err) - } - var e Entry - if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.claude-code.postgres-database"), &e); err != nil { - t.Fatal(err) - } - if e.Values["at"] != "10.7.0.1" || e.Values["from"] != "novox" || e.Values["port"] == nil { - t.Fatalf("the entry's values are %v", e.Values) - } - if e.As != "mesh_ace_agent" || e.User != "mesh_ace_agent" || m.as["ace.claude-code.postgres-database"] != "mesh_ace_agent" { - t.Fatalf("the entry is %+v, made as %q", e, m.as["ace.claude-code.postgres-database"]) - } - var plain Entry - if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil { - t.Fatal(err) - } - if _, has := plain.Values["at"]; has || plain.As != "mesh_g14_umami" { - t.Fatalf("an entry of a plan without them changed: %+v", plain) - } -} - -// A consumer's plan entry may carry the names the controller composed for it (issue 792): each is copied into the -// entry's values under its key, over anything the provider serves under it. An entry without them is as it was. -func TestAConsumersEntryCarriesTheNamesOfItsPlan(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{ - "pair/novox/postgres-database/ace/gitea": "a", "pair/novox/postgres-database/g14/umami": "b"}} - var pp catalogue.PlanProvision - if err := json.Unmarshal([]byte(`{"provision":"postgres-database","provider":"novox","provider-module":"postgres",`+ - `"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}`), &pp); err != nil { - t.Fatal(err) - } - planned(t, bus, "ace", "gitea", pp) - assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) - if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { - t.Fatal(err) - } - var e Entry - if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.gitea.postgres-database"), &e); err != nil { - t.Fatal(err) - } - if e.Values["name"] != "git.example.org" || e.Values["internal-name"] != "git.mesh" || - e.Values["name-web"] != "web.example.org" || e.Values["port"] == nil { - t.Fatalf("the entry's values are %v", e.Values) - } - var plain Entry - if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil { - t.Fatal(err) - } - if _, has := plain.Values["name"]; has { - t.Fatalf("an entry of a plan without names changed: %+v", plain) - } -} - -// refusedByTheStateGuard mirrors the key rule of the runtime's state guard (mesh-tools node-tools internal/bus -// state.go): a JSON key whose lowercased name, with `-`, `_` and spaces taken out, ends in one of these is refused. -func refusedByTheStateGuard(key string) bool { - k := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(key)) - for _, end := range []string{"password", "passwd", "secret", "token", "credential", "credentials", - "authorization", "apikey", "privatekey", "accesskey", "cookie"} { - if strings.HasSuffix(k, end) { - return true - } - } - return false -} - -func keysOf(v any, into *[]string) { - switch x := v.(type) { - case map[string]any: - for k, c := range x { - *into = append(*into, k) - keysOf(c, into) - } - case []any: - for _, c := range x { - keysOf(c, into) - } - } -} - -// Every entry the helper writes passes the state guard's key rule (issue 945), and names where the vault keeps the -// credential under `vault-name`, for a login per consumer and for a shared one. -func TestAnEntryHasNoKeyTheStateGuardRefuses(t *testing.T) { - for name, form := range map[string]CredentialForm{"pair": PerConsumer, "shared": Shared} { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1", - "provision/novox/postgres-database": "s2"}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - p := New(Options{Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, - Offers: map[string]Offer{"postgres-database": {Credential: form, User: "shared-user"}}}) - if _, err := p.Pass(context.Background()); err != nil { - t.Fatal(err) - } - var doc map[string]any - if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.letta.postgres-database"), &doc); err != nil { - t.Fatal(err) - } - var keys []string - keysOf(doc, &keys) - for _, k := range keys { - if refusedByTheStateGuard(k) { - t.Fatalf("%s: the entry carries the key %q, which the state guard refuses", name, k) - } - } - want := map[string]string{"pair": "pair/novox/postgres-database/ace/letta", - "shared": "provision/novox/postgres-database"}[name] - if doc["vault-name"] != want { - t.Fatalf("%s: vault-name is %v, want %s", name, doc["vault-name"], want) - } - } -} - -// The plan's credential form and holder name win over the offer's; the offer's form is the default (issue 825, -// finding 4). -func TestThePlansCredentialFormWinsOverTheOffers(t *testing.T) { - run := func(offer CredentialForm, plan map[string]string) Entry { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "p", - "provision/novox/postgres-database": "s", "provision/novox/postgres-database/ace/letta": "h"}} - b := bind("postgres-database", "", "novox", "postgres") - for k, val := range plan { - b[k] = val - } - assign(t, bus, "ace", "letta", b) - p := New(Options{Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, - Offers: map[string]Offer{"postgres-database": {Credential: offer, User: "shared-user"}}}) - out, err := p.Pass(context.Background()) - if err != nil || len(out) != 1 || out[0].Status != Answered { - t.Fatalf("%v %+v", err, out) - } - return entryOf(t, bus, "ace.letta.postgres-database") - } - if e := run(PerConsumer, map[string]string{"credential-form": "shared"}); e.VaultName != - "provision/novox/postgres-database" || e.User != "shared-user" { - t.Fatalf("shared over pair: %+v", e) - } - if e := run(Shared, map[string]string{"credential-form": "pair"}); e.VaultName != - "pair/novox/postgres-database/ace/letta" || e.User != "mesh_ace_letta" { - t.Fatalf("pair over shared: %+v", e) - } - for _, form := range []string{"issued", "given"} { - if e := run(PerConsumer, map[string]string{"credential-form": form, "name": "main"}); e.VaultName != - "provision/novox/postgres-database/ace/letta" || e.User != "mesh_ace_letta" { - t.Fatalf("%s holder: %+v", form, e) - } - } - if e := run(Shared, nil); e.VaultName != "provision/novox/postgres-database" { - t.Fatalf("the offer's form is the default: %+v", e) - } - { - // An offer that declares no credential is answered with none whatever form the plan says (the controller writes - // "pair" for such an offer): no vault name, and the Vault is never asked, so a provider with no Vault works (issue 825, - // finding 4; issue 923). - bus := newStandIn() - b := bind("route", "", "novox", "route-proxy") - b["credential-form"] = "pair" - assign(t, bus, "ace", "letta", b) - p := New(Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: func(string, ...any) {}, - Offers: map[string]Offer{"route": {Credential: NoCredential}}}) - out, err := p.Pass(context.Background()) - if err != nil || len(out) != 1 || out[0].Status != Answered { - t.Fatalf("%v %+v", err, out) - } - var e Entry - if err := json.Unmarshal(bus.get(BucketOf("route-proxy"), "ace.letta.route"), &e); err != nil || e.VaultName != "" || - e.User != "" { - t.Fatalf("%v %+v", err, e) - } - } -} - -// An instance deletes only an entry it wrote, and only while the entry is as it read it: one that another instance of -// the provider wrote after the consumer moved stands (issue 825, finding 5). -func TestAnEntryAnotherInstanceWroteAfterTheMoveStands(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - a := postgres(bus, v, m) - if _, err := a.Pass(context.Background()); err != nil { - t.Fatal(err) - } - // The consumer moves to the instance on shanks: A's pass reads its own entry and the unbind, and while it lets - // the consumer go, B writes the consumer's entry. - retire(t, bus, "ace", "letta", "postgres-database", "novox", "postgres") - theirs, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database", - Consumer: "ace", Module: "letta", VaultName: "pair/shanks/postgres-database/ace/letta"}) - a.opts.Gone = func(ctx context.Context, e Entry) error { - return bus.Put(ctx, BucketOf("postgres"), e.Key(), theirs) - } - out, err := a.Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if got := bus.get(BucketOf("postgres"), "ace.letta.postgres-database"); !bytes.Equal(got, theirs) { - t.Fatalf("the other instance's entry was removed: %s", got) - } - for _, o := range out { - if o.Status == Removed { - t.Fatalf("outcomes %+v", out) - } - } -} - -// A failed Make is said with the credential taken out in the forms it can come back in: as it is, in base64 (standard -// and URL, with and without padding) and as a JSON string says it (issue 825, finding 6). -func TestAFailedMakeIsSaidWithoutTheCredentialInAnyOfItsForms(t *testing.T) { - cred := "pa/ss+w\"rd?>>x" - b := []byte(cred) - jsonQuoted, _ := json.Marshal(cred) - forms := map[string]string{ - "raw": cred, "std": base64.StdEncoding.EncodeToString(b), "std unpadded": base64.RawStdEncoding.EncodeToString(b), - "url": base64.URLEncoding.EncodeToString(b), "url unpadded": base64.RawURLEncoding.EncodeToString(b), - "json": string(jsonQuoted[1 : len(jsonQuoted)-1]), - } - if forms["std"] == forms["url"] || forms["json"] == forms["raw"] { - t.Fatal("the test credential does not tell the forms apart") - } - for name, form := range forms { - bus, m := newStandIn(), &maker{fail: errors.New("login failed for " + form + " at the server")} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": cred}} - assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) - out, err := postgres(bus, v, m).Pass(context.Background()) - if err != nil || len(out) != 1 { - t.Fatalf("%v %+v", err, out) - } - if strings.Contains(out[0].Why, form) || !strings.Contains(out[0].Why, "") { - t.Fatalf("%s form: %q", name, out[0].Why) - } - } -} - -// novox/hq ADR 0570 clauses 3 and 5 (issue 1954 L6-P3, P4): a consumer the seat mesh-provisioner plans is bound by its -// plan and its provision key, never by its assignment. Its entry is made only once its key is asked and every provision -// it depends on is provided, and says provided; a plan gone retires nothing by itself; a retiring key is let go only on -// the operator's yes. -func TestTheSeatsKeysSayWhenAProviderActsAndWhenItLetsGo(t *testing.T) { - ctx := context.Background() - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}} - // The same consumer's assignment names another provider: the seat's plan wins. - assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres")) - plan, _ := provisions.EncodePlan(provisions.Plan{Node: "ace", Module: "letta", Plan: catalogue.Plan{ - Provisions: []catalogue.PlanProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}}}) - _ = bus.Put(ctx, provisions.PlansBucket, "ace.letta", plan) - key := "ace.letta.postgres-database" - put := func(k string, state string, depends []string, warrant *provisions.Warrant) { - pr := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "letta", Node: "ace"}, - Provider: provisions.End{Module: "postgres", Node: "novox"}, DependsOn: depends, State: state, Warrant: warrant} - if k != key { - _, _, pr.Provision, _, _ = provisions.ParseKey(k) - pr.Provider = provisions.End{Module: "mesh-vault", Node: "novox"} - } - if state != provisions.Asked && state != provisions.Provided { - pr.Why = "said" - } - raw, err := provisions.Encode(pr) - if err != nil { - t.Fatal(err) - } - _ = bus.Put(ctx, provisions.ProvisionsBucket, k, raw) - } - p := postgres(bus, v, m) - steps := []struct { - name string - before func() - status string - says string - entry bool - }{ - {name: "no key yet", status: Waiting, says: "holds no key"}, - {name: "a dependency asked", before: func() { - put("ace.letta.secret", provisions.Asked, nil, nil) - put(key, provisions.Asked, []string{"ace.letta.secret"}, nil) - }, status: Waiting, says: "depends on ace.letta.secret, which is asked"}, - {name: "the dependency provided", before: func() { put("ace.letta.secret", provisions.Provided, nil, nil) }, - status: Answered, entry: true}, - {name: "its plan gone: nothing let go by itself", before: func() { _ = bus.Delete(ctx, provisions.PlansBucket, "ace.letta") }, - entry: true}, - {name: "retiring, not yet answered", before: func() { put(key, provisions.Retiring, nil, nil) }, entry: true}, - {name: "retiring, the operator's yes", before: func() { - put(key, provisions.Retiring, nil, &provisions.Warrant{Ask: "a-1", Option: "yes", By: "the operator"}) - }, status: Removed}, - } - for _, s := range steps { - if s.before != nil { - s.before() - } - out, err := p.Pass(ctx) - if err != nil { - t.Fatalf("%s: %v", s.name, err) - } - var mine []Outcome - for _, o := range out { - if o.Key == key { - mine = append(mine, o) - } - } - if s.status != "" && (len(mine) != 1 || mine[0].Status != s.status || !strings.Contains(mine[0].Why, s.says)) { - t.Errorf("%s: %+v", s.name, mine) - } - if held := bus.get(BucketOf("postgres"), key) != nil; held != s.entry { - t.Errorf("%s: an entry held %v", s.name, held) - } - if s.entry && entryOf(t, bus, key).State != provisions.Provided { - t.Errorf("%s: the entry does not say provided", s.name) - } - } - if !slices.Equal(m.gone, []string{key}) || m.made[key] != "s1" { - t.Errorf("gone %v, made %v: let go once, on the warrant, and made from the vault's value", m.gone, m.made) - } -} - -// Issue 1126 review 1 (mcp-endpoint asked on ace and shanks, 2026-11-02 11:56): a consumer's key composed from its newest -// build carries the plan entry it was composed from, though its wanted build's plan names no such provision; the -// provider binds it from the key and provides it, so the want that waits for it moves. A key of this provider that -// carries no entry and no plan names is said, never bound silently or left without a word. -func TestAKeyNoPlanNamesIsBoundFromTheEntryItCarries(t *testing.T) { - bus, m := newStandIn(), &maker{} - v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "letta-secret-1"}} - // letta's wanted plan names nothing of postgres; its key, from its newest build, carries the entry. - planned(t, bus, "ace", "letta") - entry := catalogue.PlanProvision{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres", As: "mesh_ace_letta"} - pr := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "letta", Node: "ace"}, - Provider: provisions.End{Module: "postgres", Node: "novox"}, State: provisions.Asked, Plan: &entry} - raw, err := provisions.Encode(pr) - if err != nil { - t.Fatal(err) - } - _ = bus.Put(context.Background(), provisions.ProvisionsBucket, "ace.letta.postgres-database", raw) - // And umami's key carries none: asked, but no plan names it. - bare := provisions.Provision{Provision: "postgres-database", Consumer: provisions.End{Module: "umami", Node: "g14"}, - Provider: provisions.End{Module: "postgres", Node: "novox"}, State: provisions.Asked} - raw, _ = provisions.Encode(bare) - _ = bus.Put(context.Background(), provisions.ProvisionsBucket, "g14.umami.postgres-database", raw) - - out, err := postgres(bus, v, m).Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if e := entryOf(t, bus, "ace.letta.postgres-database"); e.Consumer != "ace" || e.Module != "letta" || e.Refused != "" { - t.Errorf("the entry %+v", e) - } - said := map[string]Outcome{} - for _, o := range out { - said[o.Key] = o - } - if said["ace.letta.postgres-database"].Status != Answered { - t.Errorf("letta: %+v", said["ace.letta.postgres-database"]) - } - if o := said["g14.umami.postgres-database"]; o.Status != Waiting || !strings.Contains(o.Why, "no plan names it") { - t.Errorf("umami: %+v", o) - } - if bus.get(BucketOf("postgres"), "g14.umami.postgres-database") != nil { - t.Error("a key with no entry and no plan was bound") - } -} diff --git a/go/provider/statebus_test.go b/go/provider/statebus_test.go deleted file mode 100644 index 7e20dff..0000000 --- a/go/provider/statebus_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package provider - -import ( - "bufio" - "context" - "encoding/json" - "io" - "strings" - "testing" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/provisions" -) - -// runtime drives a bundle over pipes as the runtime does: it reads what the bundle says and sends what the runtime would. -type runtime struct { - t *testing.T - lines *bufio.Scanner - in io.Writer -} - -func (r *runtime) next() map[string]any { - done := make(chan map[string]any, 1) - go func() { - if r.lines.Scan() { - var m map[string]any - _ = json.Unmarshal(r.lines.Bytes(), &m) - done <- m - } - }() - select { - case m := <-done: - return m - case <-time.After(3 * time.Second): - r.t.Fatal("the bundle said nothing") - return nil - } -} - -func (r *runtime) send(m map[string]any) { - b, _ := json.Marshal(m) - _, _ = r.in.Write(append(b, '\n')) -} - -func (r *runtime) call(id int, tool string, args map[string]any) { - r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": map[string]any{"name": tool, "arguments": args}}) -} - -func serve(t *testing.T, tools []stdio.Tool) *runtime { - t.Setenv(stdio.ServedAs, "keeper") - inR, inW := io.Pipe() - outR, outW := io.Pipe() - go func() { _ = stdio.ServeOn("", tools, inR, outW) }() - t.Cleanup(func() { _ = inW.Close() }) - return &runtime{t: t, lines: bufio.NewScanner(outR), in: inW} -} - -// The bus of a moved provider maps its own bucket to the state `bound` and the seat's plans to the state -// `mesh-provisioner.plans` (novox/hq ADR 0570 clause 2), writes only the own bucket's state, and refuses any other -// bucket, the controller's assignments among them. -func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) { - bus := StateBus("postgres") - rt := serve(t, []stdio.Tool{ - {Name: "watch", Run: func(a map[string]any) (any, error) { - return nil, bus.Watch(context.Background(), a["bucket"].(string), func(string, []byte) {}) - }}, - {Name: "put", Run: func(map[string]any) (any, error) { - return "put", bus.Put(context.Background(), BucketOf("postgres"), "k", []byte(`{"a":1}`)) - }}, - {Name: "delete", Run: func(map[string]any) (any, error) { - return "deleted", bus.Delete(context.Background(), BucketOf("postgres"), "k") - }}, - }) - for i, c := range []struct{ bucket, state string }{ - {BucketOf("postgres"), "bound"}, - {provisions.PlansBucket, "mesh-provisioner.plans"}, - } { - rt.call(i+1, "watch", map[string]any{"bucket": c.bucket}) - w := rt.next() - if w["method"] != "mesh/state.watch" || w["params"].(map[string]any)["state"] != c.state { - t.Fatalf("bucket %s asked %v, want the state %s", c.bucket, w, c.state) - } - rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}}) - rt.next() - } - rt.call(3, "put", nil) - p := rt.next() - if p["method"] != "mesh/state.put" || p["params"].(map[string]any)["state"] != "bound" || p["params"].(map[string]any)["key"] != "k" { - t.Fatalf("put asked %v", p) - } - rt.send(map[string]any{"jsonrpc": "2.0", "id": p["id"], "result": map[string]any{"revision": 1}}) - rt.next() - rt.call(4, "delete", nil) - d := rt.next() - if d["method"] != "mesh/state.delete" || d["params"].(map[string]any)["state"] != "bound" { - t.Fatalf("delete asked %v", d) - } - rt.send(map[string]any{"jsonrpc": "2.0", "id": d["id"], "result": map[string]any{}}) - rt.next() - - for _, bucket := range []string{"other_bound", "elsewhere", "mesh-controller_assignments"} { - if err := bus.Watch(context.Background(), bucket, func(string, []byte) {}); err == nil || !strings.Contains(err.Error(), "not a bucket") { - t.Fatalf("a watch of %s gave %v", bucket, err) - } - if err := bus.Put(context.Background(), bucket, "k", nil); err == nil { - t.Fatalf("a put to %s was allowed", bucket) - } - if err := bus.Delete(context.Background(), bucket, "k"); err == nil { - t.Fatalf("a delete in %s was allowed", bucket) - } - } -} - -// A watch whose ctx has ended calls its handler no more, though the runtime keeps sending changes: the Bus contract -// says a watch lasts until ctx ends, and a stdio state watch lasts as long as the process. -func TestStateBusWatchCallsItsHandlerNoMoreOnceItsContextEnded(t *testing.T) { - bus := StateBus("postgres") - ctx, cancel := context.WithCancel(context.Background()) - handled := make(chan string, 8) - rt := serve(t, []stdio.Tool{ - {Name: "start", Run: func(map[string]any) (any, error) { - return "started", bus.Watch(ctx, provisions.PlansBucket, func(key string, value []byte) { - handled <- key + "=" + string(value) - }) - }}, - {Name: "stop", Run: func(map[string]any) (any, error) { cancel(); return "stopped", nil }}, - }) - change := func(id int, watch any, key, op string) { - rt.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "mesh/state", "params": map[string]any{"watch": watch, - "change": map[string]any{"state": "mesh-controller.assignments", "key": key, "op": op, "value": "v"}}}) - if r := rt.next(); r["id"] != float64(id) || r["error"] != nil { - t.Fatalf("a change was answered %v", r) - } - } - rt.call(1, "start", nil) - w := rt.next() - rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}}) - rt.next() - - change(50, w["id"], "before", "put") - if got := <-handled; got != `before="v"` { - t.Fatalf("handled %q", got) - } - rt.call(2, "stop", nil) - rt.next() - change(51, w["id"], "after", "put") - change(52, w["id"], "after", "delete") - select { - case got := <-handled: - t.Fatalf("the handler was called after its context ended: %q", got) - case <-time.After(100 * time.Millisecond): - } -} -- 2.54.0 From 7a142c9b5b6f51d108f6189263c9b03287ba4f1e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:39:07 +0100 Subject: [PATCH 116/144] forgekey keeps the command runner: the key keeper and key parsing are ssh-client's sshkeys, KindBuilder, ExecRunnerClean and Said the delivery sdk's (S-3, B9f; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/forgekey/keykeeper.go | 127 ------------------- go/forgekey/keykeeper_test.go | 229 ---------------------------------- go/forgekey/keys.go | 110 ---------------- go/forgekey/runner.go | 81 ++---------- go/forgekey/runner_test.go | 21 +--- go/forgekey/said_test.go | 28 ----- 6 files changed, 14 insertions(+), 582 deletions(-) delete mode 100644 go/forgekey/keykeeper.go delete mode 100644 go/forgekey/keykeeper_test.go delete mode 100644 go/forgekey/keys.go delete mode 100644 go/forgekey/said_test.go diff --git a/go/forgekey/keykeeper.go b/go/forgekey/keykeeper.go deleted file mode 100644 index 7349d88..0000000 --- a/go/forgekey/keykeeper.go +++ /dev/null @@ -1,127 +0,0 @@ -// Package forgekey is the key keeper every module that reaches the forge over SSH shares: ssh-client for a -// machine's accounts, a build module for its own (design 47 R4c-3, ADR 0466 §2; issue 1469). -package forgekey - -// The key this machine's account uses to reach the forge over SSH (novox/hq ADR 0399, ADR 0400; issue 955, -// area S1). It is made here, on the machine, once: ssh-keygen writes ~/.ssh/mesh_ed25519 only when it is -// missing, so the private half never leaves the machine and an existing key is never replaced. The public -// half and its fingerprint go into this module's `keys` state, one entry per account under ., -// for the forge's provider to register. The tools bundle is the one process of this module, so the keeper -// runs inside it beside the tools (main.go), the way claude-code reports what it holds. - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -// KeyFile is the key's name under ~/.ssh: never one of the operator's own id_* files. -const KeyFile = "mesh_ed25519" - -// The kinds of key (ADR 0400 §1, ADR 0466 §2): a machine's account's own, and a build module's. -const ( - KindMachine = "machine" - KindBuilder = "builder" -) - -// KeyRetry is how soon it looks again after a failure. -const KeyRetry = 30 * time.Second - -// KeyRound is how often the keeper looks again. -const KeyRound = 10 * time.Minute - -// KeyEntry is one value of the keys state. It carries the public half only. -type KeyEntry struct { - Node string `json:"node"` - Account string `json:"account"` - Kind string `json:"kind"` - Public string `json:"public"` - Fingerprint string `json:"fingerprint"` - At string `json:"at"` -} - -// KeyPutter is the keys state, as far as the keeper needs it. -type KeyPutter interface { - Put(key string, value any) (uint64, error) -} - -// KeyKeeper keeps one account's key and its entry. -type KeyKeeper struct { - Home, Node, Account string - Kind string // KindMachine or KindBuilder - Run Runner - State KeyPutter - Now func() time.Time - // After waits between rounds; time.After when nil. A test sets it to see which pause was chosen. - After func(time.Duration) <-chan time.Time - - reported string // the public half and fingerprint last put -} - -func (k *KeyKeeper) path() string { return filepath.Join(k.Home, ".ssh", KeyFile) } - -// Once makes the key when it is missing and puts the entry when it differs from the one last put. -func (k *KeyKeeper) Once(ctx context.Context) error { - if err := k.ensure(ctx); err != nil { - return err - } - raw, err := os.ReadFile(k.path() + ".pub") - if err != nil { - return fmt.Errorf("the public half: %w", err) - } - pk, err := ParseKeyLine(string(raw)) - if err != nil { - return fmt.Errorf("%s.pub: %w", k.path(), err) - } - public := strings.TrimSpace(string(raw)) - if sig := public + "|" + pk.Fingerprint; sig == k.reported { - return nil - } - entry := KeyEntry{Node: k.Node, Account: k.Account, Kind: k.Kind, Public: public, - Fingerprint: pk.Fingerprint, At: k.Now().UTC().Format(time.RFC3339)} - if _, err := k.State.Put(k.Node+"."+k.Account, entry); err != nil { - return err - } - k.reported = public + "|" + pk.Fingerprint - return nil -} - -// ensure makes the pair when the private half is missing. Never over an existing key. -func (k *KeyKeeper) ensure(ctx context.Context) error { - if _, err := os.Lstat(k.path()); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err - } - if err := os.MkdirAll(filepath.Dir(k.path()), 0o700); err != nil { - return err - } - r := k.Run(ctx, nil, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", k.Account+"@"+k.Node+" (mesh)", "-f", k.path()) - if r.Status != 0 || r.Err != "" { - return fmt.Errorf("ssh-keygen: %s", Said(r.Err+"\n"+r.Stderr)) - } - return nil -} - -// Keep runs Once now and every KeyRound, reporting a failure through say and trying again at the next round. -func (k *KeyKeeper) Keep(ctx context.Context, say func(string, ...any)) { - for { - pause := KeyRound - if err := k.Once(ctx); err != nil { - say("the forge key: %v", err) - pause = KeyRetry // the state or the bus's grant may arrive after the process - } - after := k.After - if after == nil { - after = time.After - } - select { - case <-ctx.Done(): - return - case <-after(pause): - } - } -} diff --git a/go/forgekey/keykeeper_test.go b/go/forgekey/keykeeper_test.go deleted file mode 100644 index a52e176..0000000 --- a/go/forgekey/keykeeper_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package forgekey - -import ( - "context" - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// Issue 955, area S1: the key the machine's account makes for the forge, and its entry in the keys state. - -type fakeState struct{ puts map[string][]byte } - -func (f *fakeState) Put(key string, value any) (uint64, error) { - raw, _ := json.Marshal(value) - if f.puts == nil { - f.puts = map[string][]byte{} - } - f.puts[key] = raw - return uint64(len(f.puts)), nil -} - -// ed25519Line is a public key line with a well-formed ed25519 blob. -func ed25519Line(seed byte, comment string) string { - blob := []byte{} - for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} { - part2 := append([]byte(nil), part...) - if len(part2) == 32 { - part2[0] = seed - } - l := make([]byte, 4) - binary.BigEndian.PutUint32(l, uint32(len(part2))) - blob = append(append(blob, l...), part2...) - } - return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment -} - -// keygenRunner stands in for ssh-keygen -t ed25519: it writes the pair the command names. -func keygenRunner(calls *[][]string) Runner { - return func(ctx context.Context, env []string, name string, args ...string) Ran { - *calls = append(*calls, append([]string{name}, args...)) - var file, comment string - for i, a := range args { - switch a { - case "-f": - file = args[i+1] - case "-C": - comment = args[i+1] - } - } - if name != "ssh-keygen" || file == "" { - return Ran{Status: 1, Stderr: "unexpected"} - } - os.WriteFile(file, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE-SECRET\n"), 0o600) - os.WriteFile(file+".pub", []byte(ed25519Line(7, comment)+"\n"), 0o644) - return Ran{} - } -} - -func newKeeper(t *testing.T, home string, st *fakeState, calls *[][]string) *KeyKeeper { - return &KeyKeeper{Home: home, Node: "g14", Account: "nox", Kind: KindMachine, Run: keygenRunner(calls), State: st, - Now: func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) }} -} - -func TestKeeperMakesTheKeyAndWritesItsEntry(t *testing.T) { - home := t.TempDir() - st, calls := &fakeState{}, &[][]string{} - if err := newKeeper(t, home, st, calls).Once(context.Background()); err != nil { - t.Fatal(err) - } - raw, ok := st.puts["g14.nox"] - if !ok || len(st.puts) != 1 { - t.Fatalf("entries = %v, want one under g14.nox", st.puts) - } - var e map[string]string - json.Unmarshal(raw, &e) - if e["node"] != "g14" || e["account"] != "nox" || e["kind"] != "machine" || e["at"] != "2026-10-16T12:00:00Z" { - t.Errorf("entry = %v", e) - } - if !strings.HasPrefix(e["public"], "ssh-ed25519 ") || !strings.HasSuffix(e["public"], " nox@g14 (mesh)") { - t.Errorf("public = %q", e["public"]) - } - if !strings.HasPrefix(e["fingerprint"], "SHA256:") { - t.Errorf("fingerprint = %q", e["fingerprint"]) - } - if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") { - t.Errorf("the entry carries private material: %s", raw) - } - cmd := strings.Join((*calls)[0], " ") - for _, want := range []string{"-t ed25519", "-N ", "-C nox@g14 (mesh)", filepath.Join(home, ".ssh", "mesh_ed25519")} { - if !strings.Contains(cmd, want) { - t.Errorf("ssh-keygen call %q lacks %q", cmd, want) - } - } -} - -func TestKeeperKeepsAnExistingKeyByteForByte(t *testing.T) { - home := t.TempDir() - os.MkdirAll(filepath.Join(home, ".ssh"), 0o700) - priv, pub := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, ".ssh", "mesh_ed25519.pub") - os.WriteFile(priv, []byte("mine"), 0o600) - os.WriteFile(pub, []byte(ed25519Line(9, "nox@g14 (mesh)")+"\n"), 0o644) - st, calls := &fakeState{}, &[][]string{} - k := newKeeper(t, home, st, calls) - if err := k.Once(context.Background()); err != nil { - t.Fatal(err) - } - if len(*calls) != 0 { - t.Errorf("ssh-keygen ran over an existing key: %v", *calls) - } - if b, _ := os.ReadFile(priv); string(b) != "mine" { - t.Errorf("private file changed: %q", b) - } - if _, ok := st.puts["g14.nox"]; !ok { - t.Errorf("no entry for the existing key") - } - // A second round with nothing changed writes nothing. - delete(st.puts, "g14.nox") - k.Once(context.Background()) - if len(st.puts) != 0 { - t.Errorf("an unchanged key was put again") - } -} - -func TestKeeperNeverTouchesTheOperatorsOwnKeys(t *testing.T) { - home := t.TempDir() - os.MkdirAll(filepath.Join(home, ".ssh"), 0o700) - own := filepath.Join(home, ".ssh", "id_ed25519") - os.WriteFile(own, []byte("operator"), 0o600) - st, calls := &fakeState{}, &[][]string{} - newKeeper(t, home, st, calls).Once(context.Background()) - if b, _ := os.ReadFile(own); string(b) != "operator" { - t.Errorf("the operator's key changed") - } -} - -// 1469/AC1: an empty home, kind builder, account mesh-build-go: ssh-keygen once, for the one file, and the entry -// carries the kind, the public half and its fingerprint. -func TestBuilderKeyIsMadeOnceAndPutWithItsKind(t *testing.T) { - home := t.TempDir() - st, calls := &fakeState{}, &[][]string{} - k := newKeeper(t, home, st, calls) - k.Account, k.Kind = "mesh-build-go", KindBuilder - if err := k.Once(context.Background()); err != nil { - t.Fatal(err) - } - if len(*calls) != 1 || !strings.Contains(strings.Join((*calls)[0], " "), filepath.Join(home, ".ssh", "mesh_ed25519")) { - t.Errorf("ssh-keygen calls = %v, want one for mesh_ed25519 in the home", *calls) - } - var e KeyEntry - if err := json.Unmarshal(st.puts["g14.mesh-build-go"], &e); err != nil { - t.Fatalf("entry under g14.mesh-build-go: %v (%v)", err, st.puts) - } - pub, _ := os.ReadFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub")) - pk, _ := ParseKeyLine(string(pub)) - if e.Kind != "builder" || e.Public != strings.TrimSpace(string(pub)) || e.Fingerprint != pk.Fingerprint || e.Fingerprint == "" { - t.Errorf("entry = %+v", e) - } -} - -// 1469/AC2: an existing key, Once twice: ssh-keygen never, one put. -func TestExistingKeyIsPutOnceAcrossTwoRounds(t *testing.T) { - home := t.TempDir() - os.MkdirAll(filepath.Join(home, ".ssh"), 0o700) - os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519"), []byte("mine"), 0o600) - os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub"), []byte(ed25519Line(9, "x")+"\n"), 0o644) - st, calls := &countState{}, &[][]string{} - k := newKeeper(t, home, &fakeState{}, calls) - k.State = st - for i := 0; i < 2; i++ { - if err := k.Once(context.Background()); err != nil { - t.Fatal(err) - } - } - if len(*calls) != 0 || st.n != 1 { - t.Errorf("ssh-keygen calls = %d, puts = %d, want 0 and 1", len(*calls), st.n) - } -} - -type countState struct{ n int } - -func (c *countState) Put(string, any) (uint64, error) { c.n++; return uint64(c.n), nil } - -// 1469/AC3: the entry's JSON is ssh-client's own (mesh-catalog modules/ssh-client keykeeper_test.go's keys -// entry: node, account, kind, public, fingerprint, at, in that order); mesh-catalog's gitea provider reads it. -func TestKeyEntryJSONIsSSHClients(t *testing.T) { - raw, err := json.Marshal(KeyEntry{Node: "g14", Account: "nox", Kind: "machine", Public: "ssh-ed25519 AAAA nox@g14 (mesh)", - Fingerprint: "SHA256:abc", At: "2026-10-16T12:00:00Z"}) - if err != nil { - t.Fatal(err) - } - want := `{"node":"g14","account":"nox","kind":"machine","public":"ssh-ed25519 AAAA nox@g14 (mesh)","fingerprint":"SHA256:abc","at":"2026-10-16T12:00:00Z"}` - if string(raw) != want { - t.Errorf("entry JSON = %s\nwant %s", raw, want) - } -} - -type failState struct{} - -func (failState) Put(string, any) (uint64, error) { return 0, errors.New("no grant yet") } - -// 1469/AC4: a failing put is said, and the next look is after KeyRetry, not KeyRound. -func TestKeepSaysAFailedPutAndRetriesSoon(t *testing.T) { - home := t.TempDir() - k := newKeeper(t, home, &fakeState{}, &[][]string{}) - k.State = failState{} - ctx, cancel := context.WithCancel(context.Background()) - var said []string - var pauses []time.Duration - k.After = func(d time.Duration) <-chan time.Time { - pauses = append(pauses, d) - cancel() - return make(chan time.Time) - } - k.Keep(ctx, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }) - if len(said) != 1 || !strings.Contains(said[0], "no grant yet") { - t.Errorf("said = %v", said) - } - if len(pauses) != 1 || pauses[0] != KeyRetry { - t.Errorf("pauses = %v, want [%v]", pauses, KeyRetry) - } -} diff --git a/go/forgekey/keys.go b/go/forgekey/keys.go deleted file mode 100644 index 4af0499..0000000 --- a/go/forgekey/keys.go +++ /dev/null @@ -1,110 +0,0 @@ -package forgekey - -// Keys, by their public half only. A fingerprint is computed here from the public key's bytes, as -// ssh-keygen -l does (SHA256 of the key blob, unpadded base64); a private key is never read by this -// process — its first line, the PEM header, says it is one, and ssh-keygen is asked about it. - -import ( - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "fmt" - "math/big" - "strings" -) - -// KeyTypes are the public key algorithms an authorized_keys or known_hosts line can carry. -var KeyTypes = map[string]bool{ - "ssh-ed25519": true, "ssh-rsa": true, "ssh-dss": true, - "ecdsa-sha2-nistp256": true, "ecdsa-sha2-nistp384": true, "ecdsa-sha2-nistp521": true, - "sk-ssh-ed25519@openssh.com": true, "sk-ecdsa-sha2-nistp256@openssh.com": true, -} - -// PublicKey is one public key as a line carries it. -type PublicKey struct { - Type string `json:"type"` - Bits int `json:"bits"` - Fingerprint string `json:"fingerprint"` - Comment string `json:"comment"` - Options string `json:"options,omitempty"` - Weak string `json:"weak,omitempty"` -} - -// ParseKeyLine reads one authorized_keys line (options, type, key, comment) or a public key file's. -func ParseKeyLine(line string) (PublicKey, error) { - fields := fieldsQuoted(strings.TrimSpace(line)) - for i, f := range fields { - if !KeyTypes[f] || i+1 >= len(fields) { - continue - } - k := PublicKey{Type: f, Options: strings.Join(fields[:i], " "), Comment: strings.Join(fields[i+2:], " ")} - blob, err := base64.StdEncoding.DecodeString(fields[i+1]) - if err != nil { - return k, fmt.Errorf("the key after %s is not base64", f) - } - sum := sha256.Sum256(blob) - k.Fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:]) - k.Bits = bitsOf(f, blob) - switch { - case f == "ssh-dss": - k.Weak = "DSA: refused by current OpenSSH" - case f == "ssh-rsa" && k.Bits > 0 && k.Bits < 3072: - k.Weak = fmt.Sprintf("RSA of %d bits: below 3072", k.Bits) - } - return k, nil - } - return PublicKey{}, fmt.Errorf("no public key on this line") -} - -// fieldsQuoted splits on spaces outside double quotes, as sshd reads an options field. -func fieldsQuoted(s string) []string { - var out []string - var cur strings.Builder - quoted := false - for _, ch := range s { - switch { - case ch == '"': - quoted = !quoted - cur.WriteRune(ch) - case (ch == ' ' || ch == '\t') && !quoted: - if cur.Len() > 0 { - out = append(out, cur.String()) - cur.Reset() - } - default: - cur.WriteRune(ch) - } - } - if cur.Len() > 0 { - out = append(out, cur.String()) - } - return out -} - -// bitsOf reads a key's size from its blob: the RSA modulus, the curve, or ed25519's fixed 256. -func bitsOf(kind string, blob []byte) int { - strs := [][]byte{} - for len(blob) >= 4 { - n := binary.BigEndian.Uint32(blob) - if int(n) > len(blob)-4 { - break - } - strs = append(strs, blob[4:4+n]) - blob = blob[4+n:] - } - switch { - case strings.Contains(kind, "ed25519"): - return 256 - case kind == "ssh-rsa" && len(strs) >= 3: - return new(big.Int).SetBytes(strs[2]).BitLen() - case kind == "ssh-dss" && len(strs) >= 2: - return new(big.Int).SetBytes(strs[1]).BitLen() - case strings.Contains(kind, "nistp256"): - return 256 - case strings.Contains(kind, "nistp384"): - return 384 - case strings.Contains(kind, "nistp521"): - return 521 - } - return 0 -} diff --git a/go/forgekey/runner.go b/go/forgekey/runner.go index b989ef1..25369d6 100644 --- a/go/forgekey/runner.go +++ b/go/forgekey/runner.go @@ -7,9 +7,7 @@ import ( "fmt" "os" "os/exec" - "strings" "time" - "unicode/utf8" ) // Ran is what a command did: its output, its exit status, and why it never ran to an answer. @@ -38,33 +36,19 @@ func ExecRunner(ctx context.Context, env []string, name string, args ...string) // step's own context ends a command, and a fetch may outlast a tool call's thirty seconds. func ExecRunnerFor(bound time.Duration) Runner { return func(ctx context.Context, env []string, name string, args ...string) Ran { - return execRun(ctx, bound, false, env, name, args...) + return execRun(ctx, bound, env, name, args...) } } -// ExecRunnerClean is ExecRunnerFor whose commands get exactly the env argument as their environment, and nothing of -// this process's (issue 1497; novox/hq ADR 0466 §4: no registry password in a fetch's environment). The caller -// gives PATH and HOME itself. A directory is the command's own business (`git -C`, `go -C`): it is started in the HOME -// it is given, or the machine's temporary directory, never in this process's (stableDir). -func ExecRunnerClean(bound time.Duration) Runner { - return func(ctx context.Context, env []string, name string, args ...string) Ran { - return execRun(ctx, bound, true, env, name, args...) - } -} - -func execRun(ctx context.Context, bound time.Duration, clean bool, env []string, name string, args ...string) Ran { +func execRun(ctx context.Context, bound time.Duration, env []string, name string, args ...string) Ran { if bound > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, bound) defer cancel() } cmd := exec.CommandContext(ctx, name, args...) - if clean { - cmd.Env = append([]string{}, env...) // non-nil, so an empty list is an empty environment - } else { - cmd.Env = append(os.Environ(), env...) - } - cmd.Dir = stableDir(clean, env) + cmd.Env = append(os.Environ(), env...) + cmd.Dir = stableDir() var out, errb bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &errb err := cmd.Run() @@ -88,58 +72,11 @@ func execRun(ctx context.Context, bound time.Duration, clean bool, env []string, // stableDir is the directory a command starts in when it names none (novox/hq issue 734). **Never one that may be // deleted under it**: a module runs in the directory its build was unpacked in, which a new build replaces, and a -// command started there inherits it — `go version` then fails "getwd: no such file or directory" before it reads -// anything. A clean command starts in the HOME it is given, or the temporary directory; any other keeps this -// process's directory while it exists, and starts in the temporary directory once it is gone. "" is this process's. -func stableDir(clean bool, env []string) string { - if !clean { - if _, err := os.Getwd(); err == nil { - return "" - } - return os.TempDir() - } - for i := len(env) - 1; i >= 0; i-- { - if home, ok := strings.CutPrefix(env[i], "HOME="); ok { - if info, err := os.Stat(home); err == nil && info.IsDir() { - return home - } - break - } +// command started there inherits it. It keeps this process's directory ("") while it exists, and starts in the +// temporary directory once it is gone. +func stableDir() string { + if _, err := os.Getwd(); err == nil { + return "" } return os.TempDir() } - -// SaidBound bounds what Said keeps of a command's words, so a reason stays a line of a step's record and of a log. -const SaidBound = 600 - -// Said is what a command said of its failure in its own words, for a reason: every line that says something, trimmed -// and joined with "; ", bounded at SaidBound bytes. A line that is a bare prefix and nothing else (git writes "error:" -// alone before and after the forge's own message, "remote:" or "fatal:" likewise) is left out, so the cause is never -// cut off behind it: the first line alone of `git fetch` from a repository the account cannot read was "error:". -func Said(s string) string { - var keep []string - for _, l := range strings.Split(s, "\n") { - l = strings.TrimSpace(l) - if l == "" || barePrefix(l) { - continue - } - keep = append(keep, l) - } - out := strings.Join(keep, "; ") - if len(out) > SaidBound { - cut := SaidBound - for cut > 0 && !utf8.RuneStart(out[cut]) { - cut-- - } - out = out[:cut] + " …" - } - return out -} - -// barePrefix says whether a line is one word and a colon, such as "error:" or "remote:", with nothing after it. -func barePrefix(l string) bool { - if !strings.HasSuffix(l, ":") { - return false - } - return !strings.ContainsAny(strings.TrimSuffix(l, ":"), " \t:") -} diff --git a/go/forgekey/runner_test.go b/go/forgekey/runner_test.go index 2dc09b4..41a5759 100644 --- a/go/forgekey/runner_test.go +++ b/go/forgekey/runner_test.go @@ -48,26 +48,15 @@ func TestExecRunnerForZeroIsCutByItsContext(t *testing.T) { } } -// 1497/AC4: ExecRunnerClean(0) runs env with only the given words: PATH and HOME, not the process's secret. And issue -// 734: run from a process whose own directory was deleted under it (a module whose build a new one replaced), a clean -// command starts in its HOME, and any other in the temporary directory, rather than failing to find where it is. -func TestExecRunnerCleanGivesTheCommandOnlyTheEnvArgument(t *testing.T) { - t.Setenv("MESH_TEST_SECRET", "x") - r := ExecRunnerClean(0)(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=/h"}, "env") - if r.Status != 0 || !strings.Contains(r.Stdout, "HOME=/h") || !strings.Contains(r.Stdout, "PATH=") || strings.Contains(r.Stdout, "MESH_TEST_SECRET") { - t.Errorf("ran = %+v", r) - } - - home, gone := t.TempDir(), t.TempDir() +// Issue 734: run from a process whose own directory was deleted under it (a module whose build a new one replaced), a +// command starts in the temporary directory rather than failing to find where it is. +func TestExecRunnerForStartsInTheTemporaryDirectoryWhenItsOwnIsGone(t *testing.T) { + gone := t.TempDir() t.Chdir(gone) if err := os.Remove(gone); err != nil { t.Fatal(err) } - r = ExecRunnerClean(0)(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home}, "pwd") - if r.Status != 0 || strings.TrimSpace(r.Stdout) != home { - t.Errorf("a clean command run from a deleted directory = %+v, want it started in its HOME %s", r, home) - } - r = ExecRunnerFor(0)(context.Background(), nil, "pwd") + r := ExecRunnerFor(0)(context.Background(), nil, "pwd") if r.Status != 0 || strings.TrimSpace(r.Stdout) != os.TempDir() { t.Errorf("a command run from a deleted directory = %+v, want it started in %s", r, os.TempDir()) } diff --git a/go/forgekey/said_test.go b/go/forgekey/said_test.go deleted file mode 100644 index c361a97..0000000 --- a/go/forgekey/said_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package forgekey - -import ( - "strings" - "testing" -) - -// git's own words survive as the reason: a bare "error:" or "fatal:" line is never the whole reason (the mirror of -// jschoubben/car-hunter "is not used: git -C: error:", 2026-10-26), every line that says something is kept, and the -// words are bounded. -func TestSaidKeepsTheCauseBehindABarePrefix(t *testing.T) { - forge := "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.\nerror: \nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n" - for _, c := range []struct{ name, in, want string }{ - {"forge refusal", forge, "error: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.; fatal: Could not read from remote repository.; Please make sure you have the correct access rights; and the repository exists."}, - {"one line", "fatal: couldn't find remote ref x\n", "fatal: couldn't find remote ref x"}, - {"bare prefixes only", "error:\nremote:\n", ""}, - {"a colon inside stays", " ENOENT \nfatal: bad: thing\n", "ENOENT; fatal: bad: thing"}, - {"empty", "", ""}, - } { - if got := Said(c.in); got != c.want { - t.Errorf("%s: Said = %q, want %q", c.name, got, c.want) - } - } - long := Said(strings.Repeat("é", SaidBound)) - if !strings.HasSuffix(long, " …") || len(long) > SaidBound+len(" …") || !strings.HasPrefix(long, "é") { - t.Errorf("long: %d bytes, ends %q", len(long), long[len(long)-6:]) - } -} -- 2.54.0 From d0563ed7c1d192efdada9db5221e15c9445b88a0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:40:52 +0100 Subject: [PATCH 117/144] buscompose: VaultSeat, AccountIssuedSubject and ResolvedBucket leave (no importer; the grants part's copy names them), with the untilTheJoin and seat-names entries (S-5 first part, B9f; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/buscompose/accounts.go | 3 --- go/buscompose/state.go | 3 --- go/buscompose/users.go | 3 --- go/packages_list_test.go | 1 - go/testdata/seat-names.txt | 1 - 5 files changed, 11 deletions(-) diff --git a/go/buscompose/accounts.go b/go/buscompose/accounts.go index 38c79a1..499fbe4 100644 --- a/go/buscompose/accounts.go +++ b/go/buscompose/accounts.go @@ -17,9 +17,6 @@ const GrantsAccounts = GrantsModule + "." + AccountsState // AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash. const AccountIssuedEvent = "account-issued" -// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events. -const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent - // ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a // node-scoped seat's verb carries the machine) and the verbs the runtime serves for it. type ClaimedSeat struct { diff --git a/go/buscompose/state.go b/go/buscompose/state.go index 2f090aa..1a84d11 100644 --- a/go/buscompose/state.go +++ b/go/buscompose/state.go @@ -46,6 +46,3 @@ type KeyedRead struct { Read string `json:"read"` Key string `json:"key"` } - -// ResolvedBucket is mesh-config's state of resolved values, keyed `..`. -const ResolvedBucket = "mesh-config_resolved" diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 0eba1c7..473b041 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -100,6 +100,3 @@ type ProcessPrincipal struct { // seat:., or * for every tool on the mesh. Invokes []string } - -// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. -const VaultSeat = "mesh-vault" diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 201e472..a92b07b 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -48,7 +48,6 @@ var untilTheJoin = []string{ "delivery/signers.go: mesh-vault", "provisions/provisions.go: mesh-vault", // Names of whole seats in packages that leave in S-7 (delivery, buscompose's grants-part names), with their packages. - "buscompose/users.go: mesh-vault", "delivery/builder.go: container-image-registry", "delivery/builder.go: oci-artifact-registry", } diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index c20b996..24f112c 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -1,5 +1,4 @@ buscompose/health_grants.go: mesh.seat.mesh-self-check.event. -buscompose/users.go: mesh-vault catalogue/artifact_sweep_seat.go: mesh-artifact-sweep catalogue/artifacts.go: container-image-registry catalogue/artifacts.go: go-package-registry -- 2.54.0 From b8eb1afd141118c19a3a031c219a5ee7bd9711b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:41:53 +0100 Subject: [PATCH 118/144] health: readings.go (disk-load's readings, restic's last good night) leaves, held by disk-load's diskreadings and restic's backupgood (S-7 part, B9f; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/health/condition_test.go | 52 ----------- go/health/readings.go | 176 ------------------------------------ 2 files changed, 228 deletions(-) delete mode 100644 go/health/readings.go diff --git a/go/health/condition_test.go b/go/health/condition_test.go index f6decea..05269d9 100644 --- a/go/health/condition_test.go +++ b/go/health/condition_test.go @@ -7,8 +7,6 @@ import ( "strings" "testing" "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" ) // ADR 0564 clause 3 (issue 1952 S2): the bodies of condition-raised, condition-changed and condition-cleared read the @@ -67,56 +65,6 @@ func TestTheControllersConditionEventsReadUnchanged(t *testing.T) { // take-over key the readers follow. func TestTheHealthLayoutsHoldTheKeepersRules(t *testing.T) { at := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) - // ADR 0571 clause 3: a reading keeps its seven-day peak and is written only when it changes; a partial size is no peak. - var prev *Reading - for i, step := range []struct { - day, size int64 - partial, wrote bool - peak int64 - }{{20, 100, false, true, 100}, {20, 100, false, false, 100}, {21, 300, false, true, 300}, {22, 50, false, true, 300}, - {23, 9000, true, true, 300}, {29, 10, false, true, 50}, {30, 10, false, true, 10}} { - now := time.Date(2026, 10, int(step.day), 1, 0, 0, 0, time.UTC) - size := step.size - m := Measured{SizeBytes: &size, MeasuredAt: &now, Precision: "exact"} - if step.partial { - m.Precision = "partial: stopped" - } - r, wrote := NextReading(prev, "ace", catalogue.PlacedDataItem{Module: "pg", ID: "data", Path: "/srv/pg"}, m, now) - raw, _ := json.Marshal(r) - back, err := DecodeReading(raw) - if wrote != step.wrote || r.Peak == nil || *r.Peak != step.peak || err != nil || back.ID != "data" { - t.Fatalf("reading %d: wrote %v, peak %v, read back %v", i, wrote, r.Peak, err) - } - prev = &r - } - // The first reading sets FirstSeen and every one after keeps it, as it keeps a retirement; an older reading reads as before. - if first := time.Date(2026, 10, 20, 1, 0, 0, 0, time.UTC); !prev.FirstSeen.Equal(first) { - t.Fatalf("first seen %s, not %s", prev.FirstSeen, first) - } - retired := *prev - retired.RetiredAt, retired.RetiredWhy = at, "its module left ace" - if r, _ := NextReading(&retired, "ace", retired.PlacedDataItem, retired.Measured, at.Add(time.Hour)); !r.RetiredAt.Equal(at) || - r.RetiredWhy != "its module left ace" || !r.FirstSeen.Equal(prev.FirstSeen) { - t.Fatalf("a retired reading moved on as %+v", r) - } - if old, err := DecodeReading([]byte(`{"v":1,"node":"ace","module":"pg","item":"data"}`)); err != nil || !old.FirstSeen.IsZero() { - t.Fatalf("a reading of before read as %+v: %v", old, err) - } - if raw, _ := json.Marshal(Reading{V: ReadingVersion}); strings.Contains(string(raw), "first_seen") || strings.Contains(string(raw), "retired") { - t.Fatalf("zero fields were written: %s", raw) - } - if ReadingKey("ace", "pg", "data") != "ace.pg.data" || ReadingsBucket != "disk-load_readings" { - t.Fatal("the readings' key or bucket moved") - } - // And restic's last good night per item, keyed as a reading. - goodRow, _ := json.Marshal(GoodRow{V: GoodVersion, Node: "ace", Module: "pg", Item: "data", CoveredBy: "/srv/pg", LastGood: at}) - if g, err := DecodeGoodRow(goodRow); err != nil || !g.LastGood.Equal(at) || GoodKey("ace", "pg", "data") != "ace.pg.data" || - GoodBucket != "restic_good" || GoodRead != "restic.good" { - t.Fatalf("a last good night read back as %+v: %v", g, err) - } - if _, err := DecodeGoodRow([]byte(`{"v":2}`)); err == nil { - t.Fatal("a last good night of another version was read") - } good := Finding{Key: "provider.keycloak.ace.mesh-mail.failing", Kind: "provider-failing", Ref: "failing", Subject: Subject{Scope: ScopeProvider, ID: "keycloak.ace.mesh-mail", Machine: "ace"}, Severity: Warning, Summary: "keycloak on ace fails mesh-mail", Source: "S8", Observer: WatchmanModule, At: at} diff --git a/go/health/readings.go b/go/health/readings.go deleted file mode 100644 index e02c960..0000000 --- a/go/health/readings.go +++ /dev/null @@ -1,176 +0,0 @@ -package health - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// **Disk usage readings and peaks per data item** (novox/hq ADR 0571 clause 3, ADR 0233 §21, §27, §34): disk-load, on every -// node, measures each data item the modules of its node declare (catalogue.PlacedDataItem, from compose/frombus.DataItems) -// and keeps the newest reading and the item's largest size in seven days as its own state `readings`, one key -// `..`, written only when it changes. The node-backup holder, mounts, nfs-server and the self-check's -// probes read it there; the controller keeps none of it. Moved here from mesh-catalog modules/disk-load (b4c237d2). -const ( - DiskLoadModule = "disk-load" - ReadingsState = "readings" - ReadingsBucket = DiskLoadModule + "_" + ReadingsState - ReadingsRead = DiskLoadModule + "." + ReadingsState -) - -// ReadingVersion is the version of a reading's layout. -const ReadingVersion = 1 - -// PeakWindow is how far back the peak looks (ADR 0233 §34). -const PeakWindow = 7 * 24 * time.Hour - -// Redundancy is the redundant storage an item is on, as read: zfs, md or btrfs, its pool, array or filesystem, and its -// health (nil when it could not be read). -type Redundancy struct { - Kind string `json:"kind"` - Where string `json:"where"` - Healthy *bool `json:"healthy"` - Said string `json:"said"` -} - -// Measured is what one measurement found. Precision says what the size is: "exact", "dataset : its whole size", -// "partial: ..." (a lower bound, never compared), or "no size: ...". -type Measured struct { - SizeBytes *int64 `json:"size_bytes,omitempty"` - LastWrite *time.Time `json:"last_write,omitempty"` - MeasuredAt *time.Time `json:"measured_at,omitempty"` - Precision string `json:"precision,omitempty"` - Error string `json:"error,omitempty"` - Redundancy *Redundancy `json:"redundancy,omitempty"` -} - -// Partial says the size is only a lower bound, which a peak is never made of. -func (m Measured) Partial() bool { return strings.HasPrefix(m.Precision, "partial") } - -// DaySize is the largest size measured on one UTC day. -type DaySize struct { - Day string `json:"day"` - Bytes int64 `json:"bytes"` -} - -// Reading is the value of one key: the item as placed, what was measured, and the largest size in the seven days up to -// it (Peak, measured on PeakAt), read from the day maxima Days. -type Reading struct { - V int `json:"v"` - Node string `json:"node"` - catalogue.PlacedDataItem - Measured - Peak *int64 `json:"peak_bytes,omitempty"` - PeakAt string `json:"peak_day,omitempty"` - Days []DaySize `json:"days,omitempty"` - // FirstSeen is when the item was first measured on this node, kept by every reading after; RetiredAt and RetiredWhy - // say its module left the node, written by whoever retires it and carried on unchanged. Each is left out while zero, - // so a reading written before reads as it did. - FirstSeen time.Time `json:"first_seen,omitzero"` - RetiredAt time.Time `json:"retired_at,omitzero"` - RetiredWhy string `json:"retired_why,omitempty"` -} - -// ReadingKey is the key of an item on a node: `..`. -func ReadingKey(node, module, item string) string { return node + "." + module + "." + item } - -// DecodeReading reads a key's value; it refuses another version. -func DecodeReading(raw []byte) (Reading, error) { - var r Reading - if err := json.Unmarshal(raw, &r); err != nil { - return Reading{}, fmt.Errorf("health: a reading is not readable: %w", err) - } - if r.V != ReadingVersion { - return Reading{}, fmt.Errorf("health: a reading of version %d, and this reads %d", r.V, ReadingVersion) - } - return r, nil -} - -// NextReading is the reading after a measurement, and whether it differs from the one before in anything but the time it -// was taken: a reading that did not change is not written. A measurement with no size (an error, a shallow item) carries -// the last size's days on, so the peak outlives one failed measurement. -func NextReading(prev *Reading, node string, it catalogue.PlacedDataItem, m Measured, now time.Time) (Reading, bool) { - r := Reading{V: ReadingVersion, Node: node, PlacedDataItem: it, Measured: m, FirstSeen: now.UTC()} - var days []DaySize - if prev != nil { - days = prev.Days - r.RetiredAt, r.RetiredWhy = prev.RetiredAt, prev.RetiredWhy - if !prev.FirstSeen.IsZero() { - r.FirstSeen = prev.FirstSeen - } - } - today := now.UTC().Format("2006-01-02") - cut := now.UTC().Add(-PeakWindow).Format("2006-01-02") - var kept []DaySize - todays := int64(-1) - for _, d := range days { - switch { - case d.Day == today: - todays = d.Bytes - case d.Day >= cut: - kept = append(kept, d) - } - } - if m.SizeBytes != nil && !m.Partial() && *m.SizeBytes > todays { - todays = *m.SizeBytes - } - if todays >= 0 { - kept = append(kept, DaySize{Day: today, Bytes: todays}) - } - r.Days = kept - for _, d := range kept { - if r.Peak == nil || d.Bytes >= *r.Peak { - b := d.Bytes - r.Peak, r.PeakAt = &b, d.Day - } - } - if prev == nil { - return r, true - } - a, b := *prev, r - a.MeasuredAt, b.MeasuredAt = nil, nil - return r, !reflect.DeepEqual(a, b) -} - -// **Each item's last good night** (novox/hq ADR 0571 clause 3, ADR 0564 §6; the lead, 2026-10-29): the node-backup holder -// restic keeps, from its tools bundle through the runtime on the night's file event (its night's process runs as the -// operator's account and holds no bus account, ADR 0436 §1-2), its own state `good`, one key `..` for every item a module -// declares that a backup path covers, written when it changes: the item, the path whose snapshot keeps it and the newest -// good night of its module (zero before the first). The self-check's probe of a stale backup reads it there. Moved here -// from mesh-catalog modules/restic (e5ef3bed). -const ( - ResticModule = "restic" - GoodState = "good" - GoodBucket = ResticModule + "_" + GoodState - GoodRead = ResticModule + "." + GoodState - GoodVersion = 1 -) - -// GoodRow is one key of restic's `good`. -type GoodRow struct { - V int `json:"v"` - Node string `json:"node"` - Module string `json:"module"` - Item string `json:"item"` - CoveredBy string `json:"covered_by"` - LastGood time.Time `json:"last_good,omitzero"` -} - -// GoodKey is a row's key: `..`, as a reading's. -func GoodKey(node, module, item string) string { return ReadingKey(node, module, item) } - -// DecodeGoodRow reads a row; it refuses another version. -func DecodeGoodRow(raw []byte) (GoodRow, error) { - var g GoodRow - if err := json.Unmarshal(raw, &g); err != nil { - return GoodRow{}, fmt.Errorf("health: a last good night is not readable: %w", err) - } - if g.V != GoodVersion { - return GoodRow{}, fmt.Errorf("health: a last good night of version %d, and this reads %d", g.V, GoodVersion) - } - return g, nil -} -- 2.54.0 From 59f86598371ddad6b76a930d51bcf24f5e2e516c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:42:10 +0100 Subject: [PATCH 119/144] Package list test: composer, parts and provider may not come back (S-8 part, B9f; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/packages_list_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/packages_list_test.go b/go/packages_list_test.go index a92b07b..0ede3ba 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -39,7 +39,7 @@ var clientSeats = []string{"operator-channel", "mesh-vault", "node-engine", "npm var moduleWord = regexp.MustCompile(`\bmesh-(?:delivery|grants|vault|host|config|provisioner|broker|knowledge|enrolment)\b[-a-z]*|\b(?:indexer|assigner|teller|watchman|checker|builder)(?:-views|_read_)`) // notInSDK are the packages that moved to a seat's client or contract (ADR 0627 §1, §4) and may not come back. -var notInSDK = []string{"asks", "asker", "publish"} +var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below // would refuse; it only shrinks. The vault module's name in delivery and provisions stays past this milestone's pin -- 2.54.0 From 1815fd11b9634396855d4cb58537dea35b50c455 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 17:04:01 +0100 Subject: [PATCH 120/144] =?UTF-8?q?membership:=20CredentialIn=20and=20Bare?= =?UTF-8?q?Address=20go,=20no=20caller=20in=20any=20tree=20(ADR=200635=20?= =?UTF-8?q?=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/membership/membership.go | 30 ------------------------------ go/membership/membership_test.go | 20 -------------------- 2 files changed, 50 deletions(-) diff --git a/go/membership/membership.go b/go/membership/membership.go index 4f957cb..38e225c 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -6,7 +6,6 @@ package membership import ( "encoding/json" - "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -172,32 +171,3 @@ type WaitIssued struct { Event string `json:"event"` Subject string `json:"subject"` } - -// CredentialIn reads the credential out of a bus address, `[scheme://]user:password@host:port`, splitting on -// the last at-sign so a password containing one stays whole, and returns the address without it. -func CredentialIn(address string) (user, password, bare string) { - at := strings.LastIndex(address, "@") - if at < 0 { - return "", "", address - } - scheme := "" - rest := address[:at] - if i := strings.Index(rest, "://"); i >= 0 { - scheme, rest = rest[:i+3], rest[i+3:] - } - user, password, _ = strings.Cut(rest, ":") - return user, password, scheme + address[at+1:] -} - -// BareAddress is a bus address with any credential stripped, for something that only needs to know -// whether a server is answering there. -func BareAddress(address string) string { - _, _, bare := CredentialIn(address) - if bare == "" { - return address - } - if strings.Contains(bare, "://") { - return bare - } - return "nats://" + bare -} diff --git a/go/membership/membership_test.go b/go/membership/membership_test.go index e80d346..555f19c 100644 --- a/go/membership/membership_test.go +++ b/go/membership/membership_test.go @@ -8,26 +8,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// A credential is read out of a bus address, and the bare address carries no credential and a scheme. -func TestACredentialIsReadOutOfABusAddress(t *testing.T) { - for _, c := range []struct{ in, user, password, bare, schemed string }{ - {"nats://controller:secret@127.0.0.1:4222", "controller", "secret", "nats://127.0.0.1:4222", "nats://127.0.0.1:4222"}, - {"controller:secret@127.0.0.1:4222", "controller", "secret", "127.0.0.1:4222", "nats://127.0.0.1:4222"}, - {"nats://127.0.0.1:4222", "", "", "nats://127.0.0.1:4222", "nats://127.0.0.1:4222"}, - {"127.0.0.1:4222", "", "", "127.0.0.1:4222", "nats://127.0.0.1:4222"}, - // A password containing an at-sign: split on the last one. - {"nats://controller:a@b@127.0.0.1:4222", "controller", "a@b", "nats://127.0.0.1:4222", "nats://127.0.0.1:4222"}, - } { - user, password, bare := CredentialIn(c.in) - if user != c.user || password != c.password || bare != c.bare { - t.Errorf("%q read as %q/%q at %q; wanted %q/%q at %q", c.in, user, password, bare, c.user, c.password, c.bare) - } - if got := BareAddress(c.in); got != c.schemed { - t.Errorf("BareAddress(%q) = %q, want %q", c.in, got, c.schemed) - } - } -} - func TestAMembershipIsPublishedAndReadAtTheSubjectDerivedFromTheTwoNames(t *testing.T) { if got := Subject("ace", "route-proxy"); got != "mesh.assignment.ace.route-proxy" { t.Errorf("subject: %q", got) -- 2.54.0 From 15b65445c81db44d4142c56adde1303fef0b3aec Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 17:04:28 +0100 Subject: [PATCH 121/144] =?UTF-8?q?buscompose:=20BusRemovalGrants,=20Contr?= =?UTF-8?q?ollerLeftover*=20and=20WatchmanHears=20go,=20the=20grants=20par?= =?UTF-8?q?t's=20busgrants=20holds=20them=20(ADR=200634=20=C2=A75;=20issue?= =?UTF-8?q?=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/buscompose/health_grants.go | 102 --------------------------------- go/testdata/seat-names.txt | 1 - 2 files changed, 103 deletions(-) delete mode 100644 go/buscompose/health_grants.go diff --git a/go/buscompose/health_grants.go b/go/buscompose/health_grants.go deleted file mode 100644 index cf66b51..0000000 --- a/go/buscompose/health_grants.go +++ /dev/null @@ -1,102 +0,0 @@ -package buscompose - -import ( - "fmt" - "slices" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/health" -) - -// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module -// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before, -// which its manifest no longer consumes and no part composes; each `.`. -var ControllerLeftoverConsumers = []string{ - "EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog", - "CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName, -} - -// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller -// made for the node's engine (`EVENTS._mesh-host`, of restic's backed-up): the engine hears that event without a -// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126). -func ControllerLeftoverConsumersOn(nodes []string) []string { - out := slices.Clone(ControllerLeftoverConsumers) - for _, n := range nodes { - if safeSubject.MatchString(n) { - out = append(out, "EVENTS."+n+"_"+EngineModule) - } - } - return out -} - -// endedController is the name the ended controller had as a module: its buckets are named for it (ADR 0590 §6). -const endedController = "mesh-controller" - -// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_`). -var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed", - "condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts", - "lease", "predefined", "rollouts", "states-announced"} - -// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets' -// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2). -func ControllerLeftoverStreams(nodes []string) []string { - out := []string{"CONTROL", AssignmentsStream} - for _, b := range ControllerLeftoverBuckets { - out = append(out, "KV_"+BucketName(endedController, b)) - } - for _, n := range nodes { - if safeSubject.MatchString(n) { - out = append(out, "OBJ_"+DeclarationFilesBucket+n) - } - } - return out -} - -// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause -// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal -// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and -// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the -// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a -// manifest (novox/hq ADR 0578 §1). -func BusRemovalGrants(removes, nodes []string) ([]string, error) { - var out []string - for _, r := range removes { - switch r { - case catalogue.RemovesEngineActs: - out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>") - case catalogue.RemovesControllerLeftovers: - for _, c := range ControllerLeftoverConsumersOn(nodes) { - out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c) - } - for _, s := range ControllerLeftoverStreams(nodes) { - out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s) - } - default: - return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r, - strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", ")) - } - } - return out, nil -} - -// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and -// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool -// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed -// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit -// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among -// them (the lead, 2026-10-29). -func WatchmanHears() []string { - return []string{ - "mesh.mod.power.event.*", - "mesh.mod.*.event.provisioner.failing", - "mesh.mod.*.event.provisioner.recovered", - "mesh.control.*.tools-alive", - "mesh.seat.mesh-self-check.event." + health.EventChecked, - "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", - "mesh.mod.nats.event.bus.slow-consumer", - "mesh.mod.nats.event.bus.connection-closed", - ProvAppliedSubjects, - deadLetterPrefix + ">", - } -} diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 24f112c..e90d10a 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -1,4 +1,3 @@ -buscompose/health_grants.go: mesh.seat.mesh-self-check.event. catalogue/artifact_sweep_seat.go: mesh-artifact-sweep catalogue/artifacts.go: container-image-registry catalogue/artifacts.go: go-package-registry -- 2.54.0 From de8c881cb690fc83a428a85a2c95d2281abb2db3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 17:05:02 +0100 Subject: [PATCH 122/144] =?UTF-8?q?Delete=20go/handacts:=20no=20importer?= =?UTF-8?q?=20is=20left=20in=20any=20tree;=20the=20list=20of=20packages=20?= =?UTF-8?q?and=20notInSDK=20follow=20(S-7,=20ADR=200634=20=C2=A75;=20issue?= =?UTF-8?q?=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/handacts/handacts.go | 133 ----------------------------------- go/handacts/handacts_test.go | 71 ------------------- go/packages_list_test.go | 4 +- go/testdata/seat-names.txt | 1 - 4 files changed, 2 insertions(+), 207 deletions(-) delete mode 100644 go/handacts/handacts.go delete mode 100644 go/handacts/handacts_test.go diff --git a/go/handacts/handacts.go b/go/handacts/handacts.go deleted file mode 100644 index 1288986..0000000 --- a/go/handacts/handacts.go +++ /dev/null @@ -1,133 +0,0 @@ -// Package handacts is the hand-act log's contract (novox/hq ADR 0565 clauses 10 and 11, issue 1953): the layout of an act, -// moved unchanged from the controller's (mesh-controller internal/link HandAct) so one reader reads both logs alike; the -// names of the seat mesh-hand-acts and of what its holder keeps; and the recorder every module records through, which -// asks the seat alone: the controller's hand-act and warranted are gone (issue 1953 L5b) and the bus refuses them. -package handacts - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - "time" -) - -// The seat, its holder and what it keeps (ADR 0565 clause 10). -const ( - Seat = "mesh-hand-acts" - Module = "mesh-hand-acts" - // ActsLog is the log of every act, kept 400 days; WarrantsState one key `.` per warrant, made once. - ActsLog = "acts" - WarrantsState = "warrants" - // EventRecorded is said on the seat for every act, with the act as its body. - EventRecorded = "act-recorded" -) - -// KeptFor is how long an act is kept. -const KeptFor = 400 * 24 * time.Hour - -// The verbs an act is recorded under, as the controller's log names them. -const ( - VerbRecord = "hand-act record" - VerbDrill = "hand-act drill" - VerbWarrant = "warrant" - // CauseDrill is a drill's cause, never counted as a repair (S15); CauseOperatorAnswer an act the operator chose. - CauseDrill = "drill" - CauseOperatorAnswer = "operator-answer" - // KindRecordedBuilds is a push that only moved recorded builds: no repair (ADR 0242). - KindRecordedBuilds = "recorded-builds" -) - -// Act is one entry of the log, the controller's HandAct unchanged. -type Act struct { - ID string `json:"id"` - At time.Time `json:"at"` - // By is who: the caller the runtime proved. - By string `json:"by"` - // Verb and Args are the act as given. - Verb string `json:"verb"` - Args []string `json:"arguments,omitempty"` - Why string `json:"why"` - // Cause is the condition kind, or a word the person gives; the verb's own name when neither. - Cause string `json:"cause"` - Condition string `json:"condition,omitempty"` - // Kind is what the act was read to be from what it did, never a word a person gives. - Kind string `json:"kind,omitempty"` - Carried []string `json:"carried,omitempty"` - // Via, Ask, Proofs and RequestedBy are an act the operator chose on a warrant. - Via string `json:"via,omitempty"` - Ask string `json:"ask,omitempty"` - Proofs []string `json:"proofs,omitempty"` - RequestedBy string `json:"requested-by,omitempty"` - // Outcome is what came of an act recorded after it was done. - Outcome string `json:"outcome,omitempty"` - // Log is which log holds it, set when it is read (LogSeat); never written. - Log string `json:"log,omitempty"` -} - -// LogSeat is the log an act is held in: the seat's (the ended controller's log is read from its backup, ADR 0590). -const LogSeat = Seat - -// Repair says the act counts toward a cause repeated (S15): not a drill, not an operator's answer, not recorded builds. -func (a Act) Repair() bool { - return a.Cause != CauseDrill && a.Verb != VerbDrill && a.Cause != CauseOperatorAnswer && a.Kind != KindRecordedBuilds -} - -// Request is one act a recorder records: what, why, its cause, the condition it addresses, and its verb in the log (a -// repair when empty). -type Request struct { - What string `json:"what"` - Why string `json:"why"` - Cause string `json:"cause,omitempty"` - Condition string `json:"condition,omitempty"` - Verb string `json:"verb,omitempty"` -} - -// Taken is which log took an act, and what its holder answered. -type Taken struct { - Log string `json:"log"` - Answer json.RawMessage `json:"answer,omitempty"` -} - -// Recorder records acts for a module (ADR 0565 clause 11) through the seat mesh-hand-acts alone. An act the seat does -// not take is an error that says nothing was recorded, never a quiet success. -type Recorder struct { - // Ask calls a seat verb by its address (`seat:.`): stdio.Ask through the runtime. - Ask func(key string, body any) (json.RawMessage, error) -} - -// NoResponder says err is the bus finding nobody to answer: the seat has no holder now. -func NoResponder(err error) bool { - return err != nil && strings.Contains(err.Error(), "no responders") -} - -// Record records one act. -func (r Recorder) Record(q Request) (Taken, error) { - if strings.TrimSpace(q.What) == "" || strings.TrimSpace(q.Why) == "" { - return Taken{}, errors.New("a hand act says what was done and why: nothing was recorded") - } - body := map[string]any{"what": q.What, "why": q.Why} - for k, v := range map[string]string{"cause": q.Cause, "condition": q.Condition, "verb": q.Verb} { - if v != "" { - body[k] = v - } - } - return r.record("hand-act", body) -} - -// RecordWarrant records what the asking module did on the operator's warrant for its ask. -func (r Recorder) RecordWarrant(asker, ask string) (Taken, error) { - body := map[string]any{"asker": asker, "ask": ask} - return r.record("warranted", body) -} - -func (r Recorder) record(verb string, body any) (Taken, error) { - answer, err := r.Ask("seat:"+Seat+"."+verb, body) - if err == nil { - return Taken{Log: LogSeat, Answer: answer}, nil - } - if NoResponder(err) { - return Taken{}, fmt.Errorf("%s has no holder that answers: nothing was recorded (%w)", Seat, err) - } - return Taken{}, fmt.Errorf("%s.%s refused, nothing was recorded: %w", Seat, verb, err) -} diff --git a/go/handacts/handacts_test.go b/go/handacts/handacts_test.go deleted file mode 100644 index 11b7cea..0000000 --- a/go/handacts/handacts_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package handacts - -import ( - "encoding/json" - "errors" - "reflect" - "strings" - "testing" -) - -// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4, L5b): an act reads the controller's entries unchanged, and a -// recorder records through the seat alone, the controller's verbs being gone, and says loudly when nothing was recorded. -func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) { - // The shape of mesh-controller internal/link HandAct, as its bucket keeps an entry (handacts.go at 0fbb5ef3). - raw := `{"id":"act-1","at":"2026-10-28T09:00:00Z","by":"person.jochen","verb":"hand-act record","why":"a stuck consumer",` + - `"cause":"consumer-lost","condition":"bus.EVENTS.x.consumer-lost","via":"telegram","ask":"a-1","proofs":["P1"],` + - `"requested-by":"bus.EVENTS.x.consumer-lost","outcome":"done"}` - var a Act - if err := json.Unmarshal([]byte(raw), &a); err != nil { - t.Fatal(err) - } - again, _ := json.Marshal(a) - var want, got map[string]any - _ = json.Unmarshal([]byte(raw), &want) - _ = json.Unmarshal(again, &got) - if !reflect.DeepEqual(want, got) || !a.Repair() || (Act{Cause: CauseDrill}).Repair() || (Act{Verb: VerbDrill}).Repair() { - t.Errorf("the controller's entry reads back as %s", again) - } - noResponders := errors.New("nats: no responders available for request") - for _, c := range []struct { - name string - seat error - ctrl error - log string - refused string - asked string - warrants bool - }{ - {name: "the seat answers", log: LogSeat, asked: "seat:mesh-hand-acts.hand-act"}, - {name: "no holder: nothing recorded, the controller never asked", seat: noResponders, refused: "nothing was recorded", - asked: "seat:mesh-hand-acts.hand-act"}, - {name: "the seat refuses", seat: errors.New("why is required"), refused: "refused, nothing was recorded", - asked: "seat:mesh-hand-acts.hand-act"}, - {name: "a warrant without a holder", seat: noResponders, warrants: true, refused: "nothing was recorded", - asked: "seat:mesh-hand-acts.warranted"}, - {name: "a warrant recorded", warrants: true, log: LogSeat, asked: "seat:mesh-hand-acts.warranted"}, - } { - var asked []string - r := Recorder{Ask: func(key string, body any) (json.RawMessage, error) { - asked = append(asked, key) - if strings.HasPrefix(key, "seat:mesh-hand-acts.") { - return json.RawMessage(`{}`), c.seat - } - return json.RawMessage(`{}`), c.ctrl - }} - var taken Taken - var err error - if c.warrants { - taken, err = r.RecordWarrant("mesh-config", "ask-1") - } else { - taken, err = r.Record(Request{What: "silenced", Why: "known", Cause: "silent", Verb: "conditions silence"}) - } - if (c.refused != "") != (err != nil) || (err != nil && !strings.Contains(err.Error(), c.refused)) || taken.Log != c.log || - strings.Join(asked, ",") != c.asked { - t.Errorf("%s: took %q, %v, asked %v", c.name, taken.Log, err, asked) - } - } - if _, err := (Recorder{}).Record(Request{What: "x"}); err == nil { - t.Error("an act with no why was recorded") - } -} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 0ede3ba..3858443 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -24,7 +24,7 @@ import ( // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", - "declaration", "delivery", "forgekey", "handacts", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", + "declaration", "delivery", "forgekey", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", "objectlink", "outward", "overlay", "pernode", "process", "provisions", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } @@ -39,7 +39,7 @@ var clientSeats = []string{"operator-channel", "mesh-vault", "node-engine", "npm var moduleWord = regexp.MustCompile(`\bmesh-(?:delivery|grants|vault|host|config|provisioner|broker|knowledge|enrolment)\b[-a-z]*|\b(?:indexer|assigner|teller|watchman|checker|builder)(?:-views|_read_)`) // notInSDK are the packages that moved to a seat's client or contract (ADR 0627 §1, §4) and may not come back. -var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider"} +var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider", "handacts"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below // would refuse; it only shrinks. The vault module's name in delivery and provisions stays past this milestone's pin diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index e90d10a..3a67be4 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -37,7 +37,6 @@ delivery/placement.go: mesh-self-check delivery/provided.go: the-private-network delivery/signers.go: mesh-vault delivery/stepasks.go: node-build-node -handacts/handacts.go: mesh-hand-acts health/findings.go: mesh-conditions health/findings.go: mesh-healers health/findings.go: mesh-self-check -- 2.54.0 From 8b6da23defa790c4b51b3c20af7d18d7ac7f4ab1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 17:05:03 +0100 Subject: [PATCH 123/144] =?UTF-8?q?Delete=20go/channel:=20no=20importer=20?= =?UTF-8?q?is=20left=20in=20any=20tree;=20the=20list=20of=20packages=20and?= =?UTF-8?q?=20notInSDK=20follow=20(S-7,=20ADR=200634=20=C2=A75;=20issue=20?= =?UTF-8?q?1966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/channel/channel.go | 141 ------------------------------------- go/channel/channel_test.go | 32 --------- go/packages_list_test.go | 4 +- 3 files changed, 2 insertions(+), 175 deletions(-) delete mode 100644 go/channel/channel.go delete mode 100644 go/channel/channel_test.go diff --git a/go/channel/channel.go b/go/channel/channel.go deleted file mode 100644 index a9b1d54..0000000 --- a/go/channel/channel.go +++ /dev/null @@ -1,141 +0,0 @@ -// Package channel holds the shapes of the two kinded benches a channel holds, `channel` (to show and say) and `intake` -// (to say what was chosen, and to ask a proof), and the subjects they travel on (novox/hq ADR 0259 §3). The router -// sends a channel a Message, and a channel says a Choice, a LinkAsked, a Failed or its Standing, or asks a Code's proof. -// Those benches are not defined whole, so their value layouts stay in mesh-sdk (novox/hq ADR 0625 §9, ADR 0627 §4); -// the operator-channel seat's own ask and warrant are its contract's and its client's, in mesh-contracts. -package channel - -import "time" - -// The benches (novox/hq ADR 0259 §3). -const ( - // ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind. - ChannelSeat = "channel" - // IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry - // the kind. - IntakeSeat = "intake" -) - -// The work a channel takes, on ChannelSubject. -const ( - Show = "show" // a message offering answers - Edit = "edit" // a message shown before, replaced - Send = "send" // a message offering nothing -) - -// ChannelSubject is where the router sends a channel of a kind its work. -func ChannelSubject(verb, kind string) string { - return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind -} - -// What a channel says, on IntakeSubject. -const ( - Chosen = "choice" // a button tapped - Link = "link" // somebody asked to be linked as the operator -) - -// IntakeSubject is where a channel of a kind says what arrived. -func IntakeSubject(what, kind string) string { - return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind -} - -// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept. -const CodeProof = "code" - -// ProofSubject is where a channel of a kind asks a proof. -func ProofSubject(verb, kind string) string { - return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind -} - -// Button is one answer a channel offers: its label and the router's one-time ticket for it. -type Button struct { - Label string `json:"label"` - Ticket string `json:"ticket"` -} - -// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said -// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps -// which of its own messages that is. The words are the router's, shown as given. -type Message struct { - Handle string `json:"handle"` - Title string `json:"title"` - Body string `json:"body"` - Buttons []Button `json:"buttons,omitempty"` - // Note is the message's secondary words, after its body and its buttons: who asked and when, what each - // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart - // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's - // length cuts the body first and the note only when the title and the note alone do not fit, saying each - // cut (novox/hq ADR 0347 §5). - Note string `json:"note,omitempty"` - Urgent bool `json:"urgent,omitempty"` - Silent bool `json:"silent,omitempty"` - // Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made. - Reply string `json:"reply,omitempty"` - // To is the account linked as the operator on this kind, for a channel that verifies its sender: where - // the channel sends what is not a reply. The router's word, from its list; empty when none is linked. - To string `json:"to,omitempty"` - // Secret says the words carry something shown once (a link's code): the channel shows it and keeps no - // copy of it — no state, no history of its own. - Secret bool `json:"secret,omitempty"` -} - -// Sender is who a channel's service says sent something: the account, the name it shows, and whether the -// service authenticated it. The router alone judges whether that is the operator. -type Sender struct { - Identity string `json:"identity"` - Display string `json:"display,omitempty"` - Authenticated bool `json:"authenticated"` -} - -// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help. -type Failed struct { - Handle string `json:"handle"` - Why string `json:"why"` - Permanent bool `json:"permanent,omitempty"` - At time.Time `json:"at"` -} - -// Standing is what a channel says of itself, at least every five minutes and whenever it changes: -// whether it can send now, why not, and whether its edits notify nobody. -type Standing struct { - Ready bool `json:"ready"` - Why string `json:"why,omitempty"` - EditsSilently bool `json:"edits-silently,omitempty"` - At time.Time `json:"at"` -} - -// What a channel says of its own delivery, on IntakeSubject. -const ( - FailedWhat = "failed" - StandingWhat = "standing" -) - -// Choice is a button chosen on a channel. -type Choice struct { - // ID is the channel's own for this arrival, unique, so the router acts on it once. - ID string `json:"id"` - Ticket string `json:"ticket"` - Handle string `json:"handle,omitempty"` - Sender Sender `json:"sender"` - At time.Time `json:"at"` -} - -// LinkAsked is somebody on a channel asking to be linked as the operator. -type LinkAsked struct { - ID string `json:"id"` - Sender Sender `json:"sender"` - At time.Time `json:"at"` -} - -// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept. -type Code struct { - Sender Sender `json:"sender"` - Code string `json:"code"` - Purpose string `json:"purpose"` -} - -// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person. -type ProofAnswer struct { - Accepted bool `json:"accepted"` - Words string `json:"words"` -} diff --git a/go/channel/channel_test.go b/go/channel/channel_test.go deleted file mode 100644 index bdcb31d..0000000 --- a/go/channel/channel_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package channel - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestTheSubjectsNameTheirPublisher(t *testing.T) { - for got, want := range map[string]string{ - ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram", - IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop", - ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram", - } { - if got != want { - t.Errorf("%s, want %s", got, want) - } - } -} - -// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older -// than it reads every message it read before. -func TestAMessageCarriesItsNoteApart(t *testing.T) { - raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."}) - if !strings.Contains(string(raw), `"note":"Asked by x."`) { - t.Fatalf("the note is not encoded: %s", raw) - } - raw, _ = json.Marshal(Message{Handle: "h", Title: "t"}) - if strings.Contains(string(raw), "note") { - t.Fatalf("an empty note is encoded: %s", raw) - } -} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 3858443..002ad7c 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -23,7 +23,7 @@ import ( // Logic only some modules need is its owner module's package. A package added here is a decision to say in the pull // request: does every module share it, or is it a shape more than one part reads? var meshSDKPackages = []string{ - ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "channel", + ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "declaration", "delivery", "forgekey", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", "objectlink", "outward", "overlay", "pernode", "process", "provisions", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", @@ -39,7 +39,7 @@ var clientSeats = []string{"operator-channel", "mesh-vault", "node-engine", "npm var moduleWord = regexp.MustCompile(`\bmesh-(?:delivery|grants|vault|host|config|provisioner|broker|knowledge|enrolment)\b[-a-z]*|\b(?:indexer|assigner|teller|watchman|checker|builder)(?:-views|_read_)`) // notInSDK are the packages that moved to a seat's client or contract (ADR 0627 §1, §4) and may not come back. -var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider", "handacts"} +var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider", "handacts", "channel"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below // would refuse; it only shrinks. The vault module's name in delivery and provisions stays past this milestone's pin -- 2.54.0 From 39ac1917de249f4f9ae89413e91a843c5891c1c8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 17:05:04 +0100 Subject: [PATCH 124/144] =?UTF-8?q?Delete=20go/provisions:=20no=20importer?= =?UTF-8?q?=20is=20left=20in=20any=20tree;=20the=20list=20of=20packages=20?= =?UTF-8?q?and=20notInSDK=20follow=20(S-7,=20ADR=200634=20=C2=A75;=20issue?= =?UTF-8?q?=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/packages_list_test.go | 7 +- go/provisions/provisions.go | 310 ------------------------------- go/provisions/provisions_test.go | 121 ------------ go/testdata/seat-names.txt | 2 - 4 files changed, 3 insertions(+), 437 deletions(-) delete mode 100644 go/provisions/provisions.go delete mode 100644 go/provisions/provisions_test.go diff --git a/go/packages_list_test.go b/go/packages_list_test.go index 002ad7c..187444d 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -25,7 +25,7 @@ import ( var meshSDKPackages = []string{ ".", "announce", "announcetest", "buildstep", "buscalls", "buscompose", "busstate", "busretry", "calladdress", "catalogue", "declaration", "delivery", "forgekey", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", - "objectlink", "outward", "overlay", "pernode", "process", "provisions", + "objectlink", "outward", "overlay", "pernode", "process", "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "testbus", "views", "views/viewstest", } @@ -39,14 +39,13 @@ var clientSeats = []string{"operator-channel", "mesh-vault", "node-engine", "npm var moduleWord = regexp.MustCompile(`\bmesh-(?:delivery|grants|vault|host|config|provisioner|broker|knowledge|enrolment)\b[-a-z]*|\b(?:indexer|assigner|teller|watchman|checker|builder)(?:-views|_read_)`) // notInSDK are the packages that moved to a seat's client or contract (ADR 0627 §1, §4) and may not come back. -var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider", "handacts", "channel"} +var notInSDK = []string{"asks", "asker", "publish", "composer", "parts", "provider", "handacts", "channel", "provisions"} // untilTheJoin is what ADR 0627 still moves at the group's join, each `: `, naming what the checks below -// would refuse; it only shrinks. The vault module's name in delivery and provisions stays past this milestone's pin +// would refuse; it only shrinks. The vault module's name in delivery stays past this milestone's pin // (issue 1966 note 28) and goes in the next milestone, when the vault's answers are its contract's. var untilTheJoin = []string{ "delivery/signers.go: mesh-vault", - "provisions/provisions.go: mesh-vault", // Names of whole seats in packages that leave in S-7 (delivery, buscompose's grants-part names), with their packages. "delivery/builder.go: container-image-registry", "delivery/builder.go: oci-artifact-registry", diff --git a/go/provisions/provisions.go b/go/provisions/provisions.go deleted file mode 100644 index 3889dc3..0000000 --- a/go/provisions/provisions.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package provisions is the contract of the seat mesh-provisioner's records (novox/hq ADR 0570 clauses 1, 2 and 5; issue -// 1954): every provision one key of the state `provisions`, holding a reference and never a value, and every handed -// module's provisioning plan one key of the state `plans`. The module mesh-provisioner alone writes both; the vault, -// each node's provisioner client and every provider read them through `reads`. -package provisions - -import ( - "bytes" - "encoding/json" - "fmt" - "regexp" - "sort" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The seat, its holder and its two states, and the names a reader declares in its `reads`. -const ( - Seat = "mesh-provisioner" - Module = "mesh-provisioner" - ProvisionsState = "provisions" - PlansState = "plans" - ProvisionsRead = Module + "." + ProvisionsState - PlansRead = Module + "." + PlansState - // ProvisionsBucket and PlansBucket are the states' buckets. - ProvisionsBucket = Module + "_" + ProvisionsState - PlansBucket = Module + "_" + PlansState -) - -// The states of a provision (clause 1). -const ( - // Asked is a provision its consumer needs that its provider has not answered yet, or whose dependencies are not - // all provided (clause 3). - Asked = "asked" - // Provided is a provision the provider said it made: set only from the provider's outcome (clause 3). - Provided = "provided" - // Waits is a provision only a person can give, naming what is awaited (clause 7). - Waits = "waits" - // Failed is a provision the provider could not make, with why. - Failed = "failed" - // Retiring is a provision no want needs, asked of the operator before its credential goes (clause 5). - Retiring = "retiring" -) - -// States is every state a provision may be in. -var States = []string{Asked, Provided, Waits, Failed, Retiring} - -// Version is the version of the records' layout. -const Version = 1 - -// token is one part of a key: what a bucket's key takes, no dot. -var token = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) - -// Key is a provision's key: `..`, and `.` where the consumer keeps several values of -// one provision (ADR 0094). The same key the provider's own entry for that consumer has (provider.Entry.Key). -func Key(node, consumer, provision, local string) (string, error) { - parts := []string{node, consumer, provision} - if local != "" { - parts = append(parts, local) - } - for _, p := range parts { - if !token.MatchString(p) { - return "", fmt.Errorf("provisions: %q cannot be part of a provision's key", p) - } - } - return strings.Join(parts, "."), nil -} - -// ParseKey is the inverse of Key. -func ParseKey(key string) (node, consumer, provision, local string, err error) { - parts := strings.Split(key, ".") - if len(parts) < 3 || len(parts) > 4 { - return "", "", "", "", fmt.Errorf("provisions: %q is not ..[.]", key) - } - for _, p := range parts { - if !token.MatchString(p) { - return "", "", "", "", fmt.Errorf("provisions: %q is not ..[.]", key) - } - } - if len(parts) == 4 { - local = parts[3] - } - return parts[0], parts[1], parts[2], local, nil -} - -// PlanKey is a plan's key in `plans`: `.` (clause 2). -func PlanKey(node, module string) (string, error) { - if !token.MatchString(node) || !token.MatchString(module) { - return "", fmt.Errorf("provisions: %s.%s is not a plan's key, .", node, module) - } - return node + "." + module, nil -} - -// The forms of a provision's credential, as a plan's entry names them (catalogue.PlanProvision.CredentialForm): the -// catalogue's one list (catalogue.CredentialForms, issue 774), under the names this package has used. -const ( - FormPair = catalogue.CredentialFormPair - FormShared = catalogue.CredentialFormShared - FormIssued = catalogue.CredentialFormIssued - FormGiven = catalogue.CredentialFormGiven - // FormNone is an offer whose consumers present no credential (catalogue.CredentialFormNone): the vault mints nothing, - // and the provision depends on no credential. - FormNone = catalogue.CredentialFormNone -) - -// VaultName is the vault's name of a provision's credential in form, as its verb `own` names it and the provider asks -// it: `pair////[/]` for a login of the consumer's own (ADR 0158 -// §11), `provision//` for the provider's one shared value, and `provision////` for a holder's own issued or given value. Empty for a form with no -// credential. -func VaultName(form string, p Provision) string { - switch form { - case FormPair: - n := "pair/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module - if p.Local != "" { - n += "/" + p.Local - } - return n - case FormShared: - return "provision/" + p.Provider.Node + "/" + p.Provision - case FormIssued, FormGiven: - return "provision/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module - } - return "" -} - -// VaultModule is the provider of every provision's credential (novox/hq ADR 0570 clauses 1, 6). -const VaultModule = "mesh-vault" - -// CredentialKey is the key of a provision's credential, itself a provision whose provider is the vault (clause 6; the -// key form the lead settled on 2026-10-29): `..secret-[.]` for a value of the -// consumer's own (pair, issued, given), and `..secret-` for the provider's one -// shared value. False for a form with no vault value, and for a provision the vault itself provides. -func CredentialKey(form string, consumer, provider End, provision, local string) (string, bool) { - if provider.Module == VaultModule { - return "", false // the vault's own offer is the value: it has no credential beside it (the lead, 2026-10-29) - } - name := catalogue.CredentialProvisionPrefix + provision - switch form { - case FormPair, FormIssued, FormGiven: - k, err := Key(consumer.Node, consumer.Module, name, local) - return k, err == nil - case FormShared: - k, err := Key(provider.Node, provider.Module, name, "") - return k, err == nil - } - return "", false -} - -// DependsOn is what a provision waits for before its provider makes it (clause 3): its credential's key, where its form -// has a vault value, and the key of every provision its provider module itself requires on its own node, read from -// the provider's plan there (nil when the provider is not handed and has none). Sorted, none twice. -func DependsOn(consumer End, entry catalogue.PlanProvision, providerPlan *catalogue.Plan) []string { - seen := map[string]bool{} - var out []string - add := func(k string) { - if k != "" && !seen[k] { - seen[k] = true - out = append(out, k) - } - } - provider := End{Module: entry.ProviderModule, Node: entry.Provider} - if k, ok := CredentialKey(entry.CredentialForm, consumer, provider, entry.Provision, entry.Local); ok { - add(k) - } - if providerPlan != nil { - for _, own := range providerPlan.Provisions { - if k, err := Key(provider.Node, provider.Module, own.Provision, own.Local); err == nil { - add(k) - } - } - } - sort.Strings(out) - return out -} - -// End is one end of a provision: a module on a node. -type End struct { - Module string `json:"module"` - Node string `json:"node"` -} - -// Reference is a value the vault keeps, by its id and the generation of the value: never the value, sealed or not -// (ADR 0349 §7). The holder writes the generation the vault says in its own entry (clause 6). ID is the vault's name of -// the credential exactly as its verb `own` names it (VaultName): the consuming node's engine checks both the name and -// the generation of what it is handed. -type Reference struct { - ID string `json:"id"` - Generation int `json:"generation"` -} - -// Warrant is the operator's answer to a retiring provision's ask, as the holder writes it into the key (clause 5). -type Warrant struct { - Ask string `json:"ask"` - Option string `json:"option"` - By string `json:"by"` - At time.Time `json:"at"` -} - -// Provision is one key of `provisions`. -type Provision struct { - V int `json:"v"` - Provision string `json:"provision"` - Local string `json:"local,omitempty"` - Consumer End `json:"consumer"` - Provider End `json:"provider"` - // DependsOn are the keys of the provisions this one waits for (clause 3): a database user whose password is the - // vault's. - DependsOn []string `json:"depends-on,omitempty"` - State string `json:"state"` - // Why is the reason for waits, failed and retiring, and for asked when a dependency holds it. - Why string `json:"why,omitempty"` - Reference *Reference `json:"reference,omitempty"` - // Ask is a retiring provision's ask of the operator; Warrant the answer, once given. - Ask string `json:"ask,omitempty"` - Warrant *Warrant `json:"warrant,omitempty"` - // Plan is the plan entry the key was composed from, at the consumer's wanted build or its newest (issue 1126): the - // provider binds the consumer from it, so a requirement the newest build adds is provided before the want that - // waits for it moves, though the plan the seat keeps at the wanted build names it not yet. - Plan *catalogue.PlanProvision `json:"plan,omitempty"` - At time.Time `json:"at"` -} - -// Key is the provision's key. -func (p Provision) Key() (string, error) { - return Key(p.Consumer.Node, p.Consumer.Module, p.Provision, p.Local) -} - -func (p Provision) check() error { - if _, err := p.Key(); err != nil { - return err - } - if p.Provider.Module == "" || p.Provider.Node == "" { - return fmt.Errorf("provisions: %s names no provider", p.Provision) - } - known := false - for _, s := range States { - known = known || s == p.State - } - switch { - case !known: - return fmt.Errorf("provisions: a provision's state is one of %s, not %q", strings.Join(States, ", "), p.State) - case (p.State == Waits || p.State == Failed || p.State == Retiring) && strings.TrimSpace(p.Why) == "": - return fmt.Errorf("provisions: a %s provision says why", p.State) - case p.Warrant != nil && p.State != Retiring: - return fmt.Errorf("provisions: only a retiring provision carries the operator's warrant") - } - for _, d := range p.DependsOn { - if _, _, _, _, err := ParseKey(d); err != nil { - return err - } - } - return nil -} - -// Encode is p as JSON at Version; it refuses what a holder may not write. -func Encode(p Provision) ([]byte, error) { - p.V = Version - if err := p.check(); err != nil { - return nil, err - } - return json.Marshal(p) -} - -// Decode reads a key's value. **A field the layout does not name is read past and dropped** (issue 1126 review 1): a -// reader on an older mesh-sdk must not refuse a key a newer writer adds a field to. No value rides in a provision all -// the same (clause 1, ADR 0201 §13): Encode writes the layout alone, so what a reader does not know is never kept. -func Decode(data []byte) (Provision, error) { - var p Provision - dec := json.NewDecoder(bytes.NewReader(data)) - if err := dec.Decode(&p); err != nil { - return Provision{}, fmt.Errorf("provisions: a provision is not readable: %w", err) - } - if p.V != Version { - return Provision{}, fmt.Errorf("provisions: a provision of version %d, and this reads %d", p.V, Version) - } - return p, p.check() -} - -// Plan is one key of `plans`: a module's provisioning plan on a node, never a value (clause 2). -type Plan struct { - V int `json:"v"` - Node string `json:"node"` - Module string `json:"module"` - Plan catalogue.Plan `json:"plan"` -} - -// EncodePlan is p as JSON at Version. -func EncodePlan(p Plan) ([]byte, error) { - p.V = Version - if _, err := PlanKey(p.Node, p.Module); err != nil { - return nil, err - } - return json.Marshal(p) -} - -// DecodePlan reads a plan's key. -func DecodePlan(data []byte) (Plan, error) { - var p Plan - if err := json.Unmarshal(data, &p); err != nil { - return Plan{}, fmt.Errorf("provisions: a plan is not readable: %w", err) - } - if p.V != Version { - return Plan{}, fmt.Errorf("provisions: a plan of version %d, and this reads %d", p.V, Version) - } - _, err := PlanKey(p.Node, p.Module) - return p, err -} diff --git a/go/provisions/provisions_test.go b/go/provisions/provisions_test.go deleted file mode 100644 index f9e5ab8..0000000 --- a/go/provisions/provisions_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package provisions - -import ( - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// ADR 0570 clauses 1, 2 and 5 (issue 1954 L6-P2): a provision's key is its consumer's node and module, the provision and -// its local name, the provider's entry's key; its value round-trips, names its state and why, and refuses any field the -// layout does not name, so no value rides in it; a plan's key is its node and module. -func TestAProvisionIsAReferenceUnderItsConsumersKey(t *testing.T) { - at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) - db := Provision{Provision: "postgres-database", Consumer: End{Module: "keycloak", Node: "novox"}, - Provider: End{Module: "postgres", Node: "novox"}, DependsOn: []string{"novox.keycloak.secret.db"}, State: Asked, - Reference: &Reference{ID: "provision/postgres/postgres-database", Generation: 2}, At: at} - for _, c := range []struct { - name string - edit func(*Provision) - key string - refused string - }{ - {name: "asked, depending on the vault's", key: "novox.keycloak.postgres-database"}, - {name: "under a local name", edit: func(p *Provision) { p.Local = "replica" }, key: "novox.keycloak.postgres-database.replica"}, - {name: "retiring with the operator's warrant", edit: func(p *Provision) { - p.State, p.Why, p.Ask = Retiring, "keycloak left novox", "a-1" - p.Warrant = &Warrant{Ask: "a-1", Option: "yes", By: "the operator", At: at} - }, key: "novox.keycloak.postgres-database"}, - {name: "an unknown state", edit: func(p *Provision) { p.State = "done" }, refused: "one of"}, - {name: "failed without why", edit: func(p *Provision) { p.State = Failed }, refused: "says why"}, - {name: "a warrant on a provided one", edit: func(p *Provision) { p.State, p.Warrant = Provided, &Warrant{Ask: "a"} }, refused: "only a retiring"}, - {name: "no provider", edit: func(p *Provision) { p.Provider = End{} }, refused: "no provider"}, - {name: "a dotted consumer", edit: func(p *Provision) { p.Consumer.Module = "a.b" }, refused: "cannot be part"}, - {name: "a dependency that is no key", edit: func(p *Provision) { p.DependsOn = []string{"x"} }, refused: "is not"}, - } { - p := db - if c.edit != nil { - c.edit(&p) - } - raw, err := Encode(p) - if c.refused != "" { - if err == nil || !strings.Contains(err.Error(), c.refused) { - t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused) - } - continue - } - back, err := Decode(raw) - key, kerr := back.Key() - if err != nil || kerr != nil || key != c.key || back.V != Version || back.Reference.Generation != 2 { - t.Errorf("%s: read back as %+v under %q, %v %v", c.name, back, key, err, kerr) - } - if node, consumer, provision, local, err := ParseKey(key); err != nil || node != "novox" || consumer != "keycloak" || - provision != "postgres-database" || local != p.Local { - t.Errorf("%s: %s parses as %s %s %s %s, %v", c.name, key, node, consumer, provision, local, err) - } - } - // A field a later layout adds is read past, never kept (issue 1126 review 1: a reader on an older mesh-sdk must not - // refuse a key a newer writer adds a field to). A value still never rides in a provision: Encode writes the layout - // alone, so what a reader does not know is dropped, and encoding it again carries none of it. - later, err := Decode([]byte(`{"v":1,"provision":"p","consumer":{"module":"c","node":"n"},"provider":{"module":"p","node":"n"},` + - `"state":"provided","at":"2026-10-29T22:00:00Z","a-later-field":{"x":1},"password":"hunter2"}`)) - if err != nil || later.Provision != "p" || later.State != Provided { - t.Errorf("a provision with a field of a later layout: %+v, %v", later, err) - } - if again, _ := Encode(later); strings.Contains(string(again), "hunter2") || strings.Contains(string(again), "a-later-field") { - t.Errorf("a field the layout does not name was kept: %s", again) - } - plan := Plan{Node: "novox", Module: "restic", Plan: catalogue.Plan{}} - raw, err := EncodePlan(plan) - if err != nil { - t.Fatal(err) - } - if back, err := DecodePlan(raw); err != nil || back.Node != "novox" || back.Module != "restic" { - t.Errorf("a plan read back as %+v, %v", back, err) - } - if k, err := PlanKey("novox", "restic"); k != "novox.restic" || err != nil { - t.Errorf("a plan's key is %q, %v", k, err) - } - // A reference's id is the vault's name as its verb own names it, in each form (the host agent, 2026-10-29). - for form, want := range map[string]string{FormPair: "pair/novox/postgres-database/novox/keycloak", FormShared: "provision/novox/postgres-database", - FormIssued: "provision/novox/postgres-database/novox/keycloak", FormNone: ""} { - if got := VaultName(form, db); got != want { - t.Errorf("the %s name is %q", form, got) - } - } - if got := VaultName(FormPair, Provision{Provision: "p", Local: "r", Provider: End{Node: "a"}, Consumer: End{Node: "b", Module: "m"}}); got != "pair/a/p/b/m/r" { - t.Errorf("a local name's pair is %q", got) - } - // A provision waits for its credential, the vault's provision `secret-`, and for what its provider itself - // requires on its node (clause 3; the key form the lead settled on 2026-10-29). - keycloak, postgres := End{Module: "keycloak", Node: "novox"}, End{Module: "postgres", Node: "ace"} - providerPlan := &catalogue.Plan{Provisions: []catalogue.PlanProvision{{Provision: "backup-target", Provider: "ace", ProviderModule: "restic"}}} - for _, c := range []struct { - form string - plan *catalogue.Plan - want string - }{ - {FormPair, nil, "novox.keycloak.secret-postgres-database"}, - {FormShared, nil, "ace.postgres.secret-postgres-database"}, - {FormPair, providerPlan, "ace.postgres.backup-target,novox.keycloak.secret-postgres-database"}, - {"", nil, ""}, - // An offer with no credential (the lead, 2026-10-29): nothing minted, nothing to wait for. - {FormNone, nil, ""}, - } { - entry := catalogue.PlanProvision{Provision: "postgres-database", Provider: postgres.Node, ProviderModule: postgres.Module, - CredentialForm: c.form} - if got := strings.Join(DependsOn(keycloak, entry, c.plan), ","); got != c.want { - t.Errorf("%q depends on %q, want %q", c.form, got, c.want) - } - } - // The vault's own offer is the value: no secret-secret beside it. - vaultEntry := catalogue.PlanProvision{Provision: "secret", Provider: "novox", ProviderModule: VaultModule, CredentialForm: FormPair} - if got := DependsOn(keycloak, vaultEntry, nil); len(got) != 0 { - t.Errorf("the vault's secret depends on %v", got) - } - if ProvisionsBucket != "mesh-provisioner_provisions" || PlansRead != "mesh-provisioner.plans" { - t.Errorf("the states are %s and %s", ProvisionsBucket, PlansRead) - } -} diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 3a67be4..086c3da 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -42,6 +42,4 @@ health/findings.go: mesh-healers health/findings.go: mesh-self-check internal/beside/beside.go: mesh-catalog overlay/overlay.go: the-private-network -provisions/provisions.go: mesh-provisioner -provisions/provisions.go: mesh-vault seatlist/seatlist.go: mesh-catalog -- 2.54.0 From 1465a8a8315a5896b86a5b650cbbec1112d3afea Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 17:17:55 +0100 Subject: [PATCH 125/144] =?UTF-8?q?Delete=20go/buildstep:=20no=20importer?= =?UTF-8?q?=20is=20left=20in=20the=20joined=20mesh-delivery;=20the=20packa?= =?UTF-8?q?ge=20list=20follows=20(S-7,=20ADR=200634=20=C2=A75;=20issue=201?= =?UTF-8?q?966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/buildstep/capacity.go | 7 - go/buildstep/doc.go | 5 - go/buildstep/failure.go | 141 ------------- go/buildstep/helpers.go | 19 -- go/buildstep/launchers.go | 14 -- go/buildstep/launchers_test.go | 22 --- go/buildstep/request.go | 163 --------------- go/buildstep/request_test.go | 64 ------ go/buildstep/stale.go | 330 ------------------------------- go/buildstep/stale_test.go | 162 --------------- go/buildstep/step.go | 22 --- go/buildstep/step_test.go | 72 ------- go/buildstep/trees.go | 15 -- go/delivery/stepasks_key_test.go | 38 ---- go/packages_list_test.go | 2 +- 15 files changed, 1 insertion(+), 1075 deletions(-) delete mode 100644 go/buildstep/capacity.go delete mode 100644 go/buildstep/doc.go delete mode 100644 go/buildstep/failure.go delete mode 100644 go/buildstep/helpers.go delete mode 100644 go/buildstep/launchers.go delete mode 100644 go/buildstep/launchers_test.go delete mode 100644 go/buildstep/request.go delete mode 100644 go/buildstep/request_test.go delete mode 100644 go/buildstep/stale.go delete mode 100644 go/buildstep/stale_test.go delete mode 100644 go/buildstep/step.go delete mode 100644 go/buildstep/step_test.go delete mode 100644 go/buildstep/trees.go delete mode 100644 go/delivery/stepasks_key_test.go 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 7732329..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": "main"}}, "neither a full commit nor a Go semantic version"}, - {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 bd7deb7..0000000 --- a/go/buildstep/stale.go +++ /dev/null @@ -1,330 +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"))) - } - 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