9.0 KiB
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
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.RiskOfandtool.PolicyForare not touched. Validation happens before risk policy. - Confirmation:
ErrNeedsConfirm→ confirm turn is unchanged. Tested byTestActDestructive_StillConfirms. - Irreversible:
ErrNeedsAuthedSurface→ refusal is unchanged. Tested byTestActIrreversibleValid_NeedsAuthedSurface. - Execution:
tool.Executor.Execis 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:
resolveTaskStatuscould acceptActionCandidatedirectly instead of readingdec.Slots.Fn.handlePraxisActandhandleHexisActcould accept the candidate instead ofdec.Slots.HasFn.tool.Executor.Execalready takesname string, args []string— it could take the candidate'sFnandArgsdirectly.ActHasEntityTargetcould acceptActionCandidateinstead ofDecision.
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.