159 lines
6.4 KiB
Markdown
159 lines
6.4 KiB
Markdown
# 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.
|