Merge pull request 'Record a module's act on the operator's warrant from the router's own record (hq ADR 0274)' (#194) from feat/warranted-hand-act into main
This commit was merged in pull request #194.
This commit is contained in:
@@ -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 <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " +
|
||||
"| hand-acts [--days N] [--json]")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 <module> --ask <id>
|
||||
//
|
||||
// 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 <module> --ask <id>: 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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"resume",
|
||||
"hand-act",
|
||||
"drill",
|
||||
"warranted",
|
||||
"hand-acts",
|
||||
"durations",
|
||||
"conditions",
|
||||
|
||||
Reference in New Issue
Block a user