# 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:` | | **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.