6.4 KiB
6.4 KiB
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
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.RiskOfandtool.PolicyFornot touched. Validation happens before risk policy. - Confirmation:
ErrNeedsConfirm→ confirm turn unchanged. - Irreversible:
ErrNeedsAuthedSurface→ refusal unchanged. - Execution:
tool.Executor.Execnot 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.