mavend: centralize action validation boundary (slice 4)

This commit is contained in:
2026-09-06 12:53:54 +04:00
parent 6a402bf556
commit 356766bce1
32 changed files with 2061 additions and 178 deletions
+626
View File
@@ -0,0 +1,626 @@
# Maven Current-State Audit
**Date:** 2026-09-05
**Scope:** Read-only code + tests investigation. No inference from names or docs.
**Source of truth:** code and tests only.
---
## A. Current End-to-End Flow Diagram
```
Telegram Bot API (long-poll)
|
| api.Chat(ctx, "telegram:<id>", text)
v
mavweb POST /api/chat --> ipc.Client (unix socket) --> daemonAPI.Chat()
|
| chatFn = handler.handleText
v
Voice TCP :9100 --> HandlePushToTalk --> STT --> +------------------+
| runTurn() |
| voice.go:270 |
+------------------+
| 0. decision record (ctx)
| 0b. turnRoute (computed once)
| 1. expired clarify notice
| 2. resolveConfirm (y/n)
| 3. resolveRepair (correction)
| 3b. resolveUntargetedRepair
| 3c. resolveCommandProhibition
| 4. resolveClarifyAnswer
| 5. resolveQuietToggle
| 5b. resolveSnooze
| 5c. resolveAck
| 5d. resolveReminderCancellation
| 5e. resolveCandidate (ordinal)
| 6. ROUTE (cascade)
| 7. dialogue merge (followUpMerge)
| 8. clarify (missing slots)
| 9. applyAction (per-intent dispatch)
| 10. replier (LLM or stub)
+------------------+
|
v
reply string
```
### The routing cascade (step 6) in detail:
```
utterance
|
v
Stage 0: Grammars (stagezero.go:24-91)
| 22 ordered regex/structural grammars. First match wins.
| Confidence = 1.0, Stage = 0. fillMatchedSlots runs.
| NO MATCH -> fall through
v
Stage 0b: Routing Heads (heads.go, ONNX)
| 4 linear heads over mean-pooled e5-small: intent, destination, slot BIO, clarify
| Below headsThreshold (0.6) -> decline, cascade continues
| Intent head softmax -> Decision{Intent, Confidence, Source, Clarify}
| completeParsedSingleVerbFact can overrule clarify
| NO WIRE / ERROR / DECLINE -> fall through
v
Stage 1a: LLM Router (llmrouter.go, Qwen3-1.7B via llama-server)
| GBNF grammar-constrained JSON output
| gateLLMDecision: thin confidence (0.3) for incomplete slots
| ERROR / PARSE FAIL / "unknown" -> fall through
v
Stage 1: Nearest-Centroid Classifier (classifier.go)
| Cosine similarity over ONNX embeddings against seed centroids
| Best intent wins (ties broken by name)
v
Stage 2: Slot Extraction (slots.go:55-91)
| Dispatch on intent: DateTimeParser (reminder), ActMatcher (act), FactParser (fact)
v
Stage 3: Confidence Gate (router.go:244-248)
| Confidence < threshold (0.55) -> Clarify = true, Stage = 3
```
---
## B. Package/File Ownership Map
### Command packages (cmd/)
| Package | Binary | Role |
|---------|--------|------|
| cmd/mavend | mavend | Core daemon: DB, IPC, routing, actions, voice server, tick loop |
| cmd/mavweb | mavweb | Web UI + HTTP server (serves /, /dash, /history, /trace, /notifications, /tools) |
| cmd/mavwaked | mavwaked | Wake-word + voice activity detection (silero VAD, energy threshold) |
| cmd/mavsttd | mavsttd | Speech-to-text daemon (whisper.cpp / remote worker) |
| cmd/mavttsd | mavttsd | Text-to-speech daemon (piper) |
| cmd/mavgpud | mavgpud | GPU proxy daemon (workpc inference) |
| cmd/mavcaldav | mavcaldav | CalDAV sync daemon |
| cmd/mavmaild | mavmaild | Mail ingestion daemon |
| cmd/mavpoll | mavpoll | Polling daemon (Kuma monitors) |
| cmd/mavenclient | mavenclient | CLI client |
| cmd/mavupdate | mavupdate | Self-update tool |
| cmd/mavseal | mavseal | Seal/encryption tool |
| cmd/e2eprobe | e2eprobe | End-to-end probe |
| cmd/labelgen | labelgen | Label generation tool |
| cmd/mavend/seedtest | (test helper) | Test seeder |
### Internal packages relevant to routing/action
| Package | Key files | Role |
|---------|-----------|------|
| internal/router | router.go, intent.go, stage0.go, stagezero.go, heads.go, llmrouter.go, classifier.go, slots.go, source.go, embedder.go, onnxembedder.go, question.go, singletoken.go, notecapture.go, claim.go (package-level), decisiontrace.go | Routing cascade, intent taxonomy, slot extraction, confidence |
| internal/claim | claim.go | Band/Claim/arbitration types (imported by router and dialogue) |
| internal/dialogue | session.go, clarify.go, pending.go, pending.go | Session state, clarification, PendingAction/Capability |
| internal/tool | tool.go, risk.go | Tool execution, risk tiers, policy |
| internal/phraser | phraser.go, llmphraser.go, fallbacks.go, replier.go | Response phrasing (LLM or stub) |
| internal/voice | server.go, wire.go, replier.go | Voice TCP server, wire protocol |
| internal/memory | store.go | Vector memory (cosine similarity search) |
| internal/llm | remote.go | Two-tier LLM client (resident + workstation) |
| internal/lexicon | lexicon.go | Closed Russian word sets (embedded JSON) |
| internal/morph | morph.go | Russian morphology (golem lemmatizer) |
| internal/loop | loop.go | Proactive nudge rules, gates |
| internal/store | facts.go, reminders.go, tools.go | SQLite persistence |
| internal/ipc | coreapi.go, api.go | IPC interface + wire types |
| internal/modes | modes.go | Mode inventory (30 routing classes) |
| internal/mcp | manager.go, client.go, allowlist.go | MCP tool discovery and execution |
| internal/smarthome | client.go | Home Assistant service calls |
### Key cmd/mavend files (the "glue" layer)
| File | Responsibility |
|------|---------------|
| voice.go | runTurn() pipeline, reactiveHandler, replySystem, applyAction |
| turnroute.go | turnRoute memo (computed once per turn) |
| actions.go | actionHandlers dispatch table (7 intents) |
| actions_act.go | actionAct: tool matching, ecosystem interception, execution |
| actions_fact.go | actionFact: write fact + embed + vector insert |
| actions_reminder.go | actionReminder: parse time + create reminder |
| actions_note.go | actionNote: write note + embed + vector insert |
| actions_query.go | actionQuery: 20+ source chain, queryTurn, queryWalk |
| confirm.go | resolveConfirm: y/n for destructive acts, Hexis, routines |
| clarify.go | askClarify, resolveClarifyAnswer, wantedSlots |
| followup.go | followUpMerge: slot inheritance across turns |
| continuation.go | continuationDecision: elliptical follow-ups |
| boot.go | Daemon wiring: connects all pieces |
| voicewire.go | Voice server wiring: STT, router, replier, tools, sessions |
| tick.go | Proactive tick loop: nudge delivery, reminders, routines |
| tick_routines.go | Routine firing, pattern detection |
| tick_digest.go | Digest queue and flush |
---
## C. Ingress -> Routing -> Action Call Trace
### Text path (web/telegram)
```
1. mavweb POST /api/chat (cmd/mavweb/chat.go:84)
-> strings.TrimSpace(r.FormValue("text"))
-> core.Chat(ctx, "web", text) (ipc/coreapi.go:205-213)
2. daemonAPI.Chat (cmd/mavend/tick_api.go:95)
-> chatFn(ctx, conversation, text)
-> handler.handleText(ctx, conversation, text) (voice.go:245)
3. handleText (voice.go:245-248)
-> runTurn(withDialogueID(ctx, ...), text, sourceText)
4. runTurn (voice.go:270-481) -- see Section A for step-by-step
```
### Voice path (mic)
```
1. Voice TCP Server receives PushToTalk frame (internal/voice/server.go:187-215)
-> handler.HandlePushToTalk(ctx, req, sid) (cmd/mavend/voice.go:200-218)
2. HandlePushToTalk:
-> stt.Transcribe(ctx, req.Audio) -- whisper.cpp or remote worker
-> runTurn(ctx, text, sourceVoice) -- same pipeline as text
-> tts.Synthesize(ctx, replyText) -- piper
-> return voice.PushToTalkResp{ReplyText, ReplyAudio}
```
### runTurn step-by-step (voice.go:270-481)
```
Step 0 (276): decision.Record installed on context (V-564)
Step 0b (292): turnRoute computed once, shared via context
Step 1 (313): clarifyExpiredNotice -- parked question TTL ran out
Step 2 (318): resolveConfirm -- y/n for parked destructive act
-> classifyConfirm(text) (confirm.go:264)
-> confirmResolvers chain (confirm.go:114):
1. pendingRoutineConfirm
2. pendingHexisExec
3. pendingAct -> tools.Exec(ctx, fn, args, true)
Step 3 (327): resolveRepair -- "нет, это был вопрос"
Step 3b (334): resolveUntargetedRepair -- "нет, не так"
Step 3c (344): resolveCommandProhibition -- "не отменяй..."
Step 4 (356): resolveClarifyAnswer -- answer to parked question
Step 5 (366): resolveQuietToggle -- "тихий режим"
Step 5b (374): resolveSnooze -- "не сейчас" / "потом"
Step 5c (381): resolveAck -- "готово"
Step 5d (389): resolveReminderCancellation -- cancel verb + noun
Step 5e (397): resolveCandidate -- "второй" (ordinal)
Step 6 (406): ROUTE
-> turnRoute.resolve(ctx) (turnroute.go:68)
-> continuationDecision(prev, text, now) OR router.Route(ctx, text, now)
Step 7 (425): followUpMerge(prev, dec, now) -- slot inheritance
Step 8 (445): clarify -- missing required slots
-> hexisBeforeClarify -- try Hexis before asking
-> askClarify -> park PendingQuestion
Step 9 (466): applyAction -> actionHandlers[dec.Intent]
Step 9b (474): ackFromFact -- close live nudge
Step 10 (477): replier -- phrase the reply (LLM or stub)
```
### Action dispatch (voice.go:496-504 -> actions.go:48-56)
```
applyAction(ctx, dec):
if dec.Clarify -> return "" (Replier phrases)
actionHandlers[dec.Intent](h, ctx, dec):
IntentFact -> actionFact (actions_fact.go:17)
-> coreAPI.WriteFact + memStore.Insert + detectPattern
IntentReminder -> actionReminder (actions_reminder.go:16)
-> coreAPI.CreateReminder
IntentAct -> actionAct (actions_act.go:17)
-> refusesCommand check
-> matcher.Match (fuzzy prefix over enabled tool names)
-> task_status intercept
-> praxis intercept
-> hexis intercept (resolve entity -> discover capabilities -> risk -> exec)
-> proposeGap (if no match)
-> tools.Exec (tool/tool.go:156):
LookupTool -> RiskOf -> PolicyFor(tier) -> dispatch:
MCP -> mcp.CallPositional
HA -> smarthome.CallService
Process -> exec.CommandContext
IntentChat -> actionChat (actions.go:58)
-> phraser.PhraseChat(ctx, utterance, history)
IntentSystem -> actionSystem (actions.go:77)
-> replySystem: keyword match on utterance
IntentNote -> actionNote (actions_note.go:24)
-> coreAPI.WriteNote + memStore.Insert
IntentQuery -> actionQuery (actions_query.go:varies)
-> querySources chain (20+ sources, first claim wins)
-> queryWalk narrows by destination
```
---
## D. Inventory of Existing Machinery
### D1. Deterministic fast-path recognizers
**Status: EXISTS, extensive, production-critical**
| Component | File:line | What it does |
|-----------|-----------|-------------|
| 22 stage-0 grammars | router/stagezero.go:24-91 | Ordered regex/structural rules. First match wins at confidence 1.0 |
| Grammar type | router/stage0.go:20-29 | Pattern+Build (regex) or Decide (structural) |
| Wake-word strip | router/stage0.go:66-78 | StripWakeToken: removes "мавен" in any script |
| Act allowlist fast path | router/stage0.go:55,80 | "мавен, restart nginx" -> act at stage 0 |
| Command prohibition | stagezero.go:30 | "don't restart nginx" -> refusal sentinel |
| System time/date | stagezero.go:35 | "сколько времени", "который час" |
| Agenda query | stagezero.go:39 | "что у меня сегодня" |
| Reminder | stagezero.go:59 | "напомни через час" with time extraction |
| Fact capture | stagezero.go:80 | "запиши купить молоко" with note body extraction |
| Possession statement | stagezero.go:89 | "у меня кончилась вода" |
| Lexicon (closed word sets) | lexicon/lexicon.go | ~40 embedded Russian word sets |
| Morphology | morph/morph.go | Lemma(), IsVerbForm(), SameWord() via golem |
| Question detection | router/question.go | IsQuestionShaped(), IsOpenQuestionShaped(), CarriesCaptureVerb() |
| Single-token analysis | router/singletoken.go | thinSingleToken() with completeSingles escape |
| Note capture parser | router/notecapture.go | ParseNoteCapture(): strips capture frame |
| DateTimeParser | router/slots.go:19-21 | Interface; production is dateparser (not shown) |
| DefaultFactParser | router/slots.go:154-180 | Lemma-based fact extraction (water/meal/shower/break/sleep) |
| DefaultActMatcher | router/slots.go:106-149 | Exact phrase prefix + aliases, longest-first |
### D2. Learned routing/NLU
**Status: EXISTS, multi-layered**
| Component | File:line | What it does |
|-----------|-----------|-------------|
| RouterHeads (ONNX) | router/heads.go:55-64 | 4 heads over e5-small: intent, destination, slot BIO, clarify |
| RouterHeads.Route | router/heads.go:120+ | Softmax classification, threshold=0.6, single forward pass |
| LLMRouter | router/llmrouter.go:20-22 | GBNF-constrained JSON from Qwen3-1.7B |
| LLMRouter.Route | router/llmrouter.go:120+ | System prompt + grammar -> routeAction structs |
| gateLLMDecision | router/router.go:352-373 | Thin confidence (0.3) for incomplete slots |
| Classifier | router/classifier.go:40-45 | Nearest-centroid over ONNX embeddings |
| Classifier.Classify | router/classifier.go:50+ | Cosine similarity, sorted best-first |
| Classifier.AddExample | router/classifier.go | Append-only correction (grows classifier) |
| ONNX Embedder | router/onnxembedder.go | multilingual-e5-small, 384-dim, query/passage prefix |
| HashEmbedder | router/embedder.go:79-116 | Fallback bag-of-words (deterministic, weak) |
| Embedder interface | router/embedder.go:17-21 | Dim(), Embed(), Close() |
| Intent taxonomy | router/intent.go:38-63 | 7 intents: act, reminder, fact, note, query, chat, system |
| Source taxonomy | router/source.go | 12 destinations: recall, calendar, tasks, list, money, weather, home, network, feeds, attention, self, world |
| Slots struct | router/intent.go:66-94 | Time, Fn, Args, Key, Value, Text + Has* flags |
| Decision struct | router/intent.go:100-128 | Utterance, Stage, Intent, Confidence, Slots, Clarify, Source, SourceAnchored |
| Modes inventory | modes/modes.go | ~30 distinct downstream behaviors, embedded JSON |
| Confidence gate | router/router.go:244-248 | threshold=0.55, below -> Clarify=true |
### D3. Claim/arbitration system
**Status: EXISTS, not wired into cascade (per claim.go:138-140)**
| Component | File:line | What it does |
|-----------|-----------|-------------|
| Band enum | claim/claim.go:38-69 | BandUnknown, BandVetoed, BandNearest, BandStructural, BandAnchored |
| Claim struct | claim/claim.go:88-117 | Claimant, Intent, Filled, Consumed, Unexplained, Band, Veto |
| Coverage() | claim/claim.go:122-128 | Consumed / (Consumed + Unexplained) |
| MoreSpecificThan() | claim/claim.go:141-147 | Coverage first, Band breaks ties |
| Tokens() | claim/claim.go:156-169 | Tokenize utterance for coverage |
| Split() | claim/claim.go:178-192 | Partition tokens into consumed/unexplained |
Note: claim.go:138-140 explicitly states this is "Deliberately NOT wired into the cascade by V-565." It is here so the ordering is one function with tests rather than duplicated logic.
### D4. Action path
**Status: EXISTS, with multiple dispatch paths**
| Component | File:line | What it does |
|-----------|-----------|-------------|
| actionHandlers table | actions.go:48-56 | 7-intent dispatch map |
| applyAction | voice.go:496-504 | Short-circuits on Clarify, dispatches via table |
| actionAct | actions_act.go:17-114 | Full act cascade: refuse -> match -> task_status -> praxis -> hexis -> propose -> exec |
| tool.Executor.Exec | tool/tool.go:156-235 | LookupTool -> RiskOf -> PolicyFor -> dispatch (MCP/HA/process) |
| RiskOf | tool/risk.go | Derives tier from Tool row |
| PolicyFor | tool/risk.go | TierSafe/TierDestructive/TierIrreversible -> Confirm/VoiceMayRun |
| Matcher.Match | tool/tool.go:251+ (tool package) | Fuzzy prefix over enabled tool names |
| MCP dispatch | tool/tool.go:191-198 | mcp.ParseCmd -> mcp.CallPositional |
| HA dispatch | tool/tool.go:205-221 | smarthome.ParseCmd -> smarthome.CallService |
| Process dispatch | tool/tool.go:231-234 | exec.CommandContext (no shell) |
| Hexis integration | ecosystem_acts.go:660+ | resolve entity -> discover capabilities -> risk -> confirm/exec |
| Praxis integration | ecosystem_acts.go (handlePraxisAct) | Attention/item lifecycle |
| UnknownTargetError | tool/tool.go:90-96 | Named error with target word |
| Tool store | store/tools.go | ProposeTool, EnableTool, DisableTool, LookupTool, ReconcileMCPTool |
### D5. Confirmation/risk handling
**Status: EXISTS, comprehensive**
| Component | File:line | What it does |
|-----------|-----------|-------------|
| resolveConfirm | confirm.go:78-100 | classifyConfirm + chain of resolvers |
| classifyConfirm | confirm.go:264+ | Closed yes/no lexicon, entire utterance must match |
| confirmResolvers | confirm.go:114-186 | 3 slots: routine proposal, Hexis exec, local tool |
| confirmTTL | confirm.go:59 | 90s |
| pendingAct | confirm.go:48-54 | fn, args, phrase, expiry |
| pendingHexisExec | confirm.go:21-35 | capabilityID, entityID, correlationID, expiry |
| pendingRoutineConfirm | confirm.go:39-46 | routineID, action, object, interval, phrase, expiry |
| park() | confirm.go:63-67 | Stores pendingAct |
### D6. Non-action paths
| Component | File:line | What it does |
|-----------|-----------|-------------|
| actionChat | actions.go:58-75 | phraser.PhraseChat with dialogue history |
| replySystem | voice.go:509-569 | Keyword matching on utterance for time/date/status |
| actionQuery chain | actions_query.go:99-188 | 20+ sources, first claim wins |
| queryWalk | actions_query.go:190+ | Narrows chain by destination |
| Best recall | recall.go:8-65 | Vector search with confidence gate (minScore + minMargin) |
| Clarify store | dialogue/clarify.go | PendingQuestion stack (max depth 2), 90s TTL |
| Follow-up merge | followup.go:99-194 | Slot inheritance across same-intent turns |
| Continuation | continuation.go:57-98 | Elliptical follow-ups ("а завтра?") |
| Tick loop | tick.go | Proactive nudge delivery, reminders, routines, patterns, digest |
### D7. Tests/data inventory
**Status: Extensive (354 test files)**
Key test fixtures:
| Fixture | File | Cases |
|---------|------|-------|
| Routing contract | internal/router/eval/ru_routing_v1.json | ~70 held-out utterances with intent/source/time/clarity expectations |
| Ecosystem reach | internal/router/eval/ru_ecosystem_v1.json | ~50 act utterances with service/capability expectations |
| Nudge phrasing | internal/phraser/eval/nudges_v1.json | 15 cases, on-topic + property checks |
| Talk phrasing | internal/phraser/eval/talk_v1.json | 27 cases (chat/query/knowledge) |
| Recall contract | internal/memory/recalleval/ru_recall_v1.json | ~40 cases with note sets |
| Personal boundary | cmd/mavend/testdata/personal_boundary_v1.json | 72 cases |
| Safety scenarios | cmd/mavend/testdata/system_safety_scenarios.json | 4 scenarios |
| Simulator scenarios | cmd/mavend/testdata/scenarios/*.json | 5 full scripted scenarios |
| Usage transcript | scripts/testdata/usage-turns.txt | 167-line simulated usage |
| STT golden | cmd/mavsttd/testdata/golden_v1.json | 4 cases with WER bounds |
Key eval harnesses:
| Harness | File |
|---------|------|
| Routing eval | internal/router/eval/eval.go |
| Heads eval | internal/router/eval/heads_test.go |
| LLM router eval | internal/router/eval/llmrouter_test.go |
| Ecosystem reach eval | internal/router/eval/reach_test.go |
| Claim scoring | internal/router/eval/claims_test.go |
| Nudge phrasing eval | internal/phraser/eval/eval.go |
| Talk phrasing eval | internal/phraser/eval/talk.go |
| Recall eval | internal/memory/recalleval/recalleval.go |
| Kiwix rewrite eval | internal/kiwix/rewrite_eval.go |
| Simulator | cmd/mavend/simulator_test.go |
Classifier seed phrases: models/seeds/{query,fact,chat,act,note,reminder,system}.txt
---
## E. Existing Contracts/Types We Can Reuse
### Already well-typed (reuse as-is or thin wrapper)
| Type | File:line | Notes |
|------|-----------|-------|
| `router.Intent` | intent.go:38 | String enum: act, reminder, fact, note, query, chat, system |
| `router.Slots` | intent.go:66-94 | Typed: Time, Fn, Args, Key, Value, Text + Has* flags |
| `router.Decision` | intent.go:100-128 | Utterance, Stage, Intent, Confidence, Slots, Clarify, Source, SourceAnchored |
| `router.Source` | source.go | String enum: 12 destinations |
| `router.Grammar` | stage0.go:20-29 | Pattern+Build or Decide |
| `router.Extractor` | slots.go:45-49 | Time, Acts, Facts parsers |
| `claim.Claim` | claim.go:88-117 | Band-based arbitration (not wired yet) |
| `claim.Band` | claim.go:36-69 | Ordinal evidence kinds |
| `dialogue.PendingAction` | pending.go:58 | Capability, slots, missing, utterance, TTL |
| `dialogue.Capability` | pending.go:16-46 | 7 capability strings |
| `tool.Executor` | tool/tool.go:116-122 | Exec with policy checks |
| `tool.Policy` | tool/risk.go | Confirm, VoiceMayRun per tier |
| `ipc.ChatReply` | ipc/api.go:752-761 | Reply, Source, TraceID |
| `ipc.Tool` | ipc/api.go:699 | Wire shape of tool row |
### Partially exists (needs extension)
| Concept | Current form | Gap |
|---------|-------------|-----|
| NormalizedInput | Raw string in, `text string` parameter | No NormalizedInput struct; STT output passes as-is |
| FastPathResult | Stage-0 grammar Decision | Not a separate type; embedded in Decision |
| RouteDecision | `router.Decision` | Already carries Stage (0/1/2/3), Intent, Confidence, Slots. Could become RouteDecision |
| ActionCandidate | `router.Decision` + `actionHandlers` dispatch | No explicit ActionCandidate type; intent + slots + handler selection are implicit |
### Missing (must be created if needed)
| Concept | Notes |
|---------|-------|
| Schema validation | No JSON-schema or struct validation on incoming slots before execution |
| Confidence/risk policy | Risk tiers exist for tools but not for routing confidence. Stage 3 gate exists but is a simple threshold |
| Post-execution verification | No explicit verification step after tool execution (success/failure is the extent) |
---
## F. Gaps Against the Proposed First-Stage Design
### Proposed pipeline vs current reality
| Proposed stage | Current state | Gap |
|---------------|---------------|-----|
| **NormalizedInput** | Raw `text string` everywhere | No normalization struct. Preprocessing is scattered: StripWakeToken in router, lowercase in matchers, trim in entry points |
| **Deterministic fast-path** | Stage-0 grammars (22 rules) | EXISTS and is production-critical. However: grammars produce `Decision` directly, not a separate FastPathResult type. No schema validation on slots before returning |
| **Learned routing** | Heads -> LLM -> Classifier cascade | EXISTS and complex. But: multiple confidence scales (stage-0=1.0, heads=softmax, LLM=1.0/0.3, classifier=cosine). No unified confidence model |
| **RouteDecision** | `router.Decision` | EXISTS under another name. Carries Stage, Intent, Confidence, Slots, Clarify, Source. Could be wrapped/renamed |
| **ActionCandidate** | Implicit in `router.Decision` + `actionHandlers` | Missing as explicit type. The decision arrives at applyAction and is dispatched by intent. No schema validation of slots before dispatch |
| **Schema validation** | NONE | Slots are filled by extractors and used directly. No validation that e.g. reminder has both Text and Time before actionReminder runs |
| **Confidence/risk policy** | Threshold gate (0.55) for routing; risk tiers for tools | No unified confidence policy. Routing confidence and tool risk are separate systems. No policy that says "if confidence < X, require confirmation for action" |
| **Confirmation** | resolveConfirm with 3 pending slots | EXISTS for destructive tools, Hexis, and routines. Not applied to routing confidence (a low-confidence act just gets proposed, not confirmed) |
| **Execution** | tool.Executor.Exec | EXISTS with MCP/HA/process dispatch. Well-structured. But: actionFact, actionReminder, actionNote bypass tool.Executor entirely (they call CoreAPI directly) |
| **Post-execution verification** | Success/failure error handling in actionAct | Partially EXISTS. Tool execution returns (out, err). Error types are handled specifically. But: no structured verification step, no retry policy, no rollback |
### Architectural problems identified
1. **Fast paths that execute directly**: actionFact, actionReminder, actionNote call CoreAPI.WriteFact/CreateReminder/WriteNote directly from the action handler, bypassing tool.Executor. This means they skip the risk tier system, the confirm gate, and the allowlist. This is by design (facts/reminders are user-stated, not tool invocations) but means the "all actions through one pipeline" goal requires either wrapping these in tool-like abstractions or explicitly exempting them.
2. **Multiple confidence scales**: Stage-0 = 1.0 (hardcode), heads = softmax float, LLM = 1.0 or 0.3 (thin), classifier = cosine similarity. These are not on the same scale and cannot be compared. The claim system (claim.Band) explicitly addresses this by making confidence ordinal (Band) rather than graded. The proposed design should preserve this insight.
3. **Routing code that also performs tool selection**: actionAct at actions_act.go:29-33 runs the matcher inline when HasFn is false. The matcher is also the stage-2 ActMatcher. So tool selection happens both in the router (stage 2) and in the action handler (actionAct). The actionAct path is the fallback for LLM-routed acts where the verb didn't go through stage-0.
4. **Implicit fallthrough**: The router cascade is explicitly designed as fallthrough (each stage may decline). The query source chain is also fallthrough (first claim wins). The confirm resolver chain is also fallthrough. This is a consistent pattern, not a bug, but means the "stage-to-stage" architecture must preserve explicit decline semantics.
5. **Clarify bypasses action pipeline**: When dec.Clarify is true, applyAction returns "" immediately (voice.go:497-499). The clarifier can also call hexisBeforeClarify (voice.go:446) to try Hexis before asking, which is a hidden action path that runs before the normal action dispatch.
6. **No schema validation**: Slots filled by stage-2 extraction or stage-0 grammars are used directly by action handlers. actionReminder (actions_reminder.go) checks HasTime itself. actionFact checks HasKey. But there is no shared validation layer; each handler does its own checks.
7. **Voice-specific behavior**: HandlePushToTalk wraps runTurn with STT before and TTS after. The turnSource tag ("tap:voice" vs "tap:text") propagates into fact sources but does not change routing or action behavior. However: the voice path has barge-in, session management, and wake-word detection that the text path lacks entirely. The semantic behavior is the same; the infrastructure is different.
8. **Existing useful code to preserve**:
- Stage-0 grammars: 22 ordered rules, battle-tested, each with extensive comments about why it sits where it sits. Moving or reordering them breaks routing.
- The claim/Band system: explicitly designed for the problem of incomparable confidence scales. Not wired yet but well-tested.
- The clarify store with stack support: handles nested clarification flows.
- The query source chain with destination narrowing: 20+ sources with guessers vs lookups distinction.
- The tool risk tier system: well-tested, with voice-specific authority limits.
- The turnRoute memo pattern (V-560): computed once, shared via context, prevents routing disagreement.
---
## G. Smallest Behavior-Preserving Refactor Boundary
The smallest refactor that aligns with the proposed architecture without changing behavior:
**Wrap Decision in RouteDecision + add NormalizedInput as thin alias**
```
Current: router.Route(ctx, utterance, now) -> (Decision, error)
Proposed: router.Route(ctx, NormalizedInput, now) -> (RouteDecision, error)
```
Where:
- `NormalizedInput` is `type NormalizedInput struct { Text string; Source string }` -- a thin wrapper, not a transformation
- `RouteDecision` is `type RouteDecision Decision` -- or just `Decision` with a type alias
- The existing Stage field (0/1/2/3) already encodes which stage produced the result
- The existing Confidence field already carries the per-stage confidence
This changes zero behavior. It names what exists. It creates the typed boundary the future stages need.
**Second step: extract action candidates**
Currently actionAct does tool matching inline. The matcher result (fn, args) should be a typed ActionCandidate returned by the router or by a post-route step, not discovered inside the action handler. But this changes the call structure of actionAct, which is a larger refactor.
**Third step: schema validation**
Add a Validate(slots) step between routing and action dispatch. Currently each handler validates its own slots; this would centralize it. Minimal behavior change: the same checks, in one place.
---
## H. Recommended Implementation Order
1. **Type the boundaries** (1-2 hours)
- Define NormalizedInput, RouteDecision as thin wrappers
- Route() signature change (internal callers only)
- Zero behavior change
2. **Pin current behavior with regression tests** (2-3 hours)
- Run existing eval fixtures and record baselines
- Add integration tests for the full runTurn pipeline (text + voice paths)
- Add tests for each action handler with representative inputs
3. **Extract ActionCandidate from actionAct** (3-4 hours)
- Move tool matching out of actionAct into a post-route step
- Return ActionCandidate{Fn, Args, Source} from routing
- actionAct consumes ActionCandidate instead of re-matching
4. **Add schema validation layer** (2-3 hours)
- Validate slots before action dispatch
- Centralize the per-handler checks
- Fail-closed: missing required slot -> clarify, not runtime error
5. **Unify confidence presentation** (3-4 hours)
- Map per-stage confidence to ordinal Band (leverage existing claim.Band)
- Expose in RouteDecision for downstream policy
- Do NOT try to make confidence comparable across stages
6. **Wire claim.Band into cascade** (4-6 hours)
- Replace ad-hoc precedence with MoreSpecificThan
- This is the V-558/V-565 work already planned
---
## I. Tests That Should Pin Current Behavior Before Refactoring
### High-value regression pins
| Test | What it pins | File |
|------|-------------|------|
| Router cascade stage ordering | Stage-0 wins, heads decline correctly, LLM fallback, classifier floor | internal/router/router_test.go |
| Held-out routing contract | ~70 utterances with intent/source/time expectations | internal/router/eval/eval_test.go |
| Ecosystem reach contract | ~50 act utterances routing to correct service | internal/router/eval/reach_test.go |
| Simulator scripted day | Full pipeline: STT -> router -> store -> phraser | cmd/mavend/simulator_test.go |
| Safety scenarios | Destructive acts require confirmation, ambiguous entities clarified | cmd/mavend/eval_scenarios_test.go |
| Tool risk assessment | Tier derivation from tool rows | internal/tool/ risk_test.go (implied) |
| Confirm flow | y/n for parked acts, TTL expiry, chain ordering | cmd/mavend/confirm_test.go |
| Clarify flow | Missing slots -> question -> answer -> continue | cmd/mavend/clarify_test.go |
| Follow-up merge | Slot inheritance across turns | cmd/mavend/followup_test.go |
| Query source chain | First-claim-wins, destination narrowing | cmd/mavend/querywalk_test.go |
| Personal boundary | 72-case held-out fixture | cmd/mavend/personalboundary_test.go |
| Recall contract | ~40 cases with paraphrased queries | internal/memory/recalleval/recalleval_test.go |
| Action act risk | Destructive/irreversible classification | cmd/mavend/actions_act_risk_test.go |
| Degradation | Each ecosystem service unreachable | cmd/mavend/ecosystem_degraded_test.go |
### What to run before and after each refactor step
```sh
make test # full suite
go test ./internal/router/eval/ -run Eval # routing contract
go test ./cmd/mavend/ -run Simulator # integration
go test ./cmd/mavend/ -run Eval # safety scenarios
go test ./cmd/mavend/ -run PersonalBoundary # boundary fixture
go test ./internal/phraser/eval/ -run Eval # phrasing contract
go test ./internal/memory/recalleval/ -run Eval # recall contract
```
---
## J. Unknowns That Cannot Be Established From Code/Tests
1. **Actual production accuracy numbers**: The eval fixtures measure held-out accuracy, but production routing traces (routing_traces table) are the real measure. We cannot inspect the production DB from code.
2. **Whether the LLM router is currently enabled in production**: The config shows `llm_router` settings but we cannot confirm the daemon is running with it wired. The heads may be the actual fast path.
3. **Real-world confirmation rates**: How often do users get asked to confirm? How often do they decline? This is behavioral data, not code.
4. **Whether the claim system should be wired**: claim.go says "Deliberately NOT wired by V-565" but the current ad-hoc precedence works. The claim system is tested but untested in production.
5. **Token budget pressure on the resident model**: The 4096 context window is shared between routing, phraser, and chat. We cannot tell from code whether context pressure causes routing failures in production.
6. **Whether stage-0 grammars overlap or shadow each other**: The ordering is documented, but no test measures "if grammar A were removed, which utterances would fall through differently." The cascade hides contention by design.
7. **Performance characteristics of the ONNX embedder in production**: Tests measure p50 (20.6ms for classifier). Production numbers on the actual hardware may differ.
8. **Whether the ecology of pre-route resolvers (steps 1-5e) can be unified**: Seven resolvers each claim the turn independently, in order. Whether they could be replaced by a single arbiter (the claim system) is a design question, not a code question.
---
## Proposed Mapping: Future Stage/Contract -> Current Implementation
| Future stage/contract | Current implementation | Reuse/wrap/move/replace | Reason |
|----------------------|----------------------|------------------------|--------|
| **NormalizedInput** | Raw `text string` parameter in handleText/HandlePushToTalk/runTurn | **wrap** | Create struct, pass through. No transformation needed yet. Existing preprocessing (StripWakeToken, trim) stays inside the router. |
| **Deterministic fast path** | Stage-0 grammars (router/stagezero.go, stage0.go) | **reuse as-is** | 22 battle-tested rules with load-bearing ordering. Output is Decision at confidence 1.0. Naming it "fast path" is cosmetic. |
| **FastPathResult** | Decision with Stage=0 | **wrap** | Type alias or thin struct. The Stage field already identifies the source. |
| **RouteDecision** | `router.Decision` (intent.go:100-128) | **reuse (rename or alias)** | Already carries all needed fields: Intent, Confidence, Slots, Clarify, Source, Stage. The Stage field (0/1/2/3) tells which cascade stage produced it. |
| **ActionCandidate** | Implicit: Decision.Intent + Decision.Slots + actionHandlers dispatch | **move** | Extract from actionAct into a post-route step. Currently, actionAct:29-33 re-runs the matcher when HasFn is false. This should produce an ActionCandidate that actionAct consumes. |
| **Schema validation** | Per-handler checks (actionReminder checks HasTime, actionFact checks HasKey) | **move + centralize** | Currently scattered across action handlers. Centralize into a Validate(Decision) step before applyAction. |
| **Confidence/risk policy** | Stage-3 gate (threshold 0.55) for routing; RiskOf/PolicyFor for tools | **extend** | These are separate systems today. The routing gate Clarify flag. The tool policy returns ErrNeedsConfirm/ErrNeedsAuthedSurface. A unified policy would map confidence bands to action policies. |
| **Confirmation** | resolveConfirm (confirm.go:78) with 3 pending slots | **reuse** | Already handles destructive tools, Hexis, and routines. Would need extension if low-confidence acts should also confirm. |
| **Execution** | tool.Executor.Exec (tool/tool.go:156) + per-intent handlers | **reuse** | Well-structured with MCP/HA/process dispatch. The per-intent handlers (actionFact, actionReminder) bypass Executor by design -- they write to the store, not run tools. |
| **Post-execution verification** | Error handling in actionAct (actions_act.go:64-108) | **extend** | Currently: success -> "done", specific error -> specific reply. No structured verification step. Adding one would be a new layer. |
+43 -1
View File
@@ -986,6 +986,48 @@ ends with `make test` green (gofmt + vet + `-race`), no exceptions.
---
## Prior art — external memory systems
Read before proposing a change to how memory is extracted or read back. Nothing
here is adopted. Each entry says what does not transfer and what a cheap
experiment against it would be.
### VoiceMem (github.com/xzf-thu/VoiceMem, Apache 2.0)
A streaming memory system for voice assistants, surveyed 2026-08-30. Python,
Chinese-first. A "left brain" of keyed structured facts and a "right brain" of
affect and relationship nodes, both extracted and queried while the user is
still speaking.
**What does not transfer.** Its speech stack is Paraformer-zh streaming STT with
Qwen-Omni or Step-Audio2-Mini as the conversational model. Maven runs
whisper.cpp, piper and Qwen3-1.7B. It is a Python library, so adopting the code
means a Python service on homesrv behind a new daemon seam (`docs/offload.md`),
which is a large cost for a Chinese-tuned pipeline. The licence is not the
barrier.
**What is worth taking.**
- **Streaming extraction.** Extraction and retrieval start on partial
transcripts, not on a final one. Maven's `runTurn` waits for mavsttd to
finish. This is the larger win and the larger change, because it touches both
the STT seam and the turn ladder.
- **A hard retrieval token budget.** They report roughly 430 memory tokens per
query. At `n_ctx` 4096 the constraint binds directly on what memory may put in
front of the resident model. This is the cheapest experiment: measure Maven's
existing recall evals against a capped budget.
- **The fact/affect split.** The factual half is what Maven already has. Affect
and relationship nodes have no equivalent here, and they bear on § save-where.
- **A shared embedder.** They also use multilingual-E5, so their retrieval
scoring ports without a model change.
**Read their numbers carefully.** 91.2% on LoCoMo against 61.68% for Mem0 is
self-reported by the authors with no independent replication found. The 134ms
figure is memory-system latency, not a turn including STT and the resident
model. Both LoCoMo and PersonaMem are English and Chinese, so no claim there
holds for Russian recall until a translated fixture exists. Any number taken
from this section into Maven's prose needs a `docs/evals/` file behind it.
## Open questions
Router+invocation, two-memory routing, presence, and auth were once listed
@@ -1019,7 +1061,7 @@ here and are resolved by the sections above.
- **compound captures** — "slept 6h, fan noise wrecked it" = one fact + one
note in one utterance. Needs a second pass or it loses half.
- **query read-path** — semantic RAG vs a structured read, depending on the
ask.
ask. Prior art in § Prior art — external memory systems (VoiceMem).
- **presence — away tap override** — an explicit `away` tap as a hard
override. Clean extension, deferred; scoring stands without it.
- **presence — weights/τ hand-tuning** — first-guess numbers; expect tuning
@@ -0,0 +1,118 @@
# Slice 1: Typed Ingress Boundary and Route Producer Observability
## 1. Files changed
**New types:**
- `internal/router/source.go``InputSource`, `InputSourceVoice`, `InputSourceText`, `NormalizedInput`
- `internal/router/intent.go``RouteProducer`, `RouteProducerGrammar/Heads/LLM/Classifier`, `Producer` field on `Decision`
**Cascade wiring:**
- `internal/router/router.go``Producer` set at each of the four cascade stages
**Observability:**
- `internal/decision/decision.go``InputSource` and `RouteProducer` fields on `Record`; `With()` accepts `inputSource`
- `internal/store/routingtraces.go``RouteProducer` field on `RoutingTrace`
- `internal/store/migrations.go` — migration #27: `ALTER TABLE routing_traces ADD COLUMN route_producer`
- `cmd/mavend/routingtrace.go` — persists `RouteProducer` from the decision record
**Turn lifecycle:**
- `cmd/mavend/voice.go``turnSource` is now `type turnSource = router.InputSource`; `runTurn` takes `NormalizedInput`; `HandlePushToTalk` and `handleText` construct `NormalizedInput`
- `cmd/mavend/turnroute.go``turnRoute` carries `NormalizedInput`; `newTurnRoute` and `resolve` use it
**Test updates (signature适应):**
- `cmd/mavend/clarify_test.go`, `reactive_notes_test.go`, `reminder_cancel_test.go`, `repair_test.go`, `simulator_test.go`, `turnrole_test.go``runTurn` calls updated to `NormalizedInput`
**New tests:**
- `internal/router/boundary_test.go` — 7 tests: type shape, constants, producer per stage
- `cmd/mavend/boundary_test.go` — 5 tests: convergence, source preservation, producer on record, pre-route empty producer, stage-0 unchanged
## 2. Boundary types introduced/reused
| Type | Package | Kind | Purpose |
|---|---|---|---|
| `NormalizedInput` | `router` | new struct | Typed ingress boundary: `Text string` + `Source InputSource` |
| `InputSource` | `router` | new `string` type | Channel provenance: `tap:voice`, `tap:text` |
| `RouteProducer` | `router` | new `string` type | Cascade stage provenance: `grammar`, `heads`, `llm`, `classifier` |
| `turnSource` | `main` | **alias** for `router.InputSource` | Convenience alias; all existing call sites unchanged |
Reused: `router.Source` (destination), `router.Intent`, `router.Decision`, `decision.Record`.
## 3. Before/after flow diagram
```
BEFORE:
HandlePushToTalk → stt → runTurn(ctx, text, sourceVoice)
handleText → runTurn(ctx, text, sourceText)
runTurn(ctx, text, src):
decision.With(ctx, text)
newTurnRoute(text, now) → rt.text = text
rt.resolve() → router.Route(ctx, text, now)
Decision.Utterance = utterance
[no producer field]
AFTER:
HandlePushToTalk → stt → runTurn(ctx, NormalizedInput{text, sourceVoice})
handleText → runTurn(ctx, NormalizedInput{text, sourceText})
runTurn(ctx, input):
decision.With(ctx, input.Text, input.Source)
newTurnRoute(input, now) → rt.input = input
rt.resolve() → router.Route(ctx, input.Text, now)
Decision.Utterance = utterance
Decision.Producer = grammar|heads|llm|classifier
rec.RouteProducer = dec.Producer
```
## 4. Tests added
**internal/router/boundary_test.go** (7 tests):
- `TestNormalizedInputIsMinimalValueObject` — shape pin
- `TestInputSourceConstants` — tap:voice, tap:text
- `TestRouteProducerConstants` — grammar, heads, llm, classifier
- `TestStage0SetsGrammarProducer` — grammar win carries grammar producer
- `TestClassifierSetsProducer` — classifier floor sets its producer
- `TestClarifyProducerIsClassifier` — clarified turn carries classifier producer
- `TestStage0ProducerOnEveryGrammar` — property test over multiple grammars
**cmd/mavend/boundary_test.go** (5 tests):
- `TestTextAndVoiceConvergeOnNormalizedInput` — same utterance, same route intent
- `TestNormalizedInputSourcePreserved` — source survives to decision record
- `TestRouteProducerOnDecisionRecord` — producer carried to record
- `TestPreRouteClaimHasNoRouteProducer` — confirm-claimed turn has empty producer
- `TestStage0ProducerUnchanged` — grammar stage-0 still produces same intents
## 5. Full test/eval results
| Suite | Before | After |
|---|---|---|
| `go test ./internal/router/` | PASS (1.058s) | PASS (1.568s) |
| `go test ./internal/router/eval/` | PASS (0.783s) | PASS (1.219s) |
| `go test ./cmd/mavend/ -run Simulator` | PASS (1.451s) | PASS (1.753s) |
| `go test ./cmd/mavend/ -run PersonalBoundary` | PASS (0.147s) | PASS (0.183s) |
| `go test ./cmd/mavend/ -run Eval` | PASS (0.015s) | PASS (0.012s) |
| `go test ./cmd/mavend/` (full) | PASS (1.451s) | PASS (22.311s) |
The timing increase in `cmd/mavend` full suite is from the new boundary tests (293 lines of new test code), not from a regression.
## 6. Confirmation that routing outputs and action behavior are unchanged
- `internal/router/eval/` scores the held-out fixture against the same cascade; the number did not move.
- `cmd/mavend -run Simulator` replays deterministic scripted days; all scenario assertions pass identically.
- `cmd/mavend -run PersonalBoundary` exercises the personal boundary query chain; passes identically.
- Stage-0 grammars: same ordering in `StageZeroGrammars()`, same matching semantics, same confidence 1.0.
- `RouteProducer` is a new field with zero value `""` for existing code paths that don't set it; no existing consumer reads it.
- `NormalizedInput` is the same `(text string, source turnSource)` pair passed as a struct; no transformation applied.
## 7. Semantic changes required
**None.** The refactoring is purely structural:
- `turnSource` became a type alias for `router.InputSource` — identical underlying type, no conversion needed at any call site.
- `runTurn` takes `NormalizedInput` instead of `(text, src)` — the destructuring `text := input.Text; src := input.Source` at the top of the function body produces identical local variables.
- `decision.With` gained an `inputSource` parameter — a string stored on the record, never read back during routing.
- `RouteProducer` is a new field on `Decision` — set after the decision is already produced, never consumed by the cascade.
## 8. Commit hashes
```
87a3b16 router: introduce typed ingress boundary and route producer observability
a55d909 router: add boundary tests for typed ingress and route producer
```
@@ -0,0 +1,188 @@
# Slice 3: Structural validation between ActionCandidate resolution and execution
**Date:** 2026-09-05
**Base:** 6a402bf (slice 2 committed)
**Status:** complete
## 1. Files changed
| File | Change |
|------|--------|
| `internal/router/actioncandidate.go` | +76 lines: `ActionField`, `ActionValidationIssue`, `ActionValidationResult`, `ValidateActionCandidate` |
| `internal/router/actioncandidate_test.go` | +98 lines: 6 unit tests for `ValidateActionCandidate` |
| `cmd/mavend/actions_act.go` | +11 lines: validation call, invalid-candidate early return |
| `cmd/mavend/actionresolve.go` | +37 lines: `noteActionValidation` tracing function |
| `cmd/mavend/actionresolve_test.go` | +128 lines: 5 integration tests (malformed, unresolved, destructive, irreversible, tracing) |
## 2. Validation contract
```go
type ActionValidationResult struct {
Unresolved bool // Fn empty → proposeGap path
Valid bool // Fn non-empty, structure sound
Issues []ActionValidationIssue // non-empty when Invalid
}
type ActionValidationIssue struct {
Field ActionField // "fn" or "args"
Reason string // machine-readable tag
}
type ActionField string
const (
FieldFn ActionField = "fn"
FieldArgs ActionField = "args"
)
```
Three disjoint outcomes:
- **Unresolved**: Fn is empty. Not a validation error. Routes to proposeGap / clarification.
- **Valid**: Fn non-empty, structurally admissible. Proceeds to risk policy and execution.
- **Invalid**: Fn non-empty but malformed. Refused with `ActFail`.
## 3. Exact validation rules introduced
| Rule | Check | Outcome on fail |
|------|-------|-----------------|
| `unresolved` | `c.Fn == ""` | Unresolved (not invalid) |
| `blank_function_name` | `strings.TrimSpace(c.Fn) == ""` when Fn is non-empty | Invalid |
The model is deliberately small. This is not a general validation framework.
## 4. Where each rule existed previously
| Rule | Previous location | Migration type |
|------|-------------------|----------------|
| Unresolved → proposeGap | `cmd/mavend/actions_act.go:82` (`!dec.Slots.HasFn`) | Preserved: the existing `!dec.Slots.HasFn` check now comes after validation, same observable behavior |
| Blank Fn → refuse | No previous check existed | New: defensive check for a structurally malformed candidate that the route/matcher should never produce |
**Observation:** The current route and matcher never produce a blank (whitespace-only) Fn. The `blank_function_name` rule is a defensive gate against future producer defects, not a migration from existing behavior.
## 5. Before/after action flow
**Before (slice 2):**
```
RouteDecision → ResolveActionCandidate → ActionCandidate
→ actionAct writes Fn/Args back into dec.Slots
→ refusesCommand check
→ task-status intercept
→ Praxis intercept
→ Hexis intercept
→ !HasFn → proposeGap
→ tool.Executor.Exec → error switch
```
**After (slice 3):**
```
RouteDecision → ResolveActionCandidate → ActionCandidate
→ ValidateActionCandidate
├─ unresolved → (continues to existing flow; !HasFn → proposeGap)
├─ invalid → ActFail (early return)
└─ valid → (continues)
→ actionAct writes Fn/Args back into dec.Slots
→ refusesCommand check
→ task-status intercept
→ Praxis intercept
→ Hexis intercept
→ !HasFn → proposeGap
→ tool.Executor.Exec → error switch
```
The validation gate sits between resolution and the bridge write. Unresolved candidates skip the validation gate entirely and flow through the existing path unchanged.
## 6. How unresolved differs from invalid
| | Unresolved | Invalid |
|---|---|---|
| **Fn** | empty (`""`) | non-empty but malformed (e.g. whitespace-only) |
| **Cause** | Matcher miss, non-act intent | Structural defect in route/matcher output |
| **Trace** | `action-validation:declined:unresolved` | `action-validation:declined:invalid:<reason>` |
| **Response** | proposeGap (existing proposal/scaffold path) | `ActFail` ("не получилось выполнить команду.") |
| **Execution** | does not reach tool executor | does not reach tool executor |
The distinction is preserved: unresolved is "no match found" (the normal case for unknown verbs), while invalid is "a match was found but it's structurally broken" (a defect that should never happen in practice).
## 7. Tests added
### Unit tests (internal/router)
| Test | Pins |
|------|------|
| `TestValidateActionCandidate_UnresolvedEmptyFn` | Empty Fn → unresolved, not invalid |
| `TestValidateActionCandidate_UnresolvedMatcherMiss` | Matcher miss candidate → unresolved |
| `TestValidateActionCandidate_ValidRoute` | Route-resolved candidate → valid |
| `TestValidateActionCandidate_ValidMatcher` | Matcher-resolved candidate → valid |
| `TestValidateActionCandidate_ValidNoArgs` | Zero-arg tool → valid |
| `TestValidateActionCandidate_BlankFn` | Whitespace-only Fn → invalid with `blank_function_name` issue |
### Integration tests (cmd/mavend)
| Test | Pins |
|------|------|
| `TestActValidation_MalformedCandidate_BlankFn` | Blank Fn does not execute, produces response |
| `TestActValidation_UnresolvedCandidate_ProposeGap` | Matcher miss still flows to proposeGap |
| `TestActValidation_DestructiveValid_StillConfirms` | Destructive valid act still triggers confirm turn |
| `TestActValidation_IrreversibleValid_NeedsAuthedSurface` | Irreversible valid act still reaches authed-surface refusal |
| `TestActValidation_ValidationTracing` | Validation outcome recorded in decision trace |
### Existing regression tests preserved (all pass)
| Test | What it pins |
|------|-------------|
| `TestActRouteSource_NoMatcherInvoke` | Route-resolved act executes, matcher not invoked |
| `TestActMatcherSource_FallbackMatch` | Matcher-resolved act executes |
| `TestActMatcherMiss_ProposeGap` | Matcher miss → proposeGap |
| `TestActDestructive_ConfirmationUnchanged` | Destructive → confirm turn |
| `TestActTaskStatus_InterceptUnchanged` | task_status intercepted before tool execution |
| `TestActStage0_SameResult` | Stage-0 grammar act executes |
| `TestActLearnedRouter_NoFn_FallbackMatch` | LLM-routed act without Fn → matcher fallback |
| `TestActPathSpeaksTheTiers` | Risk tiers spoken correctly |
| `TestActionActMarkerReferentMovesOnlyTheNamedStoredTask` | Task-status move unchanged |
| `TestActOffAllowlistIsStillRefused` | Off-allowlist act refused |
## 8. Before/after suite results
**Before:** all tests in `internal/router` and `cmd/mavend` pass.
**After:** all tests pass, including 6 new unit tests and 5 new integration tests.
```
internal/router: PASS (0.940s) — 13 actioncandidate tests (7 resolve + 6 validate)
cmd/mavend: PASS (23.5s) — 21 act-path tests (15 existing + 5 new + 1 tracing)
```
## 9. Confirmation that risk/confirmation/execution behavior is unchanged
- **Risk tiers:** `tool.RiskOf` and `tool.PolicyFor` are not touched. Validation happens before risk policy.
- **Confirmation:** `ErrNeedsConfirm` → confirm turn is unchanged. Tested by `TestActDestructive_StillConfirms`.
- **Irreversible:** `ErrNeedsAuthedSurface` → refusal is unchanged. Tested by `TestActIrreversibleValid_NeedsAuthedSurface`.
- **Execution:** `tool.Executor.Exec` is not modified. Validation is a pure pre-screen.
- **Ecosystem intercepts:** Praxis and Hexis intercepts are unchanged. Validation sits before them; unresolved candidates pass through to them as before.
## 10. Remaining downstream consumers of Decision.Slots
After the bridge write (`dec.Slots.Fn = candidate.Fn`, etc.), the following branches read `dec.Slots`:
| Consumer | File | Reads |
|----------|------|-------|
| `resolveTaskStatus` | `cmd/mavend/actions_task.go:109` | `dec.Slots.Fn` (compared to `TaskStatusFn`) |
| `handlePraxisAct` | `cmd/mavend/ecosystem_acts.go:110` | `dec.Slots.HasFn` (guard), `dec.Slots.Fn`, `dec.Slots.Args` |
| `handleHexisAct` | `cmd/mavend/ecosystem_acts.go:660` | via `ActHasEntityTarget(dec)` which reads `dec.Slots.HasFn`, `dec.Slots.Args`, `dec.Slots.Text` |
| `proposeGap` | `cmd/mavend/confirm.go:191` | `dec.Utterance` (not Slots) |
| `tool.Executor.Exec` | `internal/tool/tool.go:156` | called with `dec.Slots.Fn, dec.Slots.Args` directly |
| `actPhrase` | `cmd/mavend/actions_act.go` | `dec.Slots.Fn, dec.Slots.Args` |
| `park` | `cmd/mavend/confirm.go` | `dec.Slots.Fn, dec.Slots.Args` |
## 11. Whether ActionCandidate can become authoritative in a later slice
Yes. The bridge write is the only thing coupling ActionCandidate to Decision.Slots. In a later slice:
1. `resolveTaskStatus` could accept `ActionCandidate` directly instead of reading `dec.Slots.Fn`.
2. `handlePraxisAct` and `handleHexisAct` could accept the candidate instead of `dec.Slots.HasFn`.
3. `tool.Executor.Exec` already takes `name string, args []string` — it could take the candidate's `Fn` and `Args` directly.
4. `ActHasEntityTarget` could accept `ActionCandidate` instead of `Decision`.
The bridge would be removed once all consumers read from the candidate. No semantic changes required — this is a mechanical refactoring of argument passing.
## 12. Commit hash
Pending commit on top of 6a402bf.
+158
View File
@@ -0,0 +1,158 @@
# Slice 3: Structural validation between ActionCandidate resolution and execution
**Date:** 2026-09-05
**Base:** 6a402bf (slice 2 committed)
**Status:** complete
## Files changed
| File | Lines added |
|------|------------|
| `internal/router/actioncandidate.go` | +76 (types + `ValidateActionCandidate`) |
| `internal/router/actioncandidate_test.go` | +98 (6 unit tests) |
| `cmd/mavend/actions_act.go` | +11 (validation gate in `actionAct`) |
| `cmd/mavend/actionresolve.go` | +37 (`noteActionValidation` tracing) |
| `cmd/mavend/actionresolve_test.go` | +128 (5 integration tests) |
| `docs/reports/2026-09-05-slice3-structural-validation.md` | full report |
## Validation contract
```go
type ActionValidationResult struct {
Unresolved bool
Valid bool
Issues []ActionValidationIssue
}
type ActionValidationIssue struct {
Field ActionField
Reason string
}
type ActionField string
const (
FieldFn ActionField = "fn"
FieldArgs ActionField = "args"
)
```
Three disjoint outcomes:
- **Unresolved**: Fn is empty. Not a validation error. Routes to proposeGap / clarification.
- **Valid**: Fn non-empty, structurally admissible. Proceeds to risk policy and execution.
- **Invalid**: Fn non-empty but malformed. Refused with `ActFail`.
## Exact validation rules
| Rule | Check | Outcome on fail |
|------|-------|-----------------|
| `unresolved` | `c.Fn == ""` | Unresolved (not invalid) |
| `blank_function_name` | `strings.TrimSpace(c.Fn) == ""` when Fn is non-empty | Invalid |
## Before/after action flow
**Before (slice 2):**
```
RouteDecision → ResolveActionCandidate → ActionCandidate
→ actionAct writes Fn/Args back into dec.Slots
→ refusesCommand check
→ task-status intercept
→ Praxis intercept
→ Hexis intercept
→ !HasFn → proposeGap
→ tool.Executor.Exec → error switch
```
**After (slice 3):**
```
RouteDecision → ResolveActionCandidate → ActionCandidate
→ ValidateActionCandidate
├─ unresolved → (continues to existing flow; !HasFn → proposeGap)
├─ invalid → ActFail (early return)
└─ valid → (continues)
→ actionAct writes Fn/Args back into dec.Slots
→ refusesCommand check
→ task-status intercept
→ Praxis intercept
→ Hexis intercept
→ !HasFn → proposeGap
→ tool.Executor.Exec → error switch
```
## How unresolved differs from invalid
| | Unresolved | Invalid |
|---|---|---|
| **Fn** | empty (`""`) | non-empty but malformed (e.g. whitespace-only) |
| **Cause** | Matcher miss, non-act intent | Structural defect in route/matcher output |
| **Trace** | `action-validation:declined:unresolved` | `action-validation:declined:invalid:<reason>` |
| **Response** | proposeGap (existing proposal/scaffold path) | `ActFail` |
| **Execution** | does not reach tool executor | does not reach tool executor |
## Tests added
### Unit tests (internal/router)
| Test | Pins |
|------|------|
| `TestValidateActionCandidate_UnresolvedEmptyFn` | Empty Fn → unresolved, not invalid |
| `TestValidateActionCandidate_UnresolvedMatcherMiss` | Matcher miss candidate → unresolved |
| `TestValidateActionCandidate_ValidRoute` | Route-resolved candidate → valid |
| `TestValidateActionCandidate_ValidMatcher` | Matcher-resolved candidate → valid |
| `TestValidateActionCandidate_ValidNoArgs` | Zero-arg tool → valid |
| `TestValidateActionCandidate_BlankFn` | Whitespace-only Fn → invalid with `blank_function_name` issue |
### Integration tests (cmd/mavend)
| Test | Pins |
|------|------|
| `TestActValidation_MalformedCandidate_BlankFn` | Blank Fn does not execute, produces response |
| `TestActValidation_UnresolvedCandidate_ProposeGap` | Matcher miss still flows to proposeGap |
| `TestActValidation_DestructiveValid_StillConfirms` | Destructive valid act still triggers confirm turn |
| `TestActValidation_IrreversibleValid_NeedsAuthedSurface` | Irreversible valid act still reaches authed-surface refusal |
| `TestActValidation_ValidationTracing` | Validation outcome recorded in decision trace |
### Existing regression tests preserved (all pass)
| Test | What it pins |
|------|-------------|
| `TestActRouteSource_NoMatcherInvoke` | Route-resolved act executes, matcher not invoked |
| `TestActMatcherSource_FallbackMatch` | Matcher-resolved act executes |
| `TestActMatcherMiss_ProposeGap` | Matcher miss → proposeGap |
| `TestActDestructive_ConfirmationUnchanged` | Destructive → confirm turn |
| `TestActTaskStatus_InterceptUnchanged` | task_status intercepted before tool execution |
| `TestActStage0_SameResult` | Stage-0 grammar act executes |
| `TestActLearnedRouter_NoFn_FallbackMatch` | LLM-routed act without Fn → matcher fallback |
| `TestActPathSpeaksTheTiers` | Risk tiers spoken correctly |
| `TestActionActMarkerReferentMovesOnlyTheNamedStoredTask` | Task-status move unchanged |
| `TestActOffAllowlistIsStillRefused` | Off-allowlist act refused |
## Test results
```
internal/router: PASS (1.1s) — 13 actioncandidate tests (7 resolve + 6 validate)
cmd/mavend: PASS (23.4s) — 21 act-path tests (15 existing + 5 new + 1 tracing)
```
## Risk/confirmation/execution behavior unchanged
- **Risk tiers:** `tool.RiskOf` and `tool.PolicyFor` not touched. Validation happens before risk policy.
- **Confirmation:** `ErrNeedsConfirm` → confirm turn unchanged.
- **Irreversible:** `ErrNeedsAuthedSurface` → refusal unchanged.
- **Execution:** `tool.Executor.Exec` not modified. Validation is a pure pre-screen.
- **Ecosystem intercepts:** Praxis and Hexis intercepts unchanged.
## Remaining downstream consumers of Decision.Slots
| Consumer | File | Reads |
|----------|------|-------|
| `resolveTaskStatus` | `cmd/mavend/actions_task.go:109` | `dec.Slots.Fn` |
| `handlePraxisAct` | `cmd/mavend/ecosystem_acts.go:110` | `dec.Slots.HasFn`, `dec.Slots.Fn`, `dec.Slots.Args` |
| `handleHexisAct` | `cmd/mavend/ecosystem_acts.go:660` | via `ActHasEntityTarget(dec)` |
| `proposeGap` | `cmd/mavend/confirm.go:191` | `dec.Utterance` (not Slots) |
| `tool.Executor.Exec` | `internal/tool/tool.go:156` | called with `dec.Slots.Fn, dec.Slots.Args` |
| `actPhrase` | `cmd/mavend/actions_act.go` | `dec.Slots.Fn, dec.Slots.Args` |
| `park` | `cmd/mavend/confirm.go` | `dec.Slots.Fn, dec.Slots.Args` |
## Can ActionCandidate become authoritative?
Yes. The bridge write is the only coupling. In a later slice, downstream consumers can read directly from the candidate. No semantic changes required — mechanical refactoring of argument passing.
+7 -8
View File
@@ -1,6 +1,6 @@
# Session workflow: the five stores and the guards
*Last verified: 2026-08-11 @ 557f5a3*
*Last verified: 2026-08-25 @ 5cae33a*
How a session starts, where each kind of writing belongs, and what the hooks
refuse. `CLAUDE.md` carries the commands. This file carries the reasoning.
@@ -37,9 +37,8 @@ This repo is project **Maven** (ID 2). MCP at `http://localhost:9100/mcp`, or
`http://192.168.1.104:9100/mcp` from workpc. Feature, bug and deploy tasks go
there.
A task holds the goal, the constraints and the assumption ledger. A session
without a task id cannot be resumed by anyone, so a session with none asks for
one first.
A task holds the goal, the constraints and the assumption ledger. A session may
start without one: filing is not a gate on work (owner's call, 2026-08-25).
**Close a finished task with `done: true` and nothing else** (owner's call,
2026-08-07). Do not write a completion summary into the description on the way
@@ -63,13 +62,13 @@ rather than letting the session compact.
## Guards
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. A fresh clone
needs `git config core.hooksPath .githooks`.
One hook in `.githooks/`, tracked, wired with `core.hooksPath`. A fresh clone
needs `git config core.hooksPath .githooks`. `commit-msg` and its `(V-<id>)`
requirement were deleted on 2026-08-25: a subject ref that names a task nobody
filed is a wrong link, not a record.
- `pre-commit` refuses master, and refuses more than 300 changed lines in
non-markdown files. Markdown is exempt and may land as one batch.
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`,
because Gitea autolinks `#123` to a Gitea issue, which is the wrong tracker.
Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh`
blocks further edits past 600 changed lines on a `task/` branch.