mavend: centralize action validation boundary (slice 4)
This commit is contained in:
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user