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
+76
View File
@@ -1,5 +1,7 @@
package router
import "strings"
// ActionCandidate — the result of action resolution, produced before execution.
// It replaces the implicit ownership split where the router filled Slots.Fn/Args
// and actionAct re-matched when they were absent. One candidate is produced per
@@ -44,6 +46,41 @@ const (
// ActionResolved reports whether the candidate resolved to a function.
func (c ActionCandidate) ActionResolved() bool { return c.Fn != "" }
// --- structural validation ---
// ActionField identifies a structural field of ActionCandidate for validation
// reporting. Kept as a string enum so callers can switch on known values
// without importing a large set.
type ActionField string
const (
FieldFn ActionField = "fn"
FieldArgs ActionField = "args"
)
// ActionValidationIssue records one structural problem found by
// ValidateActionCandidate. Field names which field; Reason is a short
// machine-readable tag, not a human sentence.
type ActionValidationIssue struct {
Field ActionField
Reason string
}
// ActionValidationResult is the typed output of ValidateActionCandidate.
// Three disjoint outcomes:
//
// - Unresolved: Fn is empty — the matcher did not match. The caller should
// route to proposeGap / clarification, not treat this as a structural error.
// - Valid: Fn is non-empty and the candidate is structurally admissible for
// the current execution path.
// - Invalid: Fn is non-empty but the candidate is structurally malformed.
// The caller should refuse execution.
type ActionValidationResult struct {
Unresolved bool
Valid bool
Issues []ActionValidationIssue
}
// ResolveActionCandidate produces an ActionCandidate from a routing decision.
// It is the single boundary between routing and action resolution: everything
// downstream consumes the candidate rather than re-resolving the function.
@@ -93,3 +130,42 @@ func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate {
Confidence: dec.Confidence,
}
}
// ValidateActionCandidate checks whether a resolved ActionCandidate is
// structurally admissible for the current execution path. It answers only
// shape/completeness — not trust, confidence, risk, or semantic correctness.
//
// Three outcomes:
// - Unresolved (Fn empty): the matcher missed. Caller routes to
// proposeGap / clarification. This is NOT a validation error.
// - Valid (Fn non-empty, structure sound): candidate may proceed to
// risk policy and execution.
// - Invalid (Fn non-empty but malformed): candidate must not execute.
//
// Validation does not re-run routing, intent classification, matcher
// resolution, language parsing, or entity inference.
func ValidateActionCandidate(c ActionCandidate) ActionValidationResult {
// Unresolved: Fn is empty. The matcher did not match, or the decision
// was not an act. This flows into the existing propose-gap / proposal
// path and must NOT be treated as a structural error.
if c.Fn == "" {
return ActionValidationResult{Unresolved: true}
}
var issues []ActionValidationIssue
// Fn is present but must be a non-blank identifier. The route and
// matcher both produce bare function names; whitespace-only or
// control-character Fn would be a structural defect in the producer.
if strings.TrimSpace(c.Fn) == "" {
issues = append(issues, ActionValidationIssue{
Field: FieldFn,
Reason: "blank_function_name",
})
}
if len(issues) > 0 {
return ActionValidationResult{Issues: issues}
}
return ActionValidationResult{Valid: true}
}
+98
View File
@@ -149,3 +149,101 @@ func TestResolveActionCandidate_AliasMatch(t *testing.T) {
t.Errorf("Args = %v, want [роутер]", c.Args)
}
}
// --- ValidateActionCandidate tests ---
// TestValidateActionCandidate_UnresolvedEmptyFn pins that an empty Fn
// produces an unresolved result (not invalid).
func TestValidateActionCandidate_UnresolvedEmptyFn(t *testing.T) {
c := ActionCandidate{}
v := ValidateActionCandidate(c)
if !v.Unresolved {
t.Error("expected unresolved for empty Fn")
}
if v.Valid {
t.Error("unresolved must not be valid")
}
}
// TestValidateActionCandidate_UnresolvedMatcherMiss pins that a matcher-miss
// candidate (Fn empty, source empty) is unresolved.
func TestValidateActionCandidate_UnresolvedMatcherMiss(t *testing.T) {
c := ActionCandidate{
Producer: RouteProducerLLM,
Confidence: 0.5,
}
v := ValidateActionCandidate(c)
if !v.Unresolved {
t.Error("expected unresolved for matcher miss")
}
}
// TestValidateActionCandidate_ValidRoute pins that a route-resolved candidate
// with a non-empty Fn is valid.
func TestValidateActionCandidate_ValidRoute(t *testing.T) {
c := ActionCandidate{
Fn: "restart",
Args: []string{"nginx"},
Source: ActionSourceRoute,
}
v := ValidateActionCandidate(c)
if v.Unresolved {
t.Error("expected resolved, not unresolved")
}
if !v.Valid {
t.Errorf("expected valid, got issues: %v", v.Issues)
}
}
// TestValidateActionCandidate_ValidMatcher pins that a matcher-resolved
// candidate with a non-empty Fn is valid.
func TestValidateActionCandidate_ValidMatcher(t *testing.T) {
c := ActionCandidate{
Fn: "status",
Source: ActionSourceMatcher,
}
v := ValidateActionCandidate(c)
if v.Unresolved {
t.Error("expected resolved, not unresolved")
}
if !v.Valid {
t.Errorf("expected valid, got issues: %v", v.Issues)
}
}
// TestValidateActionCandidate_ValidNoArgs pins that a zero-arg tool is valid.
func TestValidateActionCandidate_ValidNoArgs(t *testing.T) {
c := ActionCandidate{
Fn: "status",
Source: ActionSourceRoute,
}
v := ValidateActionCandidate(c)
if v.Unresolved {
t.Error("expected resolved, not unresolved")
}
if !v.Valid {
t.Errorf("expected valid, got issues: %v", v.Issues)
}
}
// TestValidateActionCandidate_BlankFn pins that a whitespace-only Fn is
// structurally invalid (not unresolved).
func TestValidateActionCandidate_BlankFn(t *testing.T) {
c := ActionCandidate{
Fn: " ",
Source: ActionSourceRoute,
}
v := ValidateActionCandidate(c)
if v.Unresolved {
t.Error("blank Fn should be invalid, not unresolved")
}
if v.Valid {
t.Error("blank Fn should be invalid")
}
if len(v.Issues) != 1 {
t.Fatalf("expected 1 issue, got %d", len(v.Issues))
}
if v.Issues[0].Field != FieldFn {
t.Errorf("issue field = %q, want fn", v.Issues[0].Field)
}
}