diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 968a7c8..004a152 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -581,6 +581,8 @@ func healthChecks(t *testing.T) { {"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, {"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false}, {"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false}, + // ADR 0565 clauses 10, 11 (issue 1953 L5-S4): mesh-hand-acts reads the controller's log beside its own. + {"mesh-hand-acts", ControllerHandActs, true}, {"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false}, } { granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced) @@ -595,6 +597,7 @@ func healthChecks(t *testing.T) { {Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}}, {Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}}, {Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}}, + {Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}}, {Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}, Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}}, {Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools", @@ -606,6 +609,7 @@ func healthChecks(t *testing.T) { } may := map[string][]string{ "a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"}, + "a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"}, WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"}, WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER", @@ -617,7 +621,9 @@ func healthChecks(t *testing.T) { // The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2). "a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"}, "a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch"}, - "a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch"}, + // Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10). + "a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", + "$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"}, // The watchman never deletes or changes the controller's consumer (clause 5). // Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's // (the lead, 2026-10-29). diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index 266eaad..bae9341 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -5,6 +5,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -84,11 +85,11 @@ const VaultBusAccounts = "mesh-vault.bus-accounts" // reads the catalogue's placement alone from then (ADR 0549 §5, issue 774). // // The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the -// watchman read the lease; the keeper alone the controller's conditions, mesh-healers alone its hand-act log; and nobody -// the healers' `would` (health_grants.go). +// watchman read the lease; the keeper alone the controller's conditions; mesh-healers and mesh-hand-acts its hand-act log +// (ADR 0565 clauses 10, 11: every reader of acts reads both logs); and nobody the healers' `would` (health_grants.go). var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule}, - ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule}, HealersWould: {}} + ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {}} // ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state // that names none. diff --git a/go/catalogue/control_records_seats.go b/go/catalogue/control_records_seats.go new file mode 100644 index 0000000..5d30fd0 --- /dev/null +++ b/go/catalogue/control_records_seats.go @@ -0,0 +1,64 @@ +package catalogue + +// **The control records, out of the controller** (novox/hq ADR 0565 clause 1, issue 1953): two seats at mesh scope, +// each held once, and the node seat's verb `packages`. +// +// - mesh-wants, held by the assigner (mesh-delivery-assigner): a person's upgrade policy and the bus step (clauses 7, +// 8, 9); +// - mesh-hand-acts, held by the module mesh-hand-acts: the hand-act log and the warrants recorded (clause 10). +// +// Their events are their holders' to say on the seat; their layouts are go/handacts' and go/delivery's. + +// The two seats. +const ( + WantsSeat = "mesh-wants" + HandActsSeat = "mesh-hand-acts" +) + +// 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, every module whose policy differs from the one the " + + "controller writes beside its compositions.", + 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\": every module whose policy at the assigner differs from the controller's", + }, 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/engine_seat.go b/go/catalogue/engine_seat.go index b3b695b..b924506 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -43,6 +43,12 @@ func EngineVerbs() []Verb { "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")}, } } diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 3082ce8..1b70e38 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -287,6 +287,10 @@ var defaultSeats = append([]Seat{ {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()}, // 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. diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index bd22358..168056e 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -71,9 +71,12 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { {WatchdogsSeat, "signals,stuck,presence,dead-letters,resync", ""}, {SelfCheckSeat, "doctor,probes", "checked"}, {HealersSeat, "healers,heal,drill", "healer-acted"}, + // And ADR 0565 clause 1's two. + {WantsSeat, "upgrade,bus", "policy-changed"}, + {HandActsSeat, "hand-act,hand-acts,warranted", "act-recorded"}, } { s, ok := SeatNamed(c.seat) - if !ok || s.Scope != ScopeMesh || s.Replicated || s.Delivers != "" || s.Decision != "novox/hq ADR 0564" || + if !ok || s.Scope != ScopeMesh || s.Replicated || s.Delivers != "" || !strings.HasPrefix(s.Decision, "novox/hq ADR 056") || strings.Join(VerbNames(s.Serves), ",") != c.verbs || strings.Join(s.Emits, ",") != c.emits { t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s) } @@ -88,10 +91,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { verbs = append(verbs, v.Name) } // acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line). - if props, _ := engine.Serves[len(engine.Serves)-1].Input["properties"].(map[string]any); props["id"] == nil { - t.Errorf("acts takes no id: %v", engine.Serves[len(engine.Serves)-1].Input) + if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil { + t.Errorf("acts takes no id: %v", engine.Serves[5].Input) } - if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts" || + if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages" || strings.Join(engine.Emits, ",") != "asked,done,refused" { t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits) } @@ -114,9 +117,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // mesh-build-machine row goes, when no registered manifest claims it. // Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1); - // fifty-three with the four health seats (novox/hq ADR 0564 clause 1). - if len(Seats()) != 53 { - t.Errorf("the mesh defines %d seats rather than 53; the set is closed, so a change here is "+ + // fifty-three with the four health seats (novox/hq ADR 0564 clause 1); fifty-five with mesh-wants and mesh-hand-acts + // (novox/hq ADR 0565 clause 1). + if len(Seats()) != 55 { + t.Errorf("the mesh defines %d seats rather than 55; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } diff --git a/go/handacts/handacts.go b/go/handacts/handacts.go new file mode 100644 index 0000000..4ce5937 --- /dev/null +++ b/go/handacts/handacts.go @@ -0,0 +1,158 @@ +// 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 and, while no holder answers, the controller's verb until the controller hands its lease away, saying +// which log took the act. +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" + // ControllerActs is the controller's log as a reader names it, read until its lease ends. + ControllerActs = "mesh-controller.hand-acts" +) + +// 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 or LogController); never written. + Log string `json:"log,omitempty"` +} + +// The logs an act may be held in. +const ( + LogSeat = Seat + LogController = "mesh-controller" +) + +// 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, and while the seat has no +// holder that answers, through the controller's verb until the controller hands its lease away (the release's order +// gives no seat's holder before its callers; 00-plan.md, "Missing" 1). +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) + // LeaseAway says the controller's lease is handed away: then nothing falls back to it. Nil is never handed away. + LeaseAway func() bool +} + +// 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 + } + } + controller := map[string]any{"what": q.What, "why": q.Why, "cause": q.Cause} + if q.Condition != "" { + controller["condition"] = q.Condition + } + if q.Cause == "" { + controller["cause"] = q.Verb + } + return r.either("hand-act", body, "hand-act", controller) +} + +// 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.either("warranted", body, "warranted", body) +} + +func (r Recorder) either(verb string, body any, controllerVerb string, controllerBody 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.%s refused: %w", Seat, verb, err) + } + if r.LeaseAway != nil && r.LeaseAway() { + return Taken{}, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing "+ + "was recorded (%w)", Seat, err) + } + answer, cerr := r.Ask("seat:mesh-controller."+controllerVerb, controllerBody) + if cerr != nil { + return Taken{}, fmt.Errorf("%s has no holder that answers (%v), and the controller's %s refused too: %w", Seat, err, + controllerVerb, cerr) + } + return Taken{Log: LogController, Answer: answer}, nil +} diff --git a/go/handacts/handacts_test.go b/go/handacts/handacts_test.go new file mode 100644 index 0000000..fe56282 --- /dev/null +++ b/go/handacts/handacts_test.go @@ -0,0 +1,74 @@ +package handacts + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" +) + +// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4): an act reads the controller's entries unchanged, and a recorder +// records through the seat, through the controller's verb only while the seat has no holder and the lease is the +// controller's, saying which log took it, and refuses to record nowhere. +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 + away bool + log string + refused string + asked string + warrants bool + }{ + {name: "the seat answers", log: LogSeat, asked: "seat:mesh-hand-acts.hand-act"}, + {name: "no holder yet", seat: noResponders, log: LogController, asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"}, + {name: "no holder, the lease handed away", seat: noResponders, away: true, refused: "handed its lease away", + asked: "seat:mesh-hand-acts.hand-act"}, + {name: "the seat refuses", seat: errors.New("why is required"), refused: "refused", asked: "seat:mesh-hand-acts.hand-act"}, + {name: "neither answers", seat: noResponders, ctrl: noResponders, refused: "refused too", + asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"}, + {name: "a warrant without a holder", seat: noResponders, warrants: true, log: LogController, + asked: "seat:mesh-hand-acts.warranted,seat:mesh-controller.warranted"}, + } { + var asked []string + r := Recorder{LeaseAway: func() bool { return c.away }, 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") + } +}