iop/packages/go/streamgate/evaluation_contract_test.go
toki c90bb755a9 feat: streamline plan/code-review/finalize router, add stream gate SDDs, sync dev-test inventory, update roadmap milestones
- Refactor plan, code-review, finalize-task-routing, refine-local-plans, router skills
- Add agent-workflow-loop-orchestration skill and plan agent configs
- Update roadmap: knowledge-tool-optimization milestones, stream-evidence-gate-core SDD
- Add stream-evidence-gate-core task, archive, and Go streamgate package
- Update dev-test inventory (edge/node smoke), agent-contract, edge-local-dev-guide
- Deprecate USER_REVIEW for output-validation-filters SDD
2026-07-24 15:11:00 +09:00

1020 lines
35 KiB
Go

package streamgate
import (
"context"
"sort"
"strings"
"testing"
"time"
)
// stubEvaluator implements FilterEvaluator for tests. It exposes the id and
// a return decision/error so test cases can drive readiness normalisation and
// outcome composition without touching the guardrail.
type stubEvaluator struct {
id string
decision FilterDecision
err error
evaluateInvoked bool
}
func (s *stubEvaluator) ID() string { return s.id }
func (s *stubEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) {
s.evaluateInvoked = true
return s.decision, s.err
}
// newPassDecision returns a passing FilterDecision for use in readiness and
// outcome composition tests.
func newPassDecision(t *testing.T, filterID string) FilterDecision {
t.Helper()
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
d, err := NewFilterDecision(FilterDecisionKindPass, "consumer.id", filterID, "rule.id", evidence, nil)
if err != nil {
t.Fatalf("newPassDecision: %v", err)
}
return d
}
// newViolationDecision returns a violating FilterDecision for enforcement
// composition tests.
func newViolationDecision(t *testing.T, filterID string) FilterDecision {
t.Helper()
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
d, err := NewFilterDecision(FilterDecisionKindViolation, "consumer.id", filterID, "rule.id", evidence, nil)
if err != nil {
t.Fatalf("newViolationDecision: %v", err)
}
return d
}
// newFatalDecision returns a fatal FilterDecision for enforcement composition tests.
func newFatalDecision(t *testing.T, filterID string) FilterDecision {
t.Helper()
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
d, err := NewFilterDecision(FilterDecisionKindFatal, "consumer.id", filterID, "rule.id", evidence, nil)
if err != nil {
t.Fatalf("newFatalDecision: %v", err)
}
return d
}
// mustEvalError creates a FilterOutcome of kind evaluation_error with a
// stable error code, failing the test on any error.
func mustEvalError(t *testing.T, code string) FilterOutcome {
t.Helper()
o, err := NewFilterOutcomeEvaluationError(code)
if err != nil {
t.Fatalf("NewFilterOutcomeEvaluationError: %v", err)
}
return o
}
// --- [API-1] Resolved epoch filter binding ---
// TestEpochFilterNormalizesApplicabilityAndRequirement verifies the readiness
// normalisation table across all four states. Each matrix entry is driven by
// a distinct stub evaluator so accidental coupling between tests is avoided.
func TestEpochFilterNormalizesApplicabilityAndRequirement(t *testing.T) {
passDecision := newPassDecision(t, "filter.id")
cases := []struct {
name string
subscribed bool
triggerReady bool
blocksRelease bool
enforcement FilterEnforcement
wantForEpoch bool
wantNotApplicable bool
wantDeferred bool
}{
{
name: "subscribed_trigger_ready",
subscribed: true,
triggerReady: true,
blocksRelease: false,
enforcement: FilterEnforcementBlocking,
wantForEpoch: true,
wantNotApplicable: false,
wantDeferred: false,
},
{
name: "subscribed_trigger_not_ready_blocks",
subscribed: true,
triggerReady: false,
blocksRelease: true,
enforcement: FilterEnforcementBlocking,
wantForEpoch: false,
wantNotApplicable: false,
wantDeferred: true,
},
{
name: "subscribed_trigger_not_ready_nonblock",
subscribed: true,
triggerReady: false,
blocksRelease: false,
enforcement: FilterEnforcementObserveOnly,
wantForEpoch: false,
wantNotApplicable: true,
wantDeferred: false,
},
{
name: "unsubscribed",
subscribed: false,
triggerReady: false,
blocksRelease: false,
enforcement: FilterEnforcementBlocking,
wantForEpoch: false,
wantNotApplicable: true,
wantDeferred: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
e := &stubEvaluator{id: "filter.id", decision: passDecision}
f, err := NewEpochFilter(
e,
tc.subscribed, tc.triggerReady, tc.blocksRelease,
tc.enforcement,
100*time.Millisecond,
)
if err != nil {
t.Fatalf("NewEpochFilter: %v", err)
}
if f.EvaluatedForEpoch() != tc.wantForEpoch {
t.Errorf("EvaluatedForEpoch() = %v, want %v", f.EvaluatedForEpoch(), tc.wantForEpoch)
}
oc := f.NormalizeOutcome()
if tc.wantNotApplicable && oc.Kind() != FilterOutcomeKindNotApplicableForEpoch {
t.Errorf("NormalizeOutcome() Kind() = %v, want not_applicable_for_epoch", oc.Kind())
}
if tc.wantDeferred && oc.Kind() != FilterOutcomeKindDeferredByRequirement {
t.Errorf("NormalizeOutcome() Kind() = %v, want deferred_by_requirement", oc.Kind())
}
// Accessors must reflect construction inputs exactly.
if f.SubscribedEventPresent() != tc.subscribed {
t.Errorf("SubscribedEventPresent() = %v, want %v", f.SubscribedEventPresent(), tc.subscribed)
}
if f.TriggerReady() != tc.triggerReady {
t.Errorf("TriggerReady() = %v, want %v", f.TriggerReady(), tc.triggerReady)
}
if f.BlocksRelease() != tc.blocksRelease {
t.Errorf("BlocksRelease() = %v, want %v", f.BlocksRelease(), tc.blocksRelease)
}
if f.EnforcedAs() != tc.enforcement {
t.Errorf("EnforcedAs() = %v, want %v", f.EnforcedAs(), tc.enforcement)
}
})
}
}
// TestEpochFilterRejectsInvalidExecutionContract verifies that NewEpochFilter
// rejects every invalid constructor combination: nil evaluator, empty id,
// invalid id grammar, unknown enforcement, and non-positive timeout.
func TestEpochFilterRejectsInvalidExecutionContract(t *testing.T) {
passDecision := newPassDecision(t, "filter.id")
// --- nil evaluator ---
t.Run("nil_evaluator", func(t *testing.T) {
_, err := NewEpochFilter(
nil,
true, true, false,
FilterEnforcementBlocking,
100*time.Millisecond,
)
if err == nil {
t.Error("NewEpochFilter(nil, ...) should return error")
}
})
// --- empty evaluator id ---
t.Run("empty_evaluator_id", func(t *testing.T) {
_, err := NewEpochFilter(
&stubEvaluator{id: "", decision: passDecision},
true, true, false,
FilterEnforcementBlocking,
100*time.Millisecond,
)
if err == nil {
t.Error("NewEpochFilter(empty id, ...) should return error")
}
})
// --- invalid evaluator id grammar ---
t.Run("invalid_evaluator_id_grammar", func(t *testing.T) {
_, err := NewEpochFilter(
&stubEvaluator{id: "INVALID-ID!", decision: passDecision},
true, true, false,
FilterEnforcementBlocking,
100*time.Millisecond,
)
if err == nil {
t.Error("NewEpochFilter(invalid id grammar, ...) should return error")
}
})
// --- unknown enforcement ---
t.Run("unknown_enforcement", func(t *testing.T) {
_, err := NewEpochFilter(
&stubEvaluator{id: "filter.id", decision: passDecision},
true, true, false,
"unknown",
100*time.Millisecond,
)
if err == nil {
t.Error("NewEpochFilter(unknown enforcement, ...) should return error")
}
})
// --- zero timeout ---
t.Run("zero_timeout", func(t *testing.T) {
_, err := NewEpochFilter(
&stubEvaluator{id: "filter.id", decision: passDecision},
true, true, false,
FilterEnforcementBlocking,
0,
)
if err == nil {
t.Error("NewEpochFilter(zero timeout, ...) should return error")
}
})
// --- negative timeout ---
t.Run("negative_timeout", func(t *testing.T) {
_, err := NewEpochFilter(
&stubEvaluator{id: "filter.id", decision: passDecision},
true, true, false,
FilterEnforcementBlocking,
-1*time.Millisecond,
)
if err == nil {
t.Error("NewEpochFilter(negative timeout, ...) should return error")
}
})
}
// TestEpochFilterTimeoutAndAccessorImmutability verifies that the timeout is
// stored exactly and that accessor reads do not leak mutable state.
func TestEpochFilterTimeoutAndAccessorImmutability(t *testing.T) {
passDecision := newPassDecision(t, "filter.id")
timeout := 42 * time.Millisecond
f, err := NewEpochFilter(
&stubEvaluator{id: "filter.id", decision: passDecision},
true, true, false,
FilterEnforcementObserveOnly,
timeout,
)
if err != nil {
t.Fatalf("NewEpochFilter: %v", err)
}
if f.EvaluationTimeout() != timeout {
t.Errorf("EvaluationTimeout() = %v, want %v", f.EvaluationTimeout(), timeout)
}
// The evaluator is returned by reference; verify that it is the same
// instance but that the accessors do not expose internal mutation paths.
e := f.Evaluator()
if e.ID() != "filter.id" {
t.Errorf("Evaluator().ID() = %q, want %q", e.ID(), "filter.id")
}
}
// --- [API-2] Immutable evaluation outcome set ---
// TestEvaluationSetIsStableAndImmutable verifies that the set sorts by filter
// id regardless of input order, rejects duplicates, and returns defensive
// copies on every accessor path.
func TestEvaluationSetIsStableAndImmutable(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
// Build outcomes in reverse id order to exercise sort stability.
d1, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "beta.id", "rule.id", evidence, nil)
d2, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "alpha.id", "rule.id", evidence, nil)
d3, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "gamma.id", "rule.id", evidence, nil)
ocBeta, _ := NewFilterOutcomeEvaluated(d1)
ocAlpha, _ := NewFilterOutcomeEvaluated(d2)
ocGamma, _ := NewFilterOutcomeEvaluated(d3)
betaO, _ := NewEpochFilterOutcome("beta.id", ocBeta, FilterEnforcementBlocking)
alphaO, _ := NewEpochFilterOutcome("alpha.id", ocAlpha, FilterEnforcementBlocking)
gammaO, _ := NewEpochFilterOutcome("gamma.id", ocGamma, FilterEnforcementBlocking)
// Input in reverse id order: gamma, beta, alpha.
inputs := []EpochFilterOutcome{
gammaO,
betaO,
alphaO,
}
set, err := NewEvaluationSet(inputs)
if err != nil {
t.Fatalf("NewEvaluationSet: %v", err)
}
// Stable order: alpha < beta < gamma.
wantOrder := []string{"alpha.id", "beta.id", "gamma.id"}
for i, w := range wantOrder {
o, err := set.At(i)
if err != nil {
t.Fatalf("At(%d): %v", i, err)
}
if o.FilterID() != w {
t.Errorf("At(%d).FilterID() = %q, want %q", i, o.FilterID(), w)
}
}
// --- ByID lookup succeeds for sorted ids ---
if _, err := set.ByID("alpha.id"); err != nil {
t.Errorf("ByID('alpha.id'): %v", err)
}
if _, err := set.ByID("nonexistent"); err == nil {
t.Error("ByID('nonexistent') should return error")
}
// --- All() returns defensive copy in stable order ---
all := set.All()
if len(all) != 3 {
t.Fatalf("All() len = %d, want 3", len(all))
}
if all[0].FilterID() != "alpha.id" {
t.Errorf("All()[0].FilterID() = %q, want %q", all[0].FilterID(), "alpha.id")
}
// --- Mutating All() does not affect stored ---
all[0] = EpochFilterOutcome{}
if set.Len() != 3 {
t.Errorf("Len() after All() mutation = %d, want 3", set.Len())
}
// --- Duplicate filter id is rejected ---
dupInputs := []EpochFilterOutcome{
alphaO,
alphaO,
}
if _, err := NewEvaluationSet(dupInputs); err == nil {
t.Error("NewEvaluationSet(duplicate id) should return error")
}
}
// TestEpochFilterOutcomeValidatesFailurePolicy verifies that the failure
// disposition is derived correctly from the outcome kind, enforcement mode,
// and decision kind. It also verifies that invalid combinations are rejected.
func TestEpochFilterOutcomeValidatesFailurePolicy(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
_ = ts // reserved for future expansion
cases := []struct {
name string
filterID string
enforcement FilterEnforcement
wantKind FilterOutcomeKind
desiredDisp EvaluationFailureDisposition
}{
{
name: "pass_blocking",
filterID: "f1",
enforcement: FilterEnforcementBlocking,
wantKind: FilterOutcomeKindEvaluated,
desiredDisp: EvaluationFailureDispositionNone,
},
{
name: "pass_observe",
filterID: "f2",
enforcement: FilterEnforcementObserveOnly,
wantKind: FilterOutcomeKindEvaluated,
desiredDisp: EvaluationFailureDispositionNone,
},
{
name: "violation_blocking",
filterID: "f3",
enforcement: FilterEnforcementBlocking,
wantKind: FilterOutcomeKindEvaluated,
desiredDisp: EvaluationFailureDispositionBlockingFatal,
},
{
name: "violation_observe",
filterID: "f4",
enforcement: FilterEnforcementObserveOnly,
wantKind: FilterOutcomeKindEvaluated,
desiredDisp: EvaluationFailureDispositionObserveError,
},
{
name: "fatal_blocking",
filterID: "f5",
enforcement: FilterEnforcementBlocking,
wantKind: FilterOutcomeKindEvaluated,
desiredDisp: EvaluationFailureDispositionBlockingFatal,
},
{
name: "fatal_observe",
filterID: "f6",
enforcement: FilterEnforcementObserveOnly,
wantKind: FilterOutcomeKindEvaluated,
desiredDisp: EvaluationFailureDispositionObserveError,
},
{
name: "not_applicable_blocking",
filterID: "f9",
enforcement: FilterEnforcementBlocking,
wantKind: FilterOutcomeKindNotApplicableForEpoch,
desiredDisp: EvaluationFailureDispositionNone,
},
{
name: "deferred",
filterID: "f10",
enforcement: FilterEnforcementObserveOnly,
wantKind: FilterOutcomeKindDeferredByRequirement,
desiredDisp: EvaluationFailureDispositionNone,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
var oc FilterOutcome
switch tc.name {
case "violation_blocking", "violation_observe":
oc = mustEvalOutcome(t, newViolationDecision(t, tc.filterID))
case "fatal_blocking", "fatal_observe":
oc = mustEvalOutcome(t, newFatalDecision(t, tc.filterID))
case "not_applicable_blocking":
oc = NewFilterOutcomeNotApplicableForEpoch()
case "deferred":
oc = NewFilterOutcomeDeferredByRequirement()
default:
oc = mustEvalOutcome(t, newPassDecision(t, tc.filterID))
}
o, err := NewEpochFilterOutcome(tc.filterID, oc, tc.enforcement)
if err != nil {
t.Fatalf("NewEpochFilterOutcome: %v", err)
}
if o.FailureDisposition() != tc.desiredDisp {
t.Errorf("FailureDisposition() = %v, want %v", o.FailureDisposition(), tc.desiredDisp)
}
if o.Outcome().Kind() != tc.wantKind {
t.Errorf("Outcome().Kind() = %v, want %v", o.Outcome().Kind(), tc.wantKind)
}
if o.FilterID() != tc.filterID {
t.Errorf("FilterID() = %q, want %q", o.FilterID(), tc.filterID)
}
if o.Enforcement() != tc.enforcement {
t.Errorf("Enforcement() = %v, want %v", o.Enforcement(), tc.enforcement)
}
})
}
// --- evaluation_error with blocking/observe_only ---
t.Run("eval_error_blocking", func(t *testing.T) {
eoc := mustEvalError(t, "err.code")
o, err := NewEpochFilterOutcome("f7", eoc, FilterEnforcementBlocking)
if err != nil {
t.Fatalf("NewEpochFilterOutcome: %v", err)
}
if o.Outcome().Kind() != FilterOutcomeKindEvaluationError {
t.Errorf("Outcome().Kind() = %v, want evaluation_error", o.Outcome().Kind())
}
if o.FailureDisposition() != EvaluationFailureDispositionBlockingFatal {
t.Errorf("FailureDisposition() = %v, want %v", o.FailureDisposition(), EvaluationFailureDispositionBlockingFatal)
}
})
t.Run("eval_error_observe", func(t *testing.T) {
eoc := mustEvalError(t, "err.code")
o, err := NewEpochFilterOutcome("f8", eoc, FilterEnforcementObserveOnly)
if err != nil {
t.Fatalf("NewEpochFilterOutcome: %v", err)
}
if o.Outcome().Kind() != FilterOutcomeKindEvaluationError {
t.Errorf("Outcome().Kind() = %v, want evaluation_error", o.Outcome().Kind())
}
if o.FailureDisposition() != EvaluationFailureDispositionObserveError {
t.Errorf("FailureDisposition() = %v, want %v", o.FailureDisposition(), EvaluationFailureDispositionObserveError)
}
})
// --- Invalid combinations are rejected ---
t.Run("invalid_empty_filter_id", func(t *testing.T) {
_, err := NewEpochFilterOutcome("", NewFilterOutcomeNotApplicableForEpoch(), FilterEnforcementBlocking)
if err == nil {
t.Error("NewEpochFilterOutcome(empty id) should return error")
}
})
t.Run("invalid_bad_id_grammar", func(t *testing.T) {
_, err := NewEpochFilterOutcome("BAD!ID", NewFilterOutcomeNotApplicableForEpoch(), FilterEnforcementBlocking)
if err == nil {
t.Error("NewEpochFilterOutcome(bad id grammar) should return error")
}
})
t.Run("invalid_unknown_enforcement", func(t *testing.T) {
_, err := NewEpochFilterOutcome("f.id", NewFilterOutcomeNotApplicableForEpoch(), "unknown")
if err == nil {
t.Error("NewEpochFilterOutcome(unknown enforcement) should return error")
}
})
}
// TestEvaluationSetRejectsInvalidEntries verifies that invalid EpochFilterOutcome
// entries (empty filter id, bad grammar, invalid outcome) are rejected at
// set construction.
func TestEvaluationSetRejectsInvalidEntries(t *testing.T) {
// Empty filter id is rejected at NewEpochFilterOutcome level.
goodOutcome := NewFilterOutcomeNotApplicableForEpoch()
badO, err := NewEpochFilterOutcome("", goodOutcome, FilterEnforcementBlocking)
if err == nil {
t.Error("NewEpochFilterOutcome(empty id) should return error")
}
if badO.FilterID() != "" {
t.Errorf("badO.FilterID() = %q, want empty", badO.FilterID())
}
}
// TestEvaluationSetAccessorReturnsDefensiveCopy verifies that mutating the
// return value of All() does not affect the set's stored outcomes.
func TestEvaluationSetAccessorReturnsDefensiveCopy(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
d, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "filter.id", "rule.id", evidence, nil)
oc, _ := NewFilterOutcomeEvaluated(d)
o, _ := NewEpochFilterOutcome("filter.id", oc, FilterEnforcementBlocking)
set, err := NewEvaluationSet([]EpochFilterOutcome{o})
if err != nil {
t.Fatalf("NewEvaluationSet: %v", err)
}
// --- All() mutation ---
all := set.All()
all[0] = EpochFilterOutcome{}
if set.Len() != 1 {
t.Error("All() replacement leaked to set length")
}
// --- At() element ---
atO, err := set.At(0)
if err != nil {
t.Fatalf("At(0): %v", err)
}
if atO.FilterID() != "filter.id" {
t.Errorf("At(0).FilterID() = %q, want %q", atO.FilterID(), "filter.id")
}
// --- ByID() lookup ---
byID, err := set.ByID("filter.id")
if err != nil {
t.Fatalf("ByID: %v", err)
}
if byID.FilterID() != "filter.id" {
t.Errorf("ByID.FilterID() = %q, want %q", byID.FilterID(), "filter.id")
}
}
// TestEvaluationSetStableOrderAcrossInputOrders verifies that regardless of
// the order the inputs are provided, the resulting EvaluationSet always
// orders outcomes by ascending stable-token id. This ensures Arbiter inputs
// do not depend on completion order.
func TestEvaluationSetStableOrderAcrossInputOrders(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
makeOutcome := func(id string) EpochFilterOutcome {
d, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", id, "rule.id", evidence, nil)
oc, _ := NewFilterOutcomeEvaluated(d)
o, _ := NewEpochFilterOutcome(id, oc, FilterEnforcementBlocking)
return o
}
// Generate a non-sorted order of ids.
ids := []string{"z.id", "a.id", "m.id", "c.id", "y.id", "b.id"}
// Sort them to know the expected order.
sort.Strings(ids)
// Build inputs in random order.
inputs := make([]EpochFilterOutcome, len(ids))
for i, id := range ids {
inputs[i] = makeOutcome(id)
}
set, err := NewEvaluationSet(inputs)
if err != nil {
t.Fatalf("NewEvaluationSet: %v", err)
}
for i, wantID := range ids {
o, err := set.At(i)
if err != nil {
t.Fatalf("At(%d): %v", i, err)
}
if o.FilterID() != wantID {
t.Errorf("At(%d).FilterID() = %q, want %q", i, o.FilterID(), wantID)
}
}
}
// TestEpochFilterOutcomeNoRawErrorPreserved verifies that the outcome stored
// in EpochFilterOutcome does not contain raw error messages; only the
// sanitized stable error code is retained.
func TestEpochFilterOutcomeNoRawErrorPreserved(t *testing.T) {
// Build an evaluation_error outcome with a stable error code.
oc, _ := NewFilterOutcomeEvaluationError("runtime.guardrail.timeout")
o, err := NewEpochFilterOutcome("f.id", oc, FilterEnforcementBlocking)
if err != nil {
t.Fatalf("NewEpochFilterOutcome: %v", err)
}
if o.Outcome().ErrorCode() != "runtime.guardrail.timeout" {
t.Errorf("ErrorCode() = %q, want %q", o.Outcome().ErrorCode(), "runtime.guardrail.timeout")
}
if o.Outcome().Decision() != nil {
t.Error("Evaluation error outcome must not carry a decision")
}
}
// TestEpochFilterNormalizeOutcomeForReadyFilter verifies that a ready filter
// (subscribed + triggerReady) produces an empty FilterOutcome from
// NormalizeOutcome. The coordinator is responsible for wrapping the actual
// evaluation result.
func TestEpochFilterNormalizeOutcomeForReadyFilter(t *testing.T) {
passDecision := newPassDecision(t, "filter.id")
f, err := NewEpochFilter(
&stubEvaluator{id: "filter.id", decision: passDecision},
true, true, false,
FilterEnforcementBlocking,
100*time.Millisecond,
)
if err != nil {
t.Fatalf("NewEpochFilter: %v", err)
}
oc := f.NormalizeOutcome()
// Ready filter has no pre-evaluation outcome; the kind is the zero value.
if oc.Kind() != "" {
t.Errorf("NormalizeOutcome().Kind() = %q, want empty (ready filter has no pre-evaluation outcome)", oc.Kind())
}
}
// TestEvaluationSetEmptyIsValid verifies that an empty EvaluationSet is valid
// and that its accessors behave safely.
func TestEvaluationSetEmptyIsValid(t *testing.T) {
set, err := NewEvaluationSet(nil)
if err != nil {
t.Fatalf("NewEvaluationSet(nil): %v", err)
}
if set.Len() != 0 {
t.Errorf("Len() = %d, want 0", set.Len())
}
if set.All() != nil {
t.Error("All() of empty set should return nil")
}
if _, err := set.At(0); err == nil {
t.Error("At(0) on empty set should return error")
}
if _, err := set.ByID("any"); err == nil {
t.Error("ByID on empty set should return error")
}
}
// TestEpochFilterNoPartialConstructionLeak verifies that the filter
// contract is also invariant under concurrent access — the constructor does
// not produce a filter that can be observed in a partially constructed state.
// This is enforced by the immutable design of the returned value.
func TestEpochFilterNoPartialConstructionLeak(t *testing.T) {
// The constructor validates all fields before returning. Since all
// fields are stored in a struct value (not pointers to shared state),
// a partially constructed EpochFilter can never be observed. This test
// simply verifies that a fully valid filter has stable accessors.
passDecision := newPassDecision(t, "filter.id")
f, err := NewEpochFilter(
&stubEvaluator{id: "filter.id", decision: passDecision},
true, true, false,
FilterEnforcementBlocking,
100*time.Millisecond,
)
if err != nil {
t.Fatalf("NewEpochFilter: %v", err)
}
// All accessors should return consistent values.
if f.ID() != "filter.id" {
t.Errorf("ID() = %q, want %q", f.ID(), "filter.id")
}
if f.EnforcedAs() != FilterEnforcementBlocking {
t.Errorf("EnforcedAs() = %v, want %v", f.EnforcedAs(), FilterEnforcementBlocking)
}
if !f.SubscribedEventPresent() {
t.Error("SubscribedEventPresent() should be true")
}
if !f.TriggerReady() {
t.Error("TriggerReady() should be true")
}
if !f.EvaluatedForEpoch() {
t.Error("EvaluatedForEpoch() should be true for subscribed+triggered filter")
}
}
// TestEpochFilterOutcomeValidateRejection verifies that EpochFilterOutcome.Validate()
// rejects entries with invalid filter ids, invalid outcomes, invalid
// enforcement, and invalid dispositions.
func TestEpochFilterOutcomeValidateRejection(t *testing.T) {
// --- Empty filter id ---
o := EpochFilterOutcome{
filterID: StableToken{},
outcome: NewFilterOutcomeNotApplicableForEpoch(),
enforcement: FilterEnforcementBlocking,
failureDisposition: EvaluationFailureDispositionNone,
}
if err := o.Validate(); err == nil {
t.Error("Validate() with empty filter id should return error")
}
// --- Invalid filter id grammar ---
badToken, _ := NewStableTokenRequired("filterID", "BAD-ID!")
o2 := EpochFilterOutcome{
filterID: badToken,
outcome: NewFilterOutcomeNotApplicableForEpoch(),
enforcement: FilterEnforcementBlocking,
failureDisposition: EvaluationFailureDispositionNone,
}
if err := o2.Validate(); err == nil {
t.Error("Validate() with bad id grammar should return error")
}
// --- Invalid outcome ---
o3 := EpochFilterOutcome{
filterID: mustToken("filter.id"),
outcome: FilterOutcome{},
enforcement: FilterEnforcementBlocking,
failureDisposition: EvaluationFailureDispositionNone,
}
if err := o3.Validate(); err == nil {
t.Error("Validate() with invalid outcome should return error")
}
// --- Invalid enforcement ---
o4 := EpochFilterOutcome{
filterID: mustToken("filter.id"),
outcome: NewFilterOutcomeNotApplicableForEpoch(),
enforcement: "unknown",
failureDisposition: EvaluationFailureDispositionNone,
}
if err := o4.Validate(); err == nil {
t.Error("Validate() with invalid enforcement should return error")
}
}
// TestEpochFilterOutcomeStableTokenGrammar verifies that EpochFilterOutcome
// constructor enforces the stable-token grammar on the filter id.
func TestEpochFilterOutcomeStableTokenGrammar(t *testing.T) {
goodOutcome := NewFilterOutcomeNotApplicableForEpoch()
// --- Uppercase rejected ---
_, err := NewEpochFilterOutcome("Filter.ID", goodOutcome, FilterEnforcementBlocking)
if err == nil {
t.Error("NewEpochFilterOutcome(uppercase id) should return error")
}
// --- Special chars rejected ---
_, err = NewEpochFilterOutcome("filter@id", goodOutcome, FilterEnforcementBlocking)
if err == nil {
t.Error("NewEpochFilterOutcome(special chars id) should return error")
}
// --- Empty rejected ---
_, err = NewEpochFilterOutcome("", goodOutcome, FilterEnforcementBlocking)
if err == nil {
t.Error("NewEpochFilterOutcome(empty id) should return error")
}
// --- Valid grammar accepted ---
_, err = NewEpochFilterOutcome("filter.id-01", goodOutcome, FilterEnforcementBlocking)
if err != nil {
t.Errorf("NewEpochFilterOutcome(valid id) = %v, want nil", err)
}
}
// TestEpochFilterSnapshotsEvaluatorID verifies that EpochFilter.ID() returns
// the evaluator id snapshot taken at construction time, not the evaluator's
// current id after mutation.
func TestEpochFilterSnapshotsEvaluatorID(t *testing.T) {
passDecision := newPassDecision(t, "filter.id")
mutable := &mutableIDEvaluator{id: "filter.id", decision: passDecision}
f, err := NewEpochFilter(
mutable,
true, true, false,
FilterEnforcementBlocking,
100*time.Millisecond,
)
if err != nil {
t.Fatalf("NewEpochFilter: %v", err)
}
// Snapshot must match the id at construction time.
if got := f.ID(); got != "filter.id" {
t.Errorf("ID() = %q, want %q", got, "filter.id")
}
// Mutate the evaluator id.
mutable.id = "mutated.id"
// ID() must still return the original snapshot.
if got := f.ID(); got != "filter.id" {
t.Errorf("ID() after mutation = %q, want %q", got, "filter.id")
}
// Evaluator().ID() must reflect the mutation (seam is preserved).
if got := f.Evaluator().ID(); got != "mutated.id" {
t.Errorf("Evaluator().ID() = %q, want %q", got, "mutated.id")
}
}
// mutableIDEvaluator is a FilterEvaluator whose id can be changed at runtime
// to test that EpochFilter snapshot is not affected by evaluator mutation.
type mutableIDEvaluator struct {
id string
decision FilterDecision
}
func (m *mutableIDEvaluator) ID() string { return m.id }
func (m *mutableIDEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) {
return m.decision, nil
}
// TestEpochFilterOutcomeRejectsDecisionIDMismatch verifies that NewEpochFilterOutcome
// rejects an evaluated outcome whose decision carries a different filter id
// than the constructor's wrapper filter id.
func TestEpochFilterOutcomeRejectsDecisionIDMismatch(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
// Build a decision with a different filter id from the wrapper.
d, err := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "other.id", "rule.id", evidence, nil)
if err != nil {
t.Fatalf("NewFilterDecision: %v", err)
}
_, err = NewEpochFilterOutcome("wrapper.id", mustEvalOutcome(t, d), FilterEnforcementBlocking)
if err == nil {
t.Error("NewEpochFilterOutcome(mismatched decision) should return error")
} else if !strings.Contains(err.Error(), "filter id mismatch") {
t.Errorf("error = %v, want to contain 'filter id mismatch'", err)
}
}
// TestEvaluationSetRejectsForgedFailureDispositionMismatch verifies that a package-local
// forged EpochFilterOutcome (constructed directly without NewEpochFilterOutcome)
// whose failure disposition does not match the outcome kind and enforcement is
// rejected by both Validate() and NewEvaluationSet. Covers representative
// cases from the evaluated, evaluation-error, not-applicable, and deferred families.
func TestEvaluationSetRejectsForgedFailureDispositionMismatch(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
evidence := buildValidEvidence(t, ts)
forgedToken, err := NewStableTokenRequired("filterID", "forged.id")
if err != nil {
t.Fatalf("NewStableTokenRequired: %v", err)
}
// Case 1: evaluated pass + blocking → should be none, but forged as blocking_fatal.
t.Run("evaluated_pass_with_blocking_fatal", func(t *testing.T) {
d, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "forged.id", "rule.id", evidence, nil)
oc, _ := NewFilterOutcomeEvaluated(d)
forged := EpochFilterOutcome{
filterID: forgedToken,
outcome: oc,
enforcement: FilterEnforcementBlocking,
failureDisposition: EvaluationFailureDispositionBlockingFatal,
}
if err := forged.Validate(); err == nil {
t.Error("Validate() should reject forged evaluated+pass with blocking_fatal disposition")
} else if !strings.Contains(err.Error(), "failure disposition mismatch") {
t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err)
}
if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil {
t.Error("NewEvaluationSet should reject forged evaluated+pass with blocking_fatal")
}
})
// Case 2: evaluated violation + observe_only → should be observe_error, but forged as blocking_fatal.
t.Run("evaluated_violation_with_observe_blocking_fatal", func(t *testing.T) {
d, _ := NewFilterDecision(FilterDecisionKindViolation, "consumer.id", "forged.id", "rule.id", evidence, nil)
oc, _ := NewFilterOutcomeEvaluated(d)
forged := EpochFilterOutcome{
filterID: forgedToken,
outcome: oc,
enforcement: FilterEnforcementObserveOnly,
failureDisposition: EvaluationFailureDispositionBlockingFatal,
}
if err := forged.Validate(); err == nil {
t.Error("Validate() should reject forged evaluated+violation with blocking_fatal disposition under observe_only")
} else if !strings.Contains(err.Error(), "failure disposition mismatch") {
t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err)
}
if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil {
t.Error("NewEvaluationSet should reject forged evaluated+violation with blocking_fatal under observe_only")
}
})
// Case 3: evaluation_error + blocking → should be blocking_fatal, but forged as observe_error.
t.Run("eval_error_with_observe_error", func(t *testing.T) {
eoc := mustEvalError(t, "err.code")
forged := EpochFilterOutcome{
filterID: forgedToken,
outcome: eoc,
enforcement: FilterEnforcementBlocking,
failureDisposition: EvaluationFailureDispositionObserveError,
}
if err := forged.Validate(); err == nil {
t.Error("Validate() should reject forged eval_error with observe_error under blocking")
} else if !strings.Contains(err.Error(), "failure disposition mismatch") {
t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err)
}
if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil {
t.Error("NewEvaluationSet should reject forged eval_error with observe_error under blocking")
}
})
// Case 4: not_applicable → should be none, but forged as blocking_fatal.
t.Run("not_applicable_with_blocking_fatal", func(t *testing.T) {
oc := NewFilterOutcomeNotApplicableForEpoch()
forged := EpochFilterOutcome{
filterID: forgedToken,
outcome: oc,
enforcement: FilterEnforcementBlocking,
failureDisposition: EvaluationFailureDispositionBlockingFatal,
}
if err := forged.Validate(); err == nil {
t.Error("Validate() should reject forged not_applicable with blocking_fatal disposition")
} else if !strings.Contains(err.Error(), "failure disposition mismatch") {
t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err)
}
if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil {
t.Error("NewEvaluationSet should reject forged not_applicable with blocking_fatal")
}
})
// Case 5: deferred → should be none, but forged as observe_error.
t.Run("deferred_with_observe_error", func(t *testing.T) {
oc := NewFilterOutcomeDeferredByRequirement()
forged := EpochFilterOutcome{
filterID: forgedToken,
outcome: oc,
enforcement: FilterEnforcementObserveOnly,
failureDisposition: EvaluationFailureDispositionObserveError,
}
if err := forged.Validate(); err == nil {
t.Error("Validate() should reject forged deferred with observe_error disposition")
} else if !strings.Contains(err.Error(), "failure disposition mismatch") {
t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err)
}
if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil {
t.Error("NewEvaluationSet should reject forged deferred with observe_error")
}
})
}
// TestEvaluationSetRejectsForgedDecisionIDMismatch verifies that a package-local
// forged EpochFilterOutcome (constructed directly without NewEpochFilterOutcome)
// whose wrapper filter id does not match the evaluated decision's filter id is
// rejected by both Validate() and NewEvaluationSet(). This is the set-boundary
// regression complement to TestEpochFilterOutcomeRejectsDecisionIDMismatch,
// which validates the constructor boundary.
func TestEvaluationSetRejectsForgedDecisionIDMismatch(t *testing.T) {
forged := EpochFilterOutcome{
filterID: mustToken("wrapper.id"),
outcome: mustEvalOutcome(t, newPassDecision(t, "other.id")),
enforcement: FilterEnforcementBlocking,
failureDisposition: EvaluationFailureDispositionNone,
}
if err := forged.Validate(); err == nil {
t.Fatal("Validate() should reject forged decision id mismatch")
} else if !strings.Contains(err.Error(), "filter id mismatch") {
t.Errorf("Validate error = %v, want to contain 'filter id mismatch'", err)
}
if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil {
t.Fatal("NewEvaluationSet should reject forged decision id mismatch")
}
}
// mustEvalOutcome wraps a FilterDecision into a FilterOutcome of kind
// evaluated, failing the test on any error.
func mustEvalOutcome(t *testing.T, d FilterDecision) FilterOutcome {
t.Helper()
o, err := NewFilterOutcomeEvaluated(d)
if err != nil {
t.Fatalf("NewFilterOutcomeEvaluated: %v", err)
}
return o
}
// ensure unused imports are consumed.
var _ = context.Background
var _ = sort.Strings