diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index 22597ceb..59a01838 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -45,9 +45,9 @@ issue is taken over, and the reason is noted on it. A worker with no move, note, issue for `worker-stale-after` (default `24h`) is stale and may be taken over from without a reason, still noted. The tracker's own writes (the triage, the duplicate check, the delivery notes) keep no worker current. The `moved` event carries `by` (the worker; who acted, for a move naming none), `since`, `caller` (who acted, as the runtime -proves it: `unproven` today), and `took-over-from`, `take-over` or `released` when they apply. `by` is what the -worker says it is: the runtime does not yet tell a module its caller (hq issue 340), so the worker lets agents and -people see each other; it is not a lock against a false name. +proves it: see *Who acted*), and `took-over-from`, `take-over` or `released` when they apply. `by` is what the +worker says it is, not who called, so the worker lets agents and people see each other; it is not a lock against +a false name. **An issue with a folder in hq** (`record` at open: the folder's name, without the number) is whole in the tracker like any other: the folder holds its narrative, the tracker its status. Until the import of @@ -138,8 +138,8 @@ It is a verb on the seat, callable by any agent, because it can do only this: A folder numbered at or past the setting `first-number` without an issue in the tracker is refused on its own: a folder from the tracker's time carries a number the tracker gave. - **It cannot forge an issue's history.** An issue it writes is `opened-by: import`, its `opened` the date - the report gave, and it carries `imported: {at, by, commit, from}` — when, who ran the import (the bus - credential, `unproven` until the runtime proves it), the commit of hq and the document. Each is an + the report gave, and it carries `imported: {at, by, commit, from}` — when, who ran the import (the caller, + as the runtime proves it), the commit of hq and the document. Each is an `imported` event. What it writes comes from hq's `main` as the records module holds it, which is reviewed in pull requests. - **What it keeps is bounded** as every verb's text is: the title 200 bytes, the report 64 KiB, one issue @@ -197,7 +197,7 @@ sessions writing a record at once never take one number. - **A reserved number is spent forever**, whether or not its record ever lands; it is never handed out again. Each is kept in the state `reservations` under `decision-0292` or `research-046` with who reserved it - (`by`, from the caller as for every verb: `unproven` until the runtime names the caller), when (`at`) and + (`by`, the caller as for every verb), when (`at`) and `for`, and said as the event `mesh-issues.reserved`. A reservation whose write cannot be confirmed is refused, and its number stays spent. - **A refused reserve takes no number**: a kind other than `decision` or `research`, a `for` that is not one @@ -230,10 +230,14 @@ is kept, and its answer says the event was not said (`announced: false`). ## Who acted -ADR 0275 §5 records the caller from the bus credential, never from an argument. **The runtime does not yet -tell a module who called it**: a tool call reaches a bundle as a name and arguments, and the tool runner is -not told who called it either (hq issue 340). Until it does, every change is recorded as by `unproven`, no -verb takes an argument naming a caller, and none can be given one. +ADR 0275 §5 records the caller from the bus credential, never from an argument. **Who acted is the bus user +that called** (hq issue 365): a call on a tool's caller-named subject carries the caller's bus user as its last +token, the bus lets each user publish there with its own name alone, and the tool runner hands the module that +user from the subject the call arrived on (mesh-sdk `stdio.CallerOf`) — `shanks.node-tools` for an agent on +that machine's console, `person.` for a person's credential. A call on the subject that names no caller +(served beside it for one release, hq issue 464), or through a runtime from before issue 365, is recorded as +by `unproven`. No verb takes an argument naming a caller, and none can be given one. The caller is only as sound +as the credential, which ADR 0266 keeps agents from reading (hq issue 333). ## Settings diff --git a/modules/mesh-issues/cmd/mesh-issues/caller_test.go b/modules/mesh-issues/cmd/mesh-issues/caller_test.go new file mode 100644 index 00000000..3bcf32ad --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/caller_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// served is the tracker's verbs served as the runtime launches them, over the SDK's stdio loop, with the caller +// read from the runtime (stdio.CallerOf): what reaches the module is what a tools/call carries. +type served struct { + in *io.PipeWriter + lines *bufio.Scanner + next int +} + +func serve(t *testing.T, h *harness) *served { + t.Helper() + h.CallerOf = stdio.CallerOf + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { _ = stdio.ServeOn("mesh-issues", Tools(h.Tracker), inR, outW) }() + t.Cleanup(func() { _ = inW.Close() }) + return &served{in: inW, lines: bufio.NewScanner(outR)} +} + +// call sends one tools/call, with the runtime's `_meta` naming caller when one is given, and answers the +// verb's result or its refusal. +func (s *served) call(t *testing.T, verb, caller string, args map[string]any) (map[string]any, string) { + t.Helper() + s.next++ + params := map[string]any{"name": Seat + "." + verb, "arguments": args} + if caller != "" { + params["_meta"] = map[string]any{stdio.MetaCaller: caller} + } + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": s.next, "method": "tools/call", "params": params}) + _, _ = s.in.Write(append(b, '\n')) + done := make(chan []byte, 1) + go func() { + if s.lines.Scan() { + done <- append([]byte(nil), s.lines.Bytes()...) + } + }() + var line []byte + select { + case line = <-done: + case <-time.After(5 * time.Second): + t.Fatalf("%s was not answered", verb) + } + var m struct { + Result struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + _ = json.Unmarshal(line, &m) + if len(m.Result.Content) == 0 { + t.Fatalf("%s was answered %s", verb, line) + } + text := m.Result.Content[0].Text + if m.Result.IsError { + return nil, text + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + return out, "" +} + +// **Who acted is the caller the runtime proved** (novox/hq issue 365, ADR 0275 §5): a call whose subject named +// its caller is recorded as that bus user — on the issue it opened, the note, the link, the move and its event, +// the value set, and the number reserved. +func TestWhoActedIsTheCallerTheRuntimeProved(t *testing.T) { + h, _ := reserveHarness(t) + s := serve(t, h) + const who = "shanks.node-tools" + opened, refused := s.call(t, "open", who, map[string]any{"title": "a title", "report": "a report"}) + if refused != "" || opened["by"] != who { + t.Fatalf("open answered %v %q, want by %s", opened, refused, who) + } + n := uint64(opened["number"].(float64)) + other, _ := s.call(t, "open", who, map[string]any{"title": "another", "report": "another report"}) + is, _ := h.Get(n) + if is.OpenedBy != who { + t.Errorf("the issue was opened by %q", is.OpenedBy) + } + if noted, refused := s.call(t, "note", who, map[string]any{"number": n, "text": "seen again"}); refused != "" || noted["by"] != who { + t.Errorf("note answered %v %q", noted, refused) + } + if linked, refused := s.call(t, "link", who, map[string]any{"number": n, "kind": "relates-to", "target": fmt.Sprint(other["number"])}); refused != "" || linked["by"] != who { + t.Errorf("link answered %v %q", linked, refused) + } + if _, refused := s.call(t, "set", who, map[string]any{"number": n, "severity": "minor"}); refused != "" { + t.Errorf("set refused: %s", refused) + } + if _, refused := s.call(t, "move", who, map[string]any{"number": n, "status": "fixing", "by": "fix-1@g14"}); refused != "" { + t.Errorf("move refused: %s", refused) + } + if reserved, refused := s.call(t, "reserve", who, map[string]any{"kind": "decision", "for": "a decision"}); refused != "" || reserved["by"] != who { + t.Errorf("reserve answered %v %q", reserved, refused) + } + is, _ = h.Get(n) + for _, note := range is.Notes { + if note.By != who { + t.Errorf("a note is by %q", note.By) + } + } + for _, e := range h.said() { + if e.Event == EventMoved && e.Body["caller"] != who { + t.Errorf("the move was said as by caller %v", e.Body["caller"]) + } + } +} + +// A caller named in the arguments is refused, whatever it names — an argument is the caller's to write — and a +// call the runtime proved no caller for is recorded Unproven, as it always was. +func TestACallerInTheArgumentsIsRefusedAndAnUnprovenCallSaysSo(t *testing.T) { + h := newHarness(t) + s := serve(t, h) + for _, args := range []map[string]any{ + {"title": "t", "report": "r", "caller": "novox.node-tools"}, + {"title": "t", "report": "r", "_meta": map[string]any{stdio.MetaCaller: "novox.node-tools"}}, + {"title": "t", "report": "r", "by": "novox.node-tools"}, + } { + if out, refused := s.call(t, "open", "shanks.node-tools", args); refused == "" || !strings.Contains(refused, "does not take") { + t.Errorf("an open naming a caller in its arguments %v was answered %v", args, out) + } + } + opened, refused := s.call(t, "open", "", map[string]any{"title": "t", "report": "r"}) + if refused != "" || opened["by"] != Unproven { + t.Fatalf("an open the runtime named no caller for answered %v %q, want by %s", opened, refused, Unproven) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/import.go b/modules/mesh-issues/cmd/mesh-issues/import.go index 5c6e6f95..ef60fb16 100644 --- a/modules/mesh-issues/cmd/mesh-issues/import.go +++ b/modules/mesh-issues/cmd/mesh-issues/import.go @@ -214,6 +214,11 @@ type candidate struct { // number: create-if-absent on the key, the counter untouched. See the file's comment for what it never // does. func (t *Tracker) Import(a ImportAsk, records Records) (*Imported, error) { + return t.importAs(t.by(), a, records) +} + +// importAs is Import as the one who acts. +func (t *Tracker) importAs(by string, a ImportAsk, records Records) (*Imported, error) { settings, err := t.Settings() if err != nil { return nil, fmt.Errorf("the settings cannot be read (%v); nothing was imported", err) @@ -241,7 +246,6 @@ func (t *Tracker) Import(a ImportAsk, records Records) (*Imported, error) { } out := &Imported{DryRun: a.DryRun, Commit: commit, ByStatus: map[string]int{}, Rows: []ImportRow{}} at := t.now() - by := t.by() // Every folder's number, and no two folders sharing one. var cands []*candidate diff --git a/modules/mesh-issues/cmd/mesh-issues/issue.go b/modules/mesh-issues/cmd/mesh-issues/issue.go index b7056cba..6fae032e 100644 --- a/modules/mesh-issues/cmd/mesh-issues/issue.go +++ b/modules/mesh-issues/cmd/mesh-issues/issue.go @@ -55,11 +55,12 @@ func oneOf(field, v string, allowed []string) (string, error) { return "", fmt.Errorf("%s %q is not one of %s", field, v, strings.Join(allowed, ", ")) } -// Unproven is who acted, as recorded, while the runtime does not tell a module who called it: a tool call -// reaches a bundle as a name and arguments only (mesh-sdk Tool.Run), and the tool runner is not told who -// called it either (hq issue 340). The caller is never taken from an argument, so this is recorded rather -// than a name anybody could type (novox/hq ADR 0275 §5). -const Unproven = "unproven" +// Unproven is who acted, as recorded, where the runtime proves no caller: a call on the subject that names none, +// a runtime from before hq issue 365, or a public method called at the terminal. Otherwise who acted is the bus +// user the runtime took from the subject the call arrived on (stdio.CallerOf). The caller is never taken from an +// argument, so neither is a name anybody could type (novox/hq ADR 0275 §5). Mesh-sdk says the same word +// (stdio.Unproven). +const Unproven = stdio.Unproven // Issue is one issue as the tracker keeps it. type Issue struct { @@ -122,7 +123,7 @@ const ImportedBy = "import" // Import is how an issue, or its status, came from hq. type Import struct { At time.Time `json:"at"` - // By is who ran the import, as the runtime proves it (Unproven until it does). + // By is who ran the import, as the runtime proves it (Unproven where it proves none). By string `json:"by"` Commit string `json:"commit"` // From is the document read, empty for a status filled without one. diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index 9a61a6c0..6f042df9 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -87,6 +87,8 @@ func main() { Logf: logf, Sleep: time.Sleep, Judge: busJudgement(os.Getenv("MESH_NODE")), + // Who called, from the runtime (hq issue 365): never from the arguments. + CallerOf: stdio.CallerOf, } t.Notify = busNotifier() t.Deliveries = func(repository, number string) (Delivery, error) { @@ -340,7 +342,7 @@ func Tools(t *Tracker) []stdio.Tool { if ask.Needs, err = a.str("needs"); err != nil { return nil, err } - return t.Open(ask) + return t.open(t.callerOf(m), ask) }}, {Name: Seat + ".get", Description: "One issue whole, by its number: title, status, severity, what it needs, report, who opened it " + @@ -463,7 +465,7 @@ func Tools(t *Tracker) []stdio.Tool { return nil, err } } - return t.Move(ask) + return t.move(t.callerOf(m), ask) }}, {Name: Seat + ".note", Description: "Add a note to an issue: what was found, tried or decided, in plain words. A note is kept as " + @@ -486,7 +488,7 @@ func Tools(t *Tracker) []stdio.Tool { if err != nil { return nil, err } - return t.Note(n, s) + return t.note(t.callerOf(m), n, s) }}, {Name: Seat + ".link", Description: "Link an issue to something, or with remove, take a link away. Kinds: duplicates or relates-to " + @@ -518,7 +520,7 @@ func Tools(t *Tracker) []stdio.Tool { if ask.Remove, err = a.flag("remove"); err != nil { return nil, err } - return t.Link(ask) + return t.link(t.callerOf(m), ask) }}, {Name: Seat + ".import", Description: "Import hq's issue folders (04-ISSUES/NNN-slug/00-report.md), read through the records module at " + @@ -548,7 +550,7 @@ func Tools(t *Tracker) []stdio.Tool { if records == nil { records = busRecords{} } - return t.Import(ask, records) + return t.importAs(t.callerOf(m), ask, records) }}, {Name: Seat + ".groom", Description: "The triage over the backlog: every issue not resolved and not yet triaged is judged, " + @@ -606,7 +608,7 @@ func Tools(t *Tracker) []stdio.Tool { if ask.SuggestedOwner, err = a.str("suggested-owner"); err != nil { return nil, err } - return t.Set(ask) + return t.set(t.callerOf(m), ask, false) }}, {Name: Seat + ".tracking", Description: "Which issues track which conditions: for every condition key an issue links (link kind condition), " + @@ -654,7 +656,7 @@ func Tools(t *Tracker) []stdio.Tool { if ask.For, err = a.str("for"); err != nil { return nil, err } - return t.Reserve(ask) + return t.reserve(t.callerOf(m), ask) }}, } } diff --git a/modules/mesh-issues/cmd/mesh-issues/reserve.go b/modules/mesh-issues/cmd/mesh-issues/reserve.go index 0bb57f93..1f061984 100644 --- a/modules/mesh-issues/cmd/mesh-issues/reserve.go +++ b/modules/mesh-issues/cmd/mesh-issues/reserve.go @@ -98,7 +98,10 @@ type Reserved struct { // Reserve hands out the next number of a sequence and keeps who reserved it, when and for what. A refused // reserve spends no number; a number handed out is spent forever. -func (t *Tracker) Reserve(a ReserveAsk) (*Reserved, error) { +func (t *Tracker) Reserve(a ReserveAsk) (*Reserved, error) { return t.reserve(t.by(), a) } + +// reserve is Reserve as the one who acts. +func (t *Tracker) reserve(by string, a ReserveAsk) (*Reserved, error) { kind := a.Kind seq, ok := sequences[kind] if !ok { @@ -121,7 +124,7 @@ func (t *Tracker) Reserve(a ReserveAsk) (*Reserved, error) { if err != nil { return nil, err } - r := Reservation{Kind: kind, Number: n, For: purpose, By: t.by(), At: t.now(), ID: newID()} + r := Reservation{Kind: kind, Number: n, For: purpose, By: by, At: t.now(), ID: newID()} if err := t.keep(&r); err != nil { return nil, err } diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index ea9b84cc..5122341e 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -34,7 +34,9 @@ type Tracker struct { Deliveries DeliveryState // Notify tells the operator's channel of a stuck fix (ADR 0281); nil says nothing there. Notify Notifier - groom grooming + // CallerOf is who called, for the arguments one call was handed (stdio.CallerOf); nil proves nobody. + CallerOf func(map[string]any) string + groom grooming } // The events this module says, one per kind of change. @@ -57,10 +59,22 @@ const ( createAttempts = 3 ) -// by is who acted on this call. The runtime does not hand a module its caller, so it is Unproven; it is -// never read from the call's arguments, so no caller can name another (ADR 0275 §5). +// by is who acted where no call says it — a public method called at the terminal or in a test: Unproven. func (t *Tracker) by() string { return Unproven } +// callerOf is who acted on one call, as the runtime proves it: the bus user that published the call, which the +// runtime takes from the subject it arrived on (hq issue 365, mesh-sdk stdio.CallerOf), or Unproven where the +// runtime proves none. It is never read from the call's arguments, so no caller can name another (ADR 0275 §5). +func (t *Tracker) callerOf(m map[string]any) string { + if t.CallerOf == nil { + return Unproven + } + if c := t.CallerOf(m); c != "" { + return c + } + return Unproven +} + func (t *Tracker) now() time.Time { return t.Now().UTC().Truncate(time.Second) } func (t *Tracker) owners() (Owners, error) { @@ -108,7 +122,10 @@ type Opened struct { // Open checks an issue, then takes the next number and writes the issue under it (to-be 53 §4). A refused // issue takes no number. A number taken and then not written — the bus failing between the two — stays // unused, and the answer says which: numbers may skip, never repeat. -func (t *Tracker) Open(a OpenAsk) (*Opened, error) { +func (t *Tracker) Open(a OpenAsk) (*Opened, error) { return t.open(t.by(), a) } + +// open is Open as the one who acts. +func (t *Tracker) open(by string, a OpenAsk) (*Opened, error) { title, err := singleLine("title", a.Title, MostTitle) if err != nil { return nil, err @@ -143,7 +160,7 @@ func (t *Tracker) Open(a OpenAsk) (*Opened, error) { } } at := t.now() - is := Issue{Title: title, Status: StatusOpen, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID(), + is := Issue{Title: title, Status: StatusOpen, Report: report, Opened: at, OpenedBy: by, LocatedIn: located, Opening: newID(), Severity: severity, Needs: needs} n, err := t.next() diff --git a/modules/mesh-issues/cmd/mesh-issues/worker.go b/modules/mesh-issues/cmd/mesh-issues/worker.go index ef3f0721..534395e8 100644 --- a/modules/mesh-issues/cmd/mesh-issues/worker.go +++ b/modules/mesh-issues/cmd/mesh-issues/worker.go @@ -7,9 +7,9 @@ package main // from a caller for `worker-stale-after` (24 hours) is stale: `list` says so, and it may be taken over without a // reason. // -// `by` is what the worker says it is, not who called: the runtime does not yet hand a module its caller (hq -// issue 340), so who acted stays recorded as the runtime proves it (Unproven), and the worker is a way for -// agents and people to see each other, not a lock against someone giving a false name. +// `by` is what the worker says it is, not who called: who acted is recorded beside it as the runtime proves it +// (the caller's bus user, hq issue 365, or Unproven), and the worker is a way for agents and people to see each +// other, not a lock against someone giving a false name. import ( "fmt" diff --git a/modules/mesh-issues/go.mod b/modules/mesh-issues/go.mod index 52731589..992631ae 100644 --- a/modules/mesh-issues/go.mod +++ b/modules/mesh-issues/go.mod @@ -3,7 +3,7 @@ module mesh-issues go 1.25.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.12 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011031108-d9e24e95a242 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.51.0 ) diff --git a/modules/mesh-issues/go.sum b/modules/mesh-issues/go.sum index be0532de..4a4207f4 100644 --- a/modules/mesh-issues/go.sum +++ b/modules/mesh-issues/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.12 h1:QvmLlT88F+OYt8MOytMGM5ZEOo0fKmNZvNVeJe6gnTk= git.novox.be/novox/mesh-sdk/go v0.1.12/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011031108-d9e24e95a242 h1:vpYUOltncgA+GCh/KI8e6azffB1K7768Ka+VuvqXjiE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261011031108-d9e24e95a242/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= 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=