diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index ae1916bf..fdfa987e 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -251,6 +251,9 @@ func handActCommand(ctx context.Context, args []string) error { if len(args) > 0 && args[0] == "drill" { return handActDrill(ctx, args[1:]) } + if len(args) > 0 && args[0] == "warrant" { + return handActWarrantCommand(ctx, args[1:]) + } if len(args) > 0 && args[0] != "list" && !strings.HasPrefix(args[0], "-") { return errors.New("hand-act record --why --cause | hand-act drill --why " + "| hand-acts [--days N] [--json]") diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 48b17938..16241347 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -536,6 +536,11 @@ func (a *verbArguments) commandLine() ([]string, error) { argv = append(argv, "--condition", c) } return argv, nil + case "warranted": + if err := need("asker", "ask"); err != nil { + return nil, err + } + return []string{"hand-act", "warrant", "--asker", str("asker"), "--ask", str("ask")}, nil case "hand-acts": argv := []string{"hand-acts", "--json"} if d := str("days"); d != "" { @@ -935,6 +940,8 @@ func repairingCommand(argv []string) string { return "plans " + argv[1] case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset": return "broker consumer-reset" + case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "warrant": + return "" // the router's record of a person's answer, never a repair (novox/hq ADR 0274) case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill": return "hand-act drill" case argv[0] == "hand-act": diff --git a/cmd/mesh-controller/warranted.go b/cmd/mesh-controller/warranted.go new file mode 100644 index 00000000..9f6f918e --- /dev/null +++ b/cmd/mesh-controller/warranted.go @@ -0,0 +1,130 @@ +package main + +// A module's act on the operator's warrant, recorded in the hand-act log (novox/hq ADR 0274, ADR 0259 §6). +// +// mesh-controller hand-act warrant --asker --ask +// +// The verb `warranted` runs it. A module that asks the operator (an asker) acts on the warrant with its own grants; +// the controller's log is where a person's decisions are read back, so the module asks the controller to record +// it. **What is recorded is the router's word, never the caller's**: the controller reads the router's own record +// of that asker's ask — the bus lets only the router write it — and records who chose, through which channel, with +// which proofs, and which answer. The caller gives nothing but which ask: a word of its own, recorded first under +// the one id, would stand for every node's (the review of 2026-10-10). Recorded once per +// ask, under an id the ask decides, however many of the module's instances ask; an ask still open, ended without +// a choice, or another asker's is refused and nothing is written. + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "regexp" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/link" +) + +var askerModule = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +// warrantedID is the one entry an ask's warrant is recorded under. +func warrantedID(asker, ask string) string { return "warrant-" + asker + "-" + ask } + +// warrantedAct is the entry for an asker's act on the warrant the router recorded for its ask (state, w), or why +// none is written. +func warrantedAct(asker, ask, caller, state string, w *asks.Warrant) (link.HandAct, error) { + switch { + case !askerModule.MatchString(asker): + return link.HandAct{}, fmt.Errorf("%q is not a module's name", asker) + case asker == askerName: + return link.HandAct{}, errors.New("the controller records its own acts on a warrant as it performs them") + case !asks.UsableID(ask): + return link.HandAct{}, fmt.Errorf("%q is not an ask's id", ask) + case state == "" || w == nil: + return link.HandAct{}, fmt.Errorf("the router holds no closed record of %s's ask %s", asker, ask) + case state == "open": + return link.HandAct{}, fmt.Errorf("%s's ask %s is still open: nobody has answered it", asker, ask) + case w.Asker != asker || w.Ask != ask: + return link.HandAct{}, fmt.Errorf("the router's record is for %s's ask %s", w.Asker, w.Ask) + case w.Outcome != asks.OutcomeChosen || w.By == nil: + return link.HandAct{}, fmt.Errorf("%s's ask %s ended %s: no person chose, so there is no warrant to record", asker, ask, w.Outcome) + case w.AskDigest == "": + return link.HandAct{}, fmt.Errorf("the router's warrant for %s's ask %s names no ask digest", asker, ask) + } + return link.HandAct{ID: warrantedID(asker, ask), Verb: handActWarrant, Args: []string{fmt.Sprintf("the operator chose %s on %s's ask %s", w.Label, asker, ask)}, + Why: fmt.Sprintf("%s (ask %s of %s)", w.Says(), ask, asker), By: byWords(*w), Cause: conditions.CauseOperatorAnswer, + Via: viaWords(*w), Ask: ask, Proofs: w.Proofs, RequestedBy: asker + ", recorded at the word of " + caller, + Outcome: "chosen; what " + asker + " did with it is in its own record", At: w.At.UTC()}, nil +} + +// readRouterRecord reads the router's record of one asker's ask: its state and warrant, or "" when there is none. +// The controller's grant reaches the JetStream API whole (`$JS.API.>`), so it reads any asker's record. +func readRouterRecord(ctx context.Context, conn *nats.Conn, bucket, asker, ask string) (string, *asks.Warrant, error) { + reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+asker+"."+ask, nil) + if err != nil { + return "", nil, err + } + if status := reply.Header.Get("Status"); status != "" { + if status == "404" { + return "", nil, nil + } + return "", nil, fmt.Errorf("the router's record could not be read: %s %s", status, reply.Header.Get("Description")) + } + var rec struct { + State string `json:"state"` + Warrant *asks.Warrant `json:"warrant"` + } + if err := json.Unmarshal(reply.Data, &rec); err != nil { + return "", nil, fmt.Errorf("the router's record of %s's ask %s cannot be read: %w", asker, ask, err) + } + return rec.State, rec.Warrant, nil +} + +func handActWarrantCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("hand-act warrant", flag.ContinueOnError) + asker := set.String("asker", "", "the module that asked") + ask := set.String("ask", "", "its ask's id") + positionals, err := parseAround(set, args) + if err != nil { + return err + } + if *asker == "" || *ask == "" || len(positionals) > 0 { + return errors.New("hand-act warrant --asker --ask : what is recorded is the router's record, and nothing else") + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + bucket, err := asksRecords(ctx, open.inventory) + if err != nil { + return err + } + if bucket == "" { + return errors.New("no module declares the operator channel's records, so no warrant can be read") + } + return onTheBus(func(conn *nats.Conn) error { + state, w, err := readRouterRecord(ctx, conn, bucket, *asker, *ask) + if err != nil { + return err + } + act, err := warrantedAct(*asker, *ask, link.Caller(), state, w) + if err != nil { + return fmt.Errorf("%w. Nothing was recorded", err) + } + written, err := link.RecordHandActOnce(ctx, conn, act) + if err != nil { + return fmt.Errorf("the warrant could not be recorded: %w", err) + } + if !written { + fmt.Printf("already recorded as %s: %s\n", act.ID, act.Why) + return nil + } + fmt.Printf("recorded as %s: %s, through %s\n", act.ID, act.Why, act.Via) + return nil + }) +} diff --git a/cmd/mesh-controller/warranted_test.go b/cmd/mesh-controller/warranted_test.go new file mode 100644 index 00000000..b97ea128 --- /dev/null +++ b/cmd/mesh-controller/warranted_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/conditions" +) + +func chosenWarrant() *asks.Warrant { + return &asks.Warrant{Ask: "instr-1", Asker: "claude-code", Outcome: asks.OutcomeChosen, Option: "approve", + Label: "Approve", Level: asks.Approve, Channel: "telegram", Proofs: []string{"P1"}, + By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, + At: time.Date(2026, 10, 10, 4, 0, 0, 0, time.UTC), AskDigest: "sha256:ab"} +} + +// What is recorded of a module's act on a warrant is the router's word (novox/hq ADR 0274): who chose, how and +// with which proofs; the caller gives only what it did. Nothing is recorded without a person's choice. +func TestAWarrantIsRecordedFromTheRoutersRecordAlone(t *testing.T) { + act, err := warrantedAct("claude-code", "instr-1", "node-tools.shanks", "chosen", chosenWarrant()) + if err != nil { + t.Fatal(err) + } + if act.ID != "warrant-claude-code-instr-1" || act.Verb != handActWarrant || act.Cause != conditions.CauseOperatorAnswer || + act.By != "the operator, as telegram identity 42" || act.Ask != "instr-1" || !slices.Equal(act.Proofs, []string{"P1"}) || + !strings.Contains(act.Why, "the operator, via telegram (user id verified), chose Approve") || + !strings.Contains(act.RequestedBy, "node-tools.shanks") || + !slices.Equal(act.Args, []string{"the operator chose Approve on claude-code's ask instr-1"}) { + t.Fatalf("recorded as %+v", act) + } + for name, c := range map[string]struct { + asker, ask, state string + w func() *asks.Warrant + }{ + "no record": {"claude-code", "instr-1", "", func() *asks.Warrant { return nil }}, + "still open": {"claude-code", "instr-1", "open", chosenWarrant}, + "another asker's": {"messenger", "instr-1", "chosen", chosenWarrant}, + "another ask's": {"claude-code", "instr-2", "chosen", chosenWarrant}, + "the controller's": {"mesh-controller", "instr-1", "chosen", chosenWarrant}, + "not a module": {"Claude Code", "instr-1", "chosen", chosenWarrant}, + "expired": {"claude-code", "instr-1", "expired", func() *asks.Warrant { + w := chosenWarrant() + w.Outcome, w.By = asks.OutcomeExpired, nil + return w + }}, + "no digest": {"claude-code", "instr-1", "chosen", func() *asks.Warrant { + w := chosenWarrant() + w.AskDigest = "" + return w + }}, + } { + if _, err := warrantedAct(c.asker, c.ask, "x", c.state, c.w()); err == nil { + t.Errorf("%s: recorded", name) + } + } +} + +func TestTheWarrantedVerbRunsTheWarrantLineWithoutAWhy(t *testing.T) { + if _, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1", "what": "a word of the caller's"}); err == nil { + t.Error("the caller's own words were taken into the record") + } + argv, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1"}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(argv, []string{"hand-act", "warrant", "--asker", "claude-code", "--ask", "instr-1"}) { + t.Fatalf("%v", argv) + } + if repairingCommand(argv) != "" { + t.Error("recording a person's answer is taken for a repair") + } + if terminalOnly(argv) != nil { + t.Error("the verb is kept for the terminal") + } + if _, err := argvFor("warranted", map[string]any{"asker": "claude-code"}); err == nil { + t.Error("a call naming no ask was taken") + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index a052ab98..c85c952a 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -338,6 +338,15 @@ var ControllerVerbs = []Verb{ "why": "what the drill tests", "condition": "the key of the condition the drill is meant to raise, if any (optional)", }, []string{"what", "why"})}, + {Name: "warranted", Description: "Record in the hand-act log what a module did on the operator's warrant (novox/hq " + + "ADR 0274, ADR 0259): who chose, through which channel, with which proofs and which answer are read from the " + + "router's own record of that module's ask, never from the caller; recorded once per ask however often it is " + + "asked; the caller names the ask and says nothing else. 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, e.g. claude-code", + "ask": "its ask's id", + }, []string{"asker", "ask"})}, {Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " + "recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.", Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)}, diff --git a/internal/link/handacts.go b/internal/link/handacts.go index dd42f6e0..92de55c7 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -122,6 +122,33 @@ func RecordHandAct(ctx context.Context, conn *nats.Conn, act HandAct) (HandAct, return act, err } +// RecordHandActOnce writes one entry under the id it carries, only where none is: an act recorded once however +// often it is asked, such as a module's act on a warrant, asked by each of its instances (novox/hq ADR 0274). It +// answers false, with no error, when the entry was already there. +func RecordHandActOnce(ctx context.Context, conn *nats.Conn, act HandAct) (bool, error) { + if act.ID == "" || strings.TrimSpace(act.Why) == "" { + return false, errors.New("an act recorded once carries its id and why") + } + if act.At.IsZero() { + act.At = time.Now().UTC() + } + kv, err := handActs(ctx, conn) + if err != nil { + return false, err + } + body, err := json.Marshal(act) + if err != nil { + return false, err + } + if _, err := kv.Create(ctx, act.ID, body); err != nil { + if errors.Is(err, jetstream.ErrKeyExists) { + return false, nil + } + return false, err + } + return true, nil +} + // HandActs is every entry since a moment, oldest first. func HandActs(ctx context.Context, conn *nats.Conn, since time.Time) ([]HandAct, error) { kv, err := handActs(ctx, conn) diff --git a/internal/link/handacts_test.go b/internal/link/handacts_test.go index 059e5527..e670fe7c 100644 --- a/internal/link/handacts_test.go +++ b/internal/link/handacts_test.go @@ -57,3 +57,30 @@ func TestNatsAnActByHandIsKeptWithWhyAndARepeatIsFound(t *testing.T) { t.Fatalf("%q", acts[2].ID) } } + +// An act on a warrant asked by each of a module's instances is recorded once (novox/hq ADR 0274). +func TestNatsAnActRecordedOnceIsWrittenOnce(t *testing.T) { + js := aBus(t) + api, err := jetstream.New(js.Conn()) + if err != nil { + t.Fatal(err) + } + _ = api.DeleteKeyValue(t.Context(), broker.HandActsBucket) + if err := js.EnsureControllerBuckets(); err != nil { + t.Fatal(err) + } + act := HandAct{ID: "warrant-claude-code-instr-1", Verb: "warrant", Why: "the operator chose Approve", By: "the operator"} + if _, err := RecordHandActOnce(t.Context(), js.Conn(), HandAct{Verb: "warrant", Why: "x"}); err == nil { + t.Fatal("an act without its id was written") + } + for i, want := range []bool{true, false, false} { + written, err := RecordHandActOnce(t.Context(), js.Conn(), act) + if err != nil || written != want { + t.Fatalf("ask %d: written %v, %v", i, written, err) + } + } + acts, err := HandActs(t.Context(), js.Conn(), time.Now().Add(-time.Hour)) + if err != nil || len(acts) != 1 || acts[0].ID != act.ID { + t.Fatalf("%v %+v", err, acts) + } +} diff --git a/module.json b/module.json index e0af4cbb..b3cef223 100644 --- a/module.json +++ b/module.json @@ -63,6 +63,7 @@ "resume", "hand-act", "drill", + "warranted", "hand-acts", "durations", "conditions",