From ed491e23fc966506624be3c7da04873b87f34cda Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 05:57:24 +0400 Subject: [PATCH] mavend: group the recall fields into one wiring struct (V-433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review comment asked for basic DI. The answer is the idiom voice.go already had for capabilities — a cohesive *Wiring struct — applied to a group that is not a capability toggle, plus the decision written down so it is a rule and not a habit. recallWiring holds the embedder, the vector store, the personal boundary and the two numbers that gate an answer. They sat in three places on reactiveHandler, with the gate numbers a hundred lines from the store they gate. Its zero value means no recall, so it is a value, not a pointer like the optional-capability groups. dataStore stays out of it. patterns.go, ecosystem_acts.go and confirm.go use it, so it is not part of this cluster. docs/handler-wiring.md records the choice, rejects a container or a wire-style generator outright, defers narrow per-handler interfaces to the package split that would justify them, and states the constraint the task named: a wiring change does not ride a feature PR. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions_fact.go | 6 +-- cmd/mavend/actions_note.go | 6 +-- cmd/mavend/actions_query.go | 14 +++---- cmd/mavend/clarify_test.go | 2 +- cmd/mavend/fact_subject_test.go | 3 +- cmd/mavend/factgate_test.go | 9 ++--- cmd/mavend/feeds_test.go | 12 +++--- cmd/mavend/personalboundary_test.go | 4 +- cmd/mavend/query_recall_test.go | 12 +++--- cmd/mavend/reactive_notes_test.go | 6 +-- cmd/mavend/recall.go | 36 ++++++++++++++++- cmd/mavend/simulator_test.go | 16 ++++---- cmd/mavend/voice.go | 29 +++++--------- cmd/mavend/voicewire.go | 40 ++++++++++--------- docs/handler-wiring.md | 62 +++++++++++++++++++++++++++++ 15 files changed, 173 insertions(+), 84 deletions(-) create mode 100644 docs/handler-wiring.md diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index 5645282..365fd5c 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -73,11 +73,11 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s // hears; storing the utterance meant recall answered with his own sentence // rather than the value. The utterance stays alongside as provenance — // readable on /trace, never the answer and never embedded. - if h.memStore != nil { + if h.recall.memStore != nil { text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value) - if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil { + if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil { log.Printf("voice: embed fact for memory: %v", err) - } else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ + } else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ "source": "voice", "type": "fact", "text": text, diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index c7c20a5..8648fd7 100644 --- a/cmd/mavend/actions_note.go +++ b/cmd/mavend/actions_note.go @@ -20,7 +20,7 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s // embed the note text with the same model the classifier uses, persist // via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not // facts — no predicate reads it (spec's two-memory split). - vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance) + vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance) if err != nil { log.Printf("voice: embed note: %v", err) return "не получилось сохранить заметку." @@ -33,8 +33,8 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s } // Insert into long-term memory (best-effort, must not fail the note write). // text/ts in the meta make a Search hit self-describing (see bestRecall). - if h.memStore != nil { - if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{ + if h.recall.memStore != nil { + if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{ "source": "voice", "type": "note", "text": dec.Utterance, diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 9629303..2f88443 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -406,7 +406,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin // sources below both need, run once, in the position it always ran in. It // only claims the turn when the embedder fails. func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) { - vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance) + vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) return "не получилось найти ответ.", true @@ -426,15 +426,15 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, // gate, was the bug — the set of questions Maven answers is unchanged, only // which memory gets to answer them. func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) { - if h.memStore == nil { + if h.recall.memStore == nil { return "", false } - hits, herr := h.memStore.Search(ctx, t.vec, 3) + hits, herr := h.recall.memStore.Search(ctx, t.vec, 3) if herr != nil { log.Printf("voice: memory search: %v", herr) return "", false } - hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin) + hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin) if !ok { return "", false } @@ -479,7 +479,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, for i, n := range notes { noteScores[i] = n.Score } - if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { + if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) { return "", false } // Same topic veto as queryMemory above: the best note must be about what @@ -766,8 +766,8 @@ func isPersonalQuery(utterance string) bool { // computed. Same shape as the cascade: the better test leads, the offline one // always answers. func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool { - h.boundary.load(ctx, h.embedder) - if personal, world, ok := h.boundary.score(t.vec); ok { + h.recall.boundary.load(ctx, h.recall.embedder) + if personal, world, ok := h.recall.boundary.score(t.vec); ok { if personal > world { log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world) return true diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index db1f140..8491e5c 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -299,7 +299,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) { ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "")) h, _, now := newClarifyHandler(t) emb := router.NewHashEmbedder(1024) - h.embedder = emb + h.recall.embedder = emb h.router = buildRouter(emb, h.matcher, 0.55, nil) if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { diff --git a/cmd/mavend/fact_subject_test.go b/cmd/mavend/fact_subject_test.go index e386b9f..885f067 100644 --- a/cmd/mavend/fact_subject_test.go +++ b/cmd/mavend/fact_subject_test.go @@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) { h := &reactiveHandler{ api: api, - embedder: emb, + recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, router: rtr, replier: voice.NewStubReplier(), now: func() time.Time { return now }, - memStore: memory.NewInMemoryStore(), dataStore: st, } diff --git a/cmd/mavend/factgate_test.go b/cmd/mavend/factgate_test.go index 5dc205f..27b41b5 100644 --- a/cmd/mavend/factgate_test.go +++ b/cmd/mavend/factgate_test.go @@ -19,11 +19,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core emb := router.NewHashEmbedder(1024) h := &reactiveHandler{ api: api, - embedder: emb, + recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil), replier: voice.NewStubReplier(), now: func() time.Time { return now }, - memStore: memory.NewInMemoryStore(), dataStore: st, } return h, api @@ -44,7 +43,7 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) { if _, err := api.LatestFact(ctx, "go_version"); err == nil { t.Fatal("a question was stored as a fact about him") } - hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3) + hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3) if err != nil { t.Fatalf("memory search: %v", err) } @@ -81,7 +80,7 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) { // #493: what recall reads back is the fact, not the sentence he said. // queryMemory returns a fact's text verbatim, so the utterance sitting here // meant "запиши что я пил воду" was the answer to "когда я пил воду?". - hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3) + hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3) if err != nil { t.Fatalf("memory search: %v", err) } @@ -117,7 +116,7 @@ func TestFactConfidence(t *testing.T) { func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 { t.Helper() - vec, err := router.EmbedQuery(context.Background(), h.embedder, text) + vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text) if err != nil { t.Fatalf("embed %q: %v", text, err) } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 0759c50..4da73f6 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa } } return &reactiveHandler{ - api: ipc.NewStoreAPI(st), - replier: voice.NewStubReplier(), - phraser: phraser.NewStub(), - now: func() time.Time { return now }, - feedsOn: feedsOn, - embedder: nil, + api: ipc.NewStoreAPI(st), + replier: voice.NewStubReplier(), + phraser: phraser.NewStub(), + now: func() time.Time { return now }, + feedsOn: feedsOn, + recall: recallWiring{embedder: nil}, } } diff --git a/cmd/mavend/personalboundary_test.go b/cmd/mavend/personalboundary_test.go index 70de0fe..65c34ca 100644 --- a/cmd/mavend/personalboundary_test.go +++ b/cmd/mavend/personalboundary_test.go @@ -70,7 +70,7 @@ func TestONNXPersonalBoundary(t *testing.T) { {"how do i boil an egg", false}, } - h := &reactiveHandler{embedder: emb} + h := &reactiveHandler{recall: recallWiring{embedder: emb}} ctx := context.Background() wrong := 0 for _, c := range cases { @@ -80,7 +80,7 @@ func TestONNXPersonalBoundary(t *testing.T) { } turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec} got := h.isPersonalTurn(ctx, turn) - p, w, ok := h.boundary.score(vec) + p, w, ok := h.recall.boundary.score(vec) if !ok { t.Fatal("seeds did not load with a working embedder") } diff --git a/cmd/mavend/query_recall_test.go b/cmd/mavend/query_recall_test.go index b97c436..16b624e 100644 --- a/cmd/mavend/query_recall_test.go +++ b/cmd/mavend/query_recall_test.go @@ -85,15 +85,17 @@ func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reac phr := &recordingPhraser{Stub: phraser.NewStub()} h := &reactiveHandler{ - api: ipc.NewStoreAPI(st), - embedder: emb, + api: ipc.NewStoreAPI(st), + recall: recallWiring{ + embedder: emb, + memStore: mem, + minScore: 0.55, + minMargin: 0.008, + }, replier: voice.NewStubReplier(), phraser: phr, now: func() time.Time { return now }, - memStore: mem, dataStore: st, - queryMinScore: 0.55, - queryMinMargin: 0.008, weatherProvider: nil, } return h, phr diff --git a/cmd/mavend/reactive_notes_test.go b/cmd/mavend/reactive_notes_test.go index b53512c..22bedc3 100644 --- a/cmd/mavend/reactive_notes_test.go +++ b/cmd/mavend/reactive_notes_test.go @@ -26,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) { h := &reactiveHandler{ api: api, - embedder: emb, + recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, router: rtr, replier: voice.NewStubReplier(), now: func() time.Time { return now }, - memStore: memory.NewInMemoryStore(), dataStore: st, } @@ -101,11 +100,10 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) { matcher := tool.NewMatcher(api) h := &reactiveHandler{ api: api, - embedder: emb, + recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, router: buildRouter(emb, matcher, 0.55, nil), replier: voice.NewStubReplier(), now: func() time.Time { return now }, - memStore: memory.NewInMemoryStore(), dataStore: st, } diff --git a/cmd/mavend/recall.go b/cmd/mavend/recall.go index d06aeb3..d93190c 100644 --- a/cmd/mavend/recall.go +++ b/cmd/mavend/recall.go @@ -1,6 +1,9 @@ package main -import "github.com/kami/maven/internal/memory" +import ( + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" +) // bestRecall is the read side of the long-term memory store: the top hit when // it clears the confidence gate. The index holds BOTH notes and facts, and @@ -23,3 +26,34 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re } return results[0], true } + +// recallWiring — the recall subsystem's dependencies, held as one group on +// reactiveHandler (Vikunja #433). It is the worked example for the wiring +// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty +// loose ones, so a handler names what it needs and the package can be split +// later without exporting the whole struct. +// +// The zero value is usable and means "no recall": no embedder, no vector +// store, and a gate that is never consulted because nothing is ever searched. +type recallWiring struct { + // embedder — reused for note write/query (same model as the classifier). + embedder router.Embedder + + // memStore — the vector index over notes and facts. + memStore memory.Store + + // boundary — the embedded seed sets behind the personal boundary + // (personalboundary.go). Zero value is usable and loads on first query; + // with no embedder it never loads and the boundary uses personalMarkers. + boundary personalBoundary + + // minScore — the note-recall confidence gate. Top cosine below this ⇒ + // "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, + // not load-bearing math (same posture as the presence thresholds). Set by + // wireVoice from VoiceConfig; default 0.55. + minScore float64 + + // minMargin — the second half of that gate: how far the top hit must beat + // the runner-up. 0 ⇒ margin off. + minMargin float64 +} diff --git a/cmd/mavend/simulator_test.go b/cmd/mavend/simulator_test.go index bdedaf6..378ba62 100644 --- a/cmd/mavend/simulator_test.go +++ b/cmd/mavend/simulator_test.go @@ -428,20 +428,22 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld { rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted)) w.handler = &reactiveHandler{ - stt: simTranscriber{}, - tts: simSynthesizer{}, - router: rtr, - embedder: emb, + stt: simTranscriber{}, + tts: simSynthesizer{}, + router: rtr, + recall: recallWiring{ + embedder: emb, + memStore: st.VectorMemory(), + minScore: config.DefaultQueryMinScore, + minMargin: config.DefaultQueryMinMargin, + }, api: api, matcher: matcher, tools: tool.NewExecutor(api, 5*time.Second), phraser: phraser.NewStub(), replier: newLLMReplier(scripted, nil), now: clock.Now, - memStore: st.VectorMemory(), dataStore: st, - queryMinScore: config.DefaultQueryMinScore, - queryMinMargin: config.DefaultQueryMinMargin, timeParser: router.StubDateTimeParser{}, dialogueSessions: dialogue.NewSessionStore(time.Hour), clarifyStore: dialogue.NewClarifyStore(time.Hour), diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index e7f4764..8bbafd4 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -55,7 +55,6 @@ import ( "github.com/kami/maven/internal/crawl" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" - "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" @@ -72,14 +71,16 @@ import ( // safe (the wired stt/tts/router/api all are); called from per-conn // goroutines on the voice.Server. type reactiveHandler struct { - stt stt.Transcriber - tts tts.Synthesizer - router *router.Router - embedder router.Embedder // reused for note write/query (same model as the classifier) - // boundary — the embedded seed sets behind the personal boundary - // (personalboundary.go). Zero value is usable and loads on first query; - // with no embedder it never loads and the boundary uses personalMarkers. - boundary personalBoundary + stt stt.Transcriber + tts tts.Synthesizer + router *router.Router + + // recall — the note-and-fact recall subsystem: the embedder, the vector + // store it writes into, the personal boundary, and the two numbers that + // gate an answer. Grouped rather than spread across the handler because a + // handler that recalls needs all five and a handler that does not needs + // none of them (Vikunja #433, docs/handler-wiring.md). + recall recallWiring // api — the CoreAPI the handler reads and writes through. Wired with the // bare store adapter and UPGRADED by main once the daemonAPI exists; see // upgradeAPI. @@ -123,18 +124,8 @@ type reactiveHandler struct { weatherProvider weather.Provider weatherLocation string // default location for weather queries - memStore memory.Store dataStore *store.Store // direct store access for event extraction + pattern detection - // queryMinScore — the note-recall confidence gate. Top cosine below this ⇒ - // "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not - // load-bearing math (same posture as the presence thresholds). Set by - // wireVoice from VoiceConfig; default 0.55. - queryMinScore float64 - // queryMinMargin — the second half of that gate: how far the top hit must - // beat the runner-up. 0 ⇒ margin off. - queryMinMargin float64 - // timeParser — used as a fallback for stage-0 reminder grammar matches // (where the extractor didn't run). Shared with the router's extractor. // The production dateparser will replace StubDateTimeParser here too. diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 9c47646..d2790df 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -262,19 +262,18 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem // ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) ----- h := &reactiveHandler{ - stt: transcriber, - tts: synthesizer, - router: rtr, - embedder: emb, - api: coreAPI, - tools: exec, - matcher: matcher, - replier: replier, - phraser: phr, - now: time.Now, - feedsOn: cfg.Feeds != nil, - home: w.home, - netscan: w.netscan, + stt: transcriber, + tts: synthesizer, + router: rtr, + api: coreAPI, + tools: exec, + matcher: matcher, + replier: replier, + phraser: phr, + now: time.Now, + feedsOn: cfg.Feeds != nil, + home: w.home, + netscan: w.netscan, // nil unless `crawl.on_demand` is on: reading a page he names is a // capability, and capabilities are off unless configured. crawler: onDemandCrawler(cfg), @@ -283,18 +282,21 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem search: wireSearch(cfg), // nil unless a `kiwix` block names a server. Same swap-aware client the // router and replier use, so the rewriter follows a model swap. - kiwix: wireKiwix(cfg, llmClient), - weatherProvider: weatherProvider, - weatherLocation: weatherLocation, - memStore: memStore, + kiwix: wireKiwix(cfg, llmClient), + weatherProvider: weatherProvider, + weatherLocation: weatherLocation, + recall: recallWiring{ + embedder: emb, + memStore: memStore, + minScore: cfg.Voice.QueryMinScore, + minMargin: cfg.Voice.QueryMinMargin, + }, dataStore: dataStore, dialogueSessions: dialogueSessions, clarifyStore: clarifyStore, // 0 here (unset config) ⇒ the dialogue default. clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts, extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}}, - queryMinScore: cfg.Voice.QueryMinScore, - queryMinMargin: cfg.Voice.QueryMinMargin, timeParser: timeParser, ecosystem: eco, } diff --git a/docs/handler-wiring.md b/docs/handler-wiring.md new file mode 100644 index 0000000..b290577 --- /dev/null +++ b/docs/handler-wiring.md @@ -0,0 +1,62 @@ +# How reactiveHandler is wired + +*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.* + +The decision Vikunja #433 asked for, and the rule that follows from it. + +## The decision + +**Group the fields into cohesive wiring structs. No container, no generator, no +framework.** `reactiveHandler` (`cmd/mavend/voice.go`) stays the one type the voice +server talks to. What changes is that a capability arrives as one named group, not as +four more loose fields on a struct that already had thirty. + +The pattern was already in the file before this was written down: `searchWiring`, +`kiwixWiring`, `homeWiring`, `netWiring` and `ecosystemWiring` are all this shape, each +`nil` when the capability is off. #433 makes it the rule rather than a habit, and adds +the case the habit had missed — a group that is not a capability toggle. + +## The worked example + +`recallWiring` (`cmd/mavend/recall.go`) holds the five things the recall path needs: +the embedder, the vector store, the personal boundary, and the score and margin that +gate an answer. They used to sit in three separate places on the handler with the two +gate numbers a hundred lines away from the store they gate. + +Its zero value means "no recall", which is why it is a value and not a pointer. The +`*Wiring` types that model an optional capability stay pointers, because `nil` is how +"not configured" is spelled and a zero-valued search client would be a client pointed at +nothing. + +## Why not the alternatives + +**Narrow consumer-side interfaces at each handler** is the more idiomatic Go answer and +it is not rejected, only deferred. It is the right move at the point a handler is pulled +into its own package, because that is when the import direction starts to matter. Doing +it first would mean writing an interface per handler against a struct nobody can pass +anywhere, which is churn bought against a package split that has not happened. + +**A container or a wire-style generator** is rejected outright. This is one binary with +one composition root (`wireVoice` in `cmd/mavend/voicewire.go`). Generated wiring would +add a build step and a layer of indirection to solve a problem that is currently one +composite literal long, and it would make the "is this capability configured" question +harder to answer by reading, which is the question this file is mostly about. + +## What this unlocks + +The reason `cmd/mavend/` cannot split into `mavend/actions/` today is that every action +handler is a method on a struct with thirty unexported fields: moving handlers to a +subdirectory means exporting all of them or inventing an interface to pass through. That +was the answer given on the tick.go and voice.go splits, and it is still true. Grouping +is the step that makes it false later — a handler that takes `recallWiring` and nothing +else can move without the other twenty-five fields following it. + +## The rule + +**A wiring change does not ride a feature PR.** The voice.go and tick.go splits were +safe to merge because the moved code diffed identical, line for line. A regrouping that +touches the confirm gate or the act allowlist is its own change, reviewed on its own, or +it is not reviewable at all. + +New capability, new group. A capability that adds four fields to `reactiveHandler` +instead of one struct is the thing this decision exists to stop. -- 2.52.0