From 05b465e7775f0559b3f3a57b053a6e11e7625072 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:07:13 +0200 Subject: [PATCH] Add drill: an ask the operator starts at the controller's terminal, whose approval performs nothing and is recorded (hq ADR 0259) The live acceptance needs an approval the operator can ask for at will and that changes nothing. A drill is asked like any condition's ask, bound to its own act, claimed once on its warrant and recorded as a warrant hand-act with who answered, through which channel and the proofs. A verb's process may not start one, so no agent asks the operator a question they did not start. --- cmd/mesh-controller/asker.go | 16 +++-- cmd/mesh-controller/drill.go | 110 ++++++++++++++++++++++++++++++ cmd/mesh-controller/drill_test.go | 60 ++++++++++++++++ cmd/mesh-controller/main.go | 2 + 4 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 cmd/mesh-controller/drill.go create mode 100644 cmd/mesh-controller/drill_test.go diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index 0c8f977d..3c21e574 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -84,6 +84,9 @@ type asked struct { // Acted is what the controller did on the warrant: empty before it did anything, "acting" while it acts, // then "done", "failed: …" or "nothing: …". Anything but empty is never acted on again. Acted string `json:"acted,omitempty"` + // Drill is an ask started at the controller's terminal (drill.go): about no condition, its answers + // perform nothing, and the reconciling of conditions leaves it alone. + Drill bool `json:"drill,omitempty"` } // askedStore keeps the asks (broker.AskedBucket). @@ -202,7 +205,7 @@ func (a *asker) reconcile(ctx context.Context) error { } byCondition := map[string]asked{} for _, r := range all { - if r.State == askOpen { + if r.State == askOpen && !r.Drill { if prior, held := byCondition[r.Condition]; !held || r.Opened.After(prior.Opened) { byCondition[r.Condition] = r } @@ -226,7 +229,7 @@ func (a *asker) reconcile(ctx context.Context) error { } byCondition = map[string]asked{} for _, r := range all { - if r.State == askOpen { + if r.State == askOpen && !r.Drill { byCondition[r.Condition] = r } } @@ -490,7 +493,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { if err != nil { return err } - stillOpen := false + stillOpen := r.Drill // a drill is about no condition for _, c := range open { stillOpen = stillOpen || c.Key == r.Condition } @@ -521,9 +524,12 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { args["why"] = why } var acted error - if act.Arguments["silence"] != "" { + switch { + case r.Drill && act.Verb == drillVerb: + // A drill's answer performs nothing: it is recorded below as the operator's decision. + case act.Arguments["silence"] != "": acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why) - } else { + default: acted = a.call(ctx, act, args) } r.Ended = a.now() diff --git a/cmd/mesh-controller/drill.go b/cmd/mesh-controller/drill.go new file mode 100644 index 00000000..1db79cf8 --- /dev/null +++ b/cmd/mesh-controller/drill.go @@ -0,0 +1,110 @@ +package main + +// The drill of the operator's answers (novox/hq ADR 0259, the live acceptance after rollout): an ask the +// operator starts at the controller's terminal, answered on the phone, whose approval changes nothing and is +// recorded as a person's decision like any other. +// +// mesh-controller drill [--for 15m] +// +// It asks with two answers — Approve (an approval, so only a channel that proves who answered carries it) and +// Decline (an acknowledgement) — each bound to the drill's own act. The serving controller acts on the warrant +// as on any other: it claims the ask once, checks the act is the one bound, performs nothing, and records the +// hand-act `warrant` with who answered, through which channel, and the proofs. `hand-acts` then shows it. +// +// **The terminal's alone**: a command a verb runs (MESH_VERB set) is refused, so no agent starts a drill — a +// drill is a question the operator expects, and one an agent could start would teach them to approve what they +// did not ask for. + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "time" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/conditions" +) + +// drillVerb is the act a drill's answers bind: nothing is called. +const drillVerb = "drill" + +// drillActions are the drill's two answers. +func drillActions() []conditions.Action { + return []conditions.Action{ + {Label: "Approve", Verb: drillVerb, Level: conditions.LevelApprove, Arguments: map[string]string{"drill": "approve"}}, + {Label: "Decline", Verb: drillVerb, Level: conditions.LevelAcknowledge, Arguments: map[string]string{"drill": "decline"}}, + } +} + +// drillAsk is the drill's ask, as the router is sent it. +func drillAsk(id string, now time.Time, lasts time.Duration) (asks.Ask, map[string]int) { + q := asks.Ask{ID: id, Headline: "Drill: approve this test question?", Who: asks.Operator, + Explanation: "Needs you: approve or decline. You started this drill at the controller's terminal. Approving " + + "changes nothing on the mesh; it is recorded as your decision, so you can check the record.", + OnExpiry: "nothing is done", Expires: now.Add(lasts), About: "drill." + id} + options := map[string]int{} + for i, act := range drillActions() { + binds, _ := asks.ActDigest(boundAct(act)) + oid := optionID(act.Label) + options[oid] = i + q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesDrill(act), Level: asks.Level(act.Level), + Binds: binds}) + } + return q, options +} + +func doesDrill(act conditions.Action) string { + if act.Arguments["drill"] == "approve" { + return "nothing changes; your approval is recorded" + } + return "nothing changes; your answer is recorded" +} + +func drillCommand(ctx context.Context, args []string) error { + if os.Getenv(verbVar) != "" { + return errors.New("drill is the controller's terminal's alone: a verb may not start one, so no agent asks " + + "the operator a question they did not start (novox/hq ADR 0259)") + } + set := flag.NewFlagSet("drill", flag.ContinueOnError) + lasts := set.Duration("for", 15*time.Minute, "how long the question waits for an answer") + if err := set.Parse(args); err != nil { + return err + } + if *lasts < time.Minute || *lasts > askApproveFor { + return fmt.Errorf("a drill waits between a minute and %s", askApproveFor) + } + js, err := aBus() + if err != nil { + return err + } + defer js.Close() + now := time.Now() + id := newAskID() + q, options := drillAsk(id, now, *lasts) + if err := q.Check(now); err != nil { + return err + } + store := busAsked{conn: js.Conn()} + // Kept before it is published, as the asker keeps every ask, so a warrant always finds it. + if err := store.Put(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: drillActions(), Options: options, + State: askOpen, Opened: now, Drill: true}); err != nil { + return fmt.Errorf("the drill could not be kept in the controller's asks: %w", err) + } + body, err := json.Marshal(q) + if err != nil { + return err + } + if _, err := js.Context().Publish(asks.AskSubject(askerName), body, nats.MsgId("ask."+id), nats.Context(ctx)); err != nil { + return fmt.Errorf("the drill could not be asked: %w", err) + } + fmt.Printf("drill %s asked: answer it on your phone before %s. Then `mesh-controller hand-acts` shows the "+ + "answer as a warrant, with who answered, through which channel and the proofs; nothing else changes.\n", + id, q.Expires.Local().Format("15:04")) + return nil +} diff --git a/cmd/mesh-controller/drill_test.go b/cmd/mesh-controller/drill_test.go new file mode 100644 index 00000000..074abd4b --- /dev/null +++ b/cmd/mesh-controller/drill_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// A drill (the live acceptance of novox/hq ADR 0259): its approval is a warrant like any other — claimed once, +// its act checked against what the option bound, recorded as the operator's decision with who, how and the +// proofs — and it performs nothing. The reconciling of conditions leaves it open. +func TestADrillsApprovalIsRecordedAndPerformsNothing(t *testing.T) { + r := newAskerRig(t) + q, options := drillAsk("cdrill", r.now, askerDrillFor) + if err := q.Check(r.now); err != nil { + t.Fatalf("the drill's ask is refused: %v", err) + } + r.store["cdrill"] = asked{ID: "cdrill", Condition: q.About, Ask: q, Actions: drillActions(), Options: options, + State: askOpen, Opened: r.now, Drill: true} + if err := r.a.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + if got := r.store["cdrill"]; got.State != askOpen { + t.Fatalf("the reconciling of conditions ended the drill: %+v", got) + } + approve, _ := q.Option("approve") + w := asks.Warrant{Ask: "cdrill", Asker: "mesh-controller", Outcome: asks.OutcomeChosen, Option: approve.ID, + Label: approve.Label, Level: approve.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now, + AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} + answerWith(t, r, w) + answerWith(t, r, w) // heard again + if len(r.called)+len(r.silenced) != 0 { + t.Errorf("a drill performed something: %v %v", r.called, r.silenced) + } + if len(r.acts) != 1 { + t.Fatalf("hand-acts %+v", r.acts) + } + act := r.acts[0] + if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "cdrill" || + strings.Join(act.Args, " ") != "drill drill=approve" || act.Outcome != "done" || strings.Join(act.Proofs, ",") != "P1" { + t.Errorf("the drill's record: %+v", act) + } + if !personsDecision(act) { + t.Error("a drill's answer counts as a repair") + } +} + +// Only the terminal starts a drill: a verb's process is refused before anything is asked. +func TestADrillIsTheTerminalsAlone(t *testing.T) { + t.Setenv(verbVar, "mesh-controller.command") + if err := drillCommand(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("a verb started a drill: %v", err) + } +} + +// askerDrillFor is how long the test's drill waits. +const askerDrillFor = 15 * time.Minute diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 2c03372a..7ea824ba 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -78,6 +78,8 @@ func run() error { return rotateCommand(ctx, args[1:]) case "ask": return askCommand(ctx, args[1:]) + case "drill": + return drillCommand(ctx, args[1:]) case "builds": return buildsCommand(ctx, args[1:]) // The build queue, controlled by hand (novox/hq ADR 0219).