1392 lines
53 KiB
Go
1392 lines
53 KiB
Go
package streamgate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func buildValidEvent(t *testing.T, kind EventKind, ts time.Time) NormalizedEvent {
|
|
t.Helper()
|
|
var ev NormalizedEvent
|
|
var err error
|
|
switch kind {
|
|
case EventKindResponseStart:
|
|
ev, err = NewResponseStartEvent("ch", 200, map[string]string{}, ts)
|
|
case EventKindTextDelta:
|
|
ev, err = NewTextDeltaEvent("ch", "delta", ts)
|
|
case EventKindTerminal:
|
|
ev, err = NewTerminalEvent("ch", ts)
|
|
case EventKindProviderError:
|
|
desc, _ := NewExternalDescriptor("provider_error", "err", "provider_failure", "")
|
|
ev, err = NewProviderErrorEvent("ch", desc, FailureCauseChain{}, ts)
|
|
default:
|
|
t.Fatalf("unhandled event kind: %v", kind)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("buildValidEvent %v: %v", kind, err)
|
|
}
|
|
return ev
|
|
}
|
|
|
|
// TestEvidenceBatchBaseDisposition verifies the canonical base disposition logic:
|
|
// - terminal batch uses the disposition of the single terminal or provider-error control event.
|
|
// - non-terminal batch uses the disposition of the last event in Events().
|
|
// - empty events with staged response start returns hold.
|
|
// - empty events with no staged response start returns an error.
|
|
func TestEvidenceBatchBaseDisposition(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
startEv := buildValidEvent(t, EventKindResponseStart, ts)
|
|
deltaEv := buildValidEvent(t, EventKindTextDelta, ts)
|
|
termEv := buildValidEvent(t, EventKindTerminal, ts)
|
|
errEv := buildValidEvent(t, EventKindProviderError, ts)
|
|
|
|
// 1. Non-terminal batch: [startEv, deltaEv] -> last event is deltaEv -> release_candidate
|
|
b1, err := NewEvidenceBatch(
|
|
[]NormalizedEvent{startEv, deltaEv},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch b1: %v", err)
|
|
}
|
|
disp1, err := b1.BaseDisposition()
|
|
if err != nil {
|
|
t.Fatalf("b1.BaseDisposition: %v", err)
|
|
}
|
|
if disp1 != BaseDispositionReleaseCandidate {
|
|
t.Errorf("b1 disposition = %v, want %v", disp1, BaseDispositionReleaseCandidate)
|
|
}
|
|
|
|
// 2. Terminal batch: [deltaEv, termEv] -> terminal control event -> terminal_success_candidate
|
|
b2, err := NewEvidenceBatch(
|
|
[]NormalizedEvent{deltaEv, termEv},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch b2: %v", err)
|
|
}
|
|
disp2, err := b2.BaseDisposition()
|
|
if err != nil {
|
|
t.Fatalf("b2.BaseDisposition: %v", err)
|
|
}
|
|
if disp2 != BaseDispositionTerminalSuccessCandidate {
|
|
t.Errorf("b2 disposition = %v, want %v", disp2, BaseDispositionTerminalSuccessCandidate)
|
|
}
|
|
|
|
// 3. Provider error terminal batch: [errEv] -> terminal_error_candidate
|
|
b3, err := NewEvidenceBatch(
|
|
[]NormalizedEvent{errEv},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch b3: %v", err)
|
|
}
|
|
disp3, err := b3.BaseDisposition()
|
|
if err != nil {
|
|
t.Fatalf("b3.BaseDisposition: %v", err)
|
|
}
|
|
if disp3 != BaseDispositionTerminalErrorCandidate {
|
|
t.Errorf("b3 disposition = %v, want %v", disp3, BaseDispositionTerminalErrorCandidate)
|
|
}
|
|
|
|
// 4. Staged response start only: empty events, valid stagedStart -> hold
|
|
stagedStart, err := NewResponseStart("ch", 200, map[string]string{}, ts)
|
|
if err != nil {
|
|
t.Fatalf("NewResponseStart: %v", err)
|
|
}
|
|
b4, err := NewEvidenceBatch(
|
|
nil,
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
&stagedStart, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch b4: %v", err)
|
|
}
|
|
disp4, err := b4.BaseDisposition()
|
|
if err != nil {
|
|
t.Fatalf("b4.BaseDisposition: %v", err)
|
|
}
|
|
if disp4 != BaseDispositionHold {
|
|
t.Errorf("b4 disposition = %v, want %v", disp4, BaseDispositionHold)
|
|
}
|
|
|
|
// 5. Empty events and no staged start -> error
|
|
bEmpty := EvidenceBatch{capturedAt: ts, commitState: CommitStateTransportUncommitted}
|
|
if _, err := bEmpty.BaseDisposition(); err == nil {
|
|
t.Error("bEmpty.BaseDisposition() should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultValidationAndImmutability verifies constructor validation,
|
|
// required/forbidden fields, and defensive copying of nested objects in ArbitrationResult.
|
|
func TestArbitrationResultValidationAndImmutability(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv := buildValidEvent(t, EventKindTextDelta, ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
// Release action with filterID or intent must be rejected
|
|
if _, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "f.1", "", nil, nil); err == nil {
|
|
t.Error("Release action with filterID should return error")
|
|
}
|
|
if _, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "", "", &intent, nil); err == nil {
|
|
t.Error("Release action with intent should return error")
|
|
}
|
|
|
|
// Recover action requires filterID and intent
|
|
if _, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "", "r.1", &intent, nil); err == nil {
|
|
t.Error("Recover action without filterID should return error")
|
|
}
|
|
if _, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", nil, nil); err == nil {
|
|
t.Error("Recover action without intent should return error")
|
|
}
|
|
|
|
// Replacement action requires filterID and replacement
|
|
if _, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, nil); err == nil {
|
|
t.Error("Replacement action without replacement proposal should return error")
|
|
}
|
|
|
|
// Valid Recover result accessor immutability
|
|
res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionRecover {
|
|
t.Errorf("Action() = %v, want %v", res.Action(), ArbitrationActionRecover)
|
|
}
|
|
if res.FilterID() != "f.1" || res.RuleID() != "r.1" {
|
|
t.Errorf("FilterID/RuleID = %s/%s, want f.1/r.1", res.FilterID(), res.RuleID())
|
|
}
|
|
gotIntent := res.RecoveryIntent()
|
|
if gotIntent == nil || gotIntent.Priority() != 100 {
|
|
t.Errorf("RecoveryIntent() priority = %v, want 100", gotIntent)
|
|
}
|
|
|
|
// Mutate local intent reference; res.RecoveryIntent() must remain unaffected
|
|
intent = RecoveryIntent{}
|
|
if res.RecoveryIntent() == nil || res.RecoveryIntent().Priority() != 100 {
|
|
t.Error("ArbitrationResult RecoveryIntent was mutated by caller")
|
|
}
|
|
|
|
// Valid Replacement result
|
|
resRep, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult replacement: %v", err)
|
|
}
|
|
if resRep.Action() != ArbitrationActionReplacement {
|
|
t.Errorf("Action() = %v, want %v", resRep.Action(), ArbitrationActionReplacement)
|
|
}
|
|
if resRep.ReplacementProposal() == nil || len(resRep.ReplacementProposal().Events()) != 1 {
|
|
t.Error("ReplacementProposal accessor failed")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultReplacementRejectsRecoveryIntent verifies that a
|
|
// replacement action with a recovery intent attached is rejected by both the
|
|
// constructor and Validate. This is the regression for the original bug where
|
|
// intentCopy = nil silently erased a caller-provided intent when a replacement
|
|
// was present.
|
|
func TestArbitrationResultReplacementRejectsRecoveryIntent(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv := buildValidEvent(t, EventKindTextDelta, ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
// Constructor: replacement + intent must be rejected.
|
|
_, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", &intent, &prop)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult(replacement, intent, replacement) should return error")
|
|
} else if !strings.Contains(err.Error(), "must not carry recovery intent") {
|
|
t.Errorf("error = %v, want 'must not carry recovery intent'", err)
|
|
}
|
|
|
|
// Validate must also reject the same state.
|
|
res := ArbitrationResult{
|
|
action: ArbitrationActionReplacement,
|
|
baseDisposition: baseDisp,
|
|
filterID: "f.1",
|
|
ruleID: "r.1",
|
|
intent: &intent,
|
|
replacement: &prop,
|
|
}
|
|
if err := res.Validate(); err == nil {
|
|
t.Error("Validate() on replacement+intent must return error")
|
|
} else if !strings.Contains(err.Error(), "must not carry recovery intent") && !strings.Contains(err.Error(), "and no intent") {
|
|
t.Errorf("Validate error = %v, want 'must not carry recovery intent' or 'and no intent'", err)
|
|
}
|
|
|
|
// The caller's intent must remain unchanged after the failed constructor.
|
|
if intent.Priority() != 100 {
|
|
t.Errorf("caller intent was mutated: priority = %d", intent.Priority())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultRecoverReplacementRequireRuleID verifies that recover
|
|
// and replacement actions reject empty ruleID.
|
|
func TestArbitrationResultRecoverReplacementRequireRuleID(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv := buildValidEvent(t, EventKindTextDelta, ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
// Recover without ruleID.
|
|
_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "", &intent, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult(recover, empty ruleID) should return error")
|
|
} else if !strings.Contains(err.Error(), "requires rule id") {
|
|
t.Errorf("error = %v, want 'requires rule id'", err)
|
|
}
|
|
|
|
// Replacement without ruleID.
|
|
_, err = NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "", nil, &prop)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult(replacement, empty ruleID) should return error")
|
|
} else if !strings.Contains(err.Error(), "requires rule id") {
|
|
t.Errorf("error = %v, want 'requires rule id'", err)
|
|
}
|
|
|
|
// Validate must also reject empty ruleID for recover.
|
|
resRecover := ArbitrationResult{
|
|
action: ArbitrationActionRecover,
|
|
baseDisposition: baseDisp,
|
|
filterID: "f.1",
|
|
ruleID: "",
|
|
intent: &intent,
|
|
}
|
|
if err := resRecover.Validate(); err == nil {
|
|
t.Error("Validate() on recover with empty ruleID must return error")
|
|
} else if !strings.Contains(err.Error(), "requires filter id, rule id, and intent") {
|
|
t.Errorf("Validate error = %v, want 'requires filter id, rule id, and intent'", err)
|
|
}
|
|
|
|
// Validate must also reject empty ruleID for replacement.
|
|
resReplace := ArbitrationResult{
|
|
action: ArbitrationActionReplacement,
|
|
baseDisposition: baseDisp,
|
|
filterID: "f.1",
|
|
ruleID: "",
|
|
replacement: &prop,
|
|
}
|
|
if err := resReplace.Validate(); err == nil {
|
|
t.Error("Validate() on replacement with empty ruleID must return error")
|
|
} else if !strings.Contains(err.Error(), "requires filter id, rule id, and replacement proposal") {
|
|
t.Errorf("Validate error = %v, want 'requires filter id, rule id, and replacement proposal'", err)
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultRecoverRequiresIntentAndFilter verifies that the
|
|
// constructor rejects recover without intent or without filter, and that
|
|
// Validate agrees.
|
|
func TestArbitrationResultRecoverRequiresIntentAndFilter(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
|
|
// Recover without intent.
|
|
_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", nil, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult(recover, no intent) should return error")
|
|
}
|
|
|
|
// Recover without filter.
|
|
_, err = NewArbitrationResult(ArbitrationActionRecover, baseDisp, "", "r.1", &intent, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult(recover, no filter) should return error")
|
|
}
|
|
|
|
// Validate must also reject.
|
|
res := ArbitrationResult{
|
|
action: ArbitrationActionRecover,
|
|
baseDisposition: baseDisp,
|
|
filterID: "",
|
|
ruleID: "r.1",
|
|
intent: &intent,
|
|
}
|
|
if err := res.Validate(); err == nil {
|
|
t.Error("Validate() on recover without filter must return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultReplacementRequiresProposal verifies that the
|
|
// constructor rejects replacement without a replacement proposal.
|
|
func TestArbitrationResultReplacementRequiresProposal(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
|
|
// Replacement without proposal.
|
|
_, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult(replacement, no proposal) should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultRecoverRejectsReplacement verifies that recover rejects
|
|
// a non-nil replacement proposal.
|
|
func TestArbitrationResultRecoverRejectsReplacement(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv := buildValidEvent(t, EventKindTextDelta, ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, &prop)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult(recover, with replacement) should return error")
|
|
} else if !strings.Contains(err.Error(), "must not carry replacement proposal") {
|
|
t.Errorf("error = %v, want 'must not carry replacement proposal'", err)
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultDeterministicAcrossPermutations verifies that the same
|
|
// set of outcomes in any input order produces an identical ArbitrationResult.
|
|
func TestArbitrationResultDeterministicAcrossPermutations(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
makeOutcome := func(id string, priority int, decision FilterDecision) EpochFilterOutcome {
|
|
oc, _ := NewFilterOutcomeEvaluated(decision)
|
|
o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
|
|
return o
|
|
}
|
|
|
|
// Two recover candidates with different priorities.
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent100, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
violDec1, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "beta.filter", "r.beta", evidence, &intent100)
|
|
intent200, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 200)
|
|
violDec2, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "alpha.filter", "r.alpha", evidence, &intent200)
|
|
|
|
// Order 1: low priority first, high priority second.
|
|
set1, _ := NewEvaluationSet([]EpochFilterOutcome{
|
|
makeOutcome("beta.filter", 100, violDec1),
|
|
makeOutcome("alpha.filter", 200, violDec2),
|
|
})
|
|
|
|
// Order 2: high priority first, low priority second.
|
|
set2, _ := NewEvaluationSet([]EpochFilterOutcome{
|
|
makeOutcome("alpha.filter", 200, violDec2),
|
|
makeOutcome("beta.filter", 100, violDec1),
|
|
})
|
|
|
|
// Order 3: reversed from order 1.
|
|
set3, _ := NewEvaluationSet([]EpochFilterOutcome{
|
|
makeOutcome("alpha.filter", 200, violDec2),
|
|
makeOutcome("beta.filter", 100, violDec1),
|
|
})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res1, err := Arbitrate(context.Background(), eq, set1)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate set1: %v", err)
|
|
}
|
|
res2, err := Arbitrate(context.Background(), eq, set2)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate set2: %v", err)
|
|
}
|
|
res3, err := Arbitrate(context.Background(), eq, set3)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate set3: %v", err)
|
|
}
|
|
|
|
// All three must agree on action, filterID, ruleID, and intent priority.
|
|
checkResult := func(r ArbitrationResult, label string) {
|
|
if r.Action() != ArbitrationActionRecover {
|
|
t.Errorf("%s: Action = %v, want recover", label, r.Action())
|
|
}
|
|
if r.FilterID() != "alpha.filter" {
|
|
t.Errorf("%s: FilterID = %s, want alpha.filter", label, r.FilterID())
|
|
}
|
|
if r.RuleID() != "r.alpha" {
|
|
t.Errorf("%s: RuleID = %s, want r.alpha", label, r.RuleID())
|
|
}
|
|
if r.RecoveryIntent() == nil || r.RecoveryIntent().Priority() != 200 {
|
|
t.Errorf("%s: RecoveryIntent priority = %v, want 200", label, r.RecoveryIntent())
|
|
}
|
|
}
|
|
checkResult(res1, "set1")
|
|
checkResult(res2, "set2")
|
|
checkResult(res3, "set3")
|
|
}
|
|
|
|
// TestArbitrationResultTerminalPrecedenceOverRecoverReplacement verifies that
|
|
// terminal failures always take precedence over recover/replacement candidates,
|
|
// regardless of priority.
|
|
func TestArbitrationResultTerminalPrecedenceOverRecoverReplacement(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
makeOutcome := func(id string, kind FilterDecisionKind, priority int, decision FilterDecision) EpochFilterOutcome {
|
|
oc, _ := NewFilterOutcomeEvaluated(decision)
|
|
o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
|
|
return o
|
|
}
|
|
|
|
// Fatal at priority 1 with recover at priority 999.
|
|
fatalDec, _ := NewFilterDecision(FilterDecisionKindFatal, "c.1", "fatal.filter", "r.fatal", evidence, nil)
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent999, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 999)
|
|
violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "recover.filter", "r.recover", evidence, &intent999)
|
|
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{
|
|
makeOutcome("fatal.filter", FilterDecisionKindFatal, 1, fatalDec),
|
|
makeOutcome("recover.filter", FilterDecisionKindViolation, 999, violDec),
|
|
})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionTerminal {
|
|
t.Errorf("Action = %v, want terminal (fatal beats high-priority recover)", res.Action())
|
|
}
|
|
if res.FilterID() != "fatal.filter" {
|
|
t.Errorf("FilterID = %s, want fatal.filter", res.FilterID())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultObserveOnlyExcludedFromOverride verifies that observe-only
|
|
// outcomes are never selected as action candidates.
|
|
func TestArbitrationResultObserveOnlyExcludedFromOverride(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
makeOutcome := func(id string, decision FilterDecision, enf FilterEnforcement) EpochFilterOutcome {
|
|
oc, _ := NewFilterOutcomeEvaluated(decision)
|
|
o, _ := NewEpochFilterOutcome(id, 100, oc, enf)
|
|
return o
|
|
}
|
|
|
|
// Only observe-only violation.
|
|
violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "obs.filter", "r.1", evidence, nil)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{
|
|
makeOutcome("obs.filter", violDec, FilterEnforcementObserveOnly),
|
|
})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionRelease {
|
|
t.Errorf("Action = %v, want release (observe-only must not override)", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultRecoverVsReplacementPriority verifies that when both
|
|
// recover and replacement candidates exist, they are sorted together by
|
|
// priority desc / filterID asc, and the highest-priority candidate wins.
|
|
func TestArbitrationResultRecoverVsReplacementPriority(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
makeEvalOutcome := func(id string, priority int, decision FilterDecision) EpochFilterOutcome {
|
|
oc, _ := NewFilterOutcomeEvaluated(decision)
|
|
o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
|
|
return o
|
|
}
|
|
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent100, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "recover.filter", "r.recover", evidence, &intent100)
|
|
|
|
repEv, _ := NewTextDeltaEvent("ch", "replacement", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
replDec, _ := NewFilterDecisionWithReplacement(FilterDecisionKindReplacement, "c.1", "repl.filter", "r.repl", evidence, nil, &prop)
|
|
|
|
// Recover at priority 100, replacement at priority 200.
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{
|
|
makeEvalOutcome("recover.filter", 100, violDec),
|
|
makeEvalOutcome("repl.filter", 200, replDec),
|
|
})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
// Replacement has higher priority (200 > 100), so it should win.
|
|
if res.Action() != ArbitrationActionReplacement {
|
|
t.Errorf("Action = %v, want replacement (higher priority wins)", res.Action())
|
|
}
|
|
if res.FilterID() != "repl.filter" {
|
|
t.Errorf("FilterID = %s, want repl.filter", res.FilterID())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultEmptySetDefaultAction verifies that an empty evaluation
|
|
// set falls through to the base disposition action.
|
|
func TestArbitrationResultEmptySetDefaultAction(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Non-terminal batch -> release_candidate -> release.
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
set, _ := NewEvaluationSet(nil)
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate empty set: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionRelease {
|
|
t.Errorf("Action = %v, want release", res.Action())
|
|
}
|
|
|
|
// Terminal success batch -> terminal_success_candidate -> terminal.
|
|
eq2, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
res2, err := Arbitrate(context.Background(), eq2, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate empty terminal set: %v", err)
|
|
}
|
|
if res2.Action() != ArbitrationActionTerminal {
|
|
t.Errorf("Action = %v, want terminal", res2.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultDeferredNonTerminalHold verifies that blocking deferred
|
|
// in a non-terminal batch yields hold.
|
|
func TestArbitrationResultDeferredNonTerminalHold(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
oc := NewFilterOutcomeDeferredByRequirement()
|
|
ef, _ := NewEpochFilterOutcome("defer.filter", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionHold {
|
|
t.Errorf("Action = %v, want hold", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultDeferredTerminalBatchYieldsTerminal verifies that
|
|
// blocking deferred in a terminal batch yields terminal.
|
|
func TestArbitrationResultDeferredTerminalBatchYieldsTerminal(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
oc := NewFilterOutcomeDeferredByRequirement()
|
|
ef, _ := NewEpochFilterOutcome("defer.filter", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionTerminal {
|
|
t.Errorf("Action = %v, want terminal", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultIntentPriorityMismatchError verifies that a violation
|
|
// decision whose recovery intent has a different priority than the filter's
|
|
// effective priority is rejected.
|
|
func TestArbitrationResultIntentPriorityMismatchError(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent50, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 50)
|
|
violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "f.1", "r.1", evidence, &intent50)
|
|
|
|
oc, _ := NewFilterOutcomeEvaluated(violDec)
|
|
ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
_, err := Arbitrate(context.Background(), eq, set)
|
|
if err == nil {
|
|
t.Error("Arbitrate with intent priority mismatch should return error")
|
|
} else if !strings.Contains(err.Error(), "priority 50 mismatch") {
|
|
t.Errorf("error = %v, want priority mismatch", err)
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultRecoveryIntentPreserved verifies that the recovery intent
|
|
// priority and directive are preserved through the constructor.
|
|
func TestArbitrationResultRecoveryIntentPreserved(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 42)
|
|
|
|
res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
|
|
ri := res.RecoveryIntent()
|
|
if ri == nil {
|
|
t.Fatal("RecoveryIntent() returned nil")
|
|
}
|
|
if ri.Priority() != 42 {
|
|
t.Errorf("RecoveryIntent().Priority() = %d, want 42", ri.Priority())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultReplacementProposalPreserved verifies that the replacement
|
|
// proposal events are preserved through the constructor.
|
|
func TestArbitrationResultReplacementProposalPreserved(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
res, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
|
|
rp := res.ReplacementProposal()
|
|
if rp == nil {
|
|
t.Fatal("ReplacementProposal() returned nil")
|
|
}
|
|
events := rp.Events()
|
|
if len(events) != 1 {
|
|
t.Fatalf("ReplacementProposal events = %d, want 1", len(events))
|
|
}
|
|
text, _ := events[0].AsTextDelta()
|
|
if text != "replaced" {
|
|
t.Errorf("ReplacementProposal event text = %q, want 'replaced'", text)
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultDefensiveCopyOnCallerMutation verifies that mutating
|
|
// the caller's intent and replacement after construction does not affect
|
|
// the stored result.
|
|
func TestArbitrationResultDefensiveCopyOnCallerMutation(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
// Mutate intent after construction.
|
|
_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
intent2, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 999)
|
|
_ = intent2
|
|
// Re-construct to verify defensive copy.
|
|
res2, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult 2: %v", err)
|
|
}
|
|
// The stored intent should still have priority 100.
|
|
if res2.RecoveryIntent().Priority() != 100 {
|
|
t.Errorf("Stored intent priority = %d, want 100", res2.RecoveryIntent().Priority())
|
|
}
|
|
|
|
// Mutate replacement after construction.
|
|
_, err = NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult replacement: %v", err)
|
|
}
|
|
repEv2, _ := NewTextDeltaEvent("ch", "mutated", ts)
|
|
prop2, _ := NewReplacementProposal([]NormalizedEvent{repEv2})
|
|
_ = prop2
|
|
// Re-construct to verify defensive copy.
|
|
resRep2, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult replacement 2: %v", err)
|
|
}
|
|
if len(resRep2.ReplacementProposal().Events()) != 1 {
|
|
t.Fatalf("Stored replacement events = %d, want 1", len(resRep2.ReplacementProposal().Events()))
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultFilterRuleIDValidation verifies that filterID and ruleID
|
|
// are validated for stable token grammar.
|
|
func TestArbitrationResultFilterRuleIDValidation(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
|
|
// Bad filter id grammar.
|
|
_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "BAD-ID!", "r.1", nil, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult with bad filter id should return error")
|
|
}
|
|
|
|
// Bad rule id grammar.
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
_, err = NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "BAD-ID!", &intent, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult with bad rule id should return error")
|
|
}
|
|
|
|
// Rule id without filter id.
|
|
_, err = NewArbitrationResult(ArbitrationActionRecover, baseDisp, "", "r.1", &intent, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult with rule id but no filter id should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultBaseDispositionValidation verifies that invalid base
|
|
// dispositions are rejected.
|
|
func TestArbitrationResultBaseDispositionValidation(t *testing.T) {
|
|
badDisp := BaseEventDisposition("invalid")
|
|
_, err := NewArbitrationResult(ArbitrationActionRelease, badDisp, "", "", nil, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult with invalid base disposition should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultActionValidation verifies that invalid actions are rejected.
|
|
func TestArbitrationResultActionValidation(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
badAction := ArbitrationAction("invalid")
|
|
_, err := NewArbitrationResult(badAction, baseDisp, "", "", nil, nil)
|
|
if err == nil {
|
|
t.Error("NewArbitrationResult with invalid action should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultReleaseHoldNoFilterRule verifies that release and hold
|
|
// actions reject filterID and ruleID.
|
|
func TestArbitrationResultReleaseHoldNoFilterRule(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
|
|
// Release with filterID.
|
|
_, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "f.1", "", nil, nil)
|
|
if err == nil {
|
|
t.Error("Release with filterID should return error")
|
|
}
|
|
|
|
// Release with ruleID.
|
|
_, err = NewArbitrationResult(ArbitrationActionRelease, baseDisp, "f.1", "r.1", nil, nil)
|
|
if err == nil {
|
|
t.Error("Release with ruleID should return error")
|
|
}
|
|
|
|
// Hold with filterID.
|
|
_, err = NewArbitrationResult(ArbitrationActionHold, baseDisp, "f.1", "", nil, nil)
|
|
if err == nil {
|
|
t.Error("Hold with filterID should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultTerminalNoIntentReplacement verifies that terminal
|
|
// action rejects intent and replacement.
|
|
func TestArbitrationResultTerminalNoIntentReplacement(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionTerminalSuccessCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
// Terminal with intent.
|
|
_, err := NewArbitrationResult(ArbitrationActionTerminal, baseDisp, "", "", &intent, nil)
|
|
if err == nil {
|
|
t.Error("Terminal with intent should return error")
|
|
}
|
|
|
|
// Terminal with replacement.
|
|
_, err = NewArbitrationResult(ArbitrationActionTerminal, baseDisp, "", "", nil, &prop)
|
|
if err == nil {
|
|
t.Error("Terminal with replacement should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultUnsetContextReturnsError verifies that Arbitrate returns
|
|
// the context error when the context is already cancelled.
|
|
func TestArbitrationResultUnsetContextReturnsError(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
set, _ := NewEvaluationSet(nil)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := Arbitrate(ctx, eq, set)
|
|
if err == nil {
|
|
t.Error("Arbitrate with cancelled context should return error")
|
|
} else if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultBaseDispositionPreserved verifies that the base
|
|
// disposition from the evidence batch is preserved in the result.
|
|
func TestArbitrationResultBaseDispositionPreserved(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
// Terminal error candidate.
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindProviderError, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
set, _ := NewEvaluationSet(nil)
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.BaseDisposition() != BaseDispositionTerminalErrorCandidate {
|
|
t.Errorf("BaseDisposition = %v, want terminal_error_candidate", res.BaseDisposition())
|
|
}
|
|
|
|
// Terminal success candidate.
|
|
eq2, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
res2, err := Arbitrate(context.Background(), eq2, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res2.BaseDisposition() != BaseDispositionTerminalSuccessCandidate {
|
|
t.Errorf("BaseDisposition = %v, want terminal_success_candidate", res2.BaseDisposition())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultDecisionArbiterWrapper verifies that DecisionArbiter
|
|
// delegates to the package-level Arbitrate function.
|
|
func TestArbitrationResultDecisionArbiterWrapper(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "f.1", "r.1", evidence, &intent)
|
|
oc, _ := NewFilterOutcomeEvaluated(violDec)
|
|
ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
arbiter := NewDecisionArbiter()
|
|
res, err := arbiter.Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("DecisionArbiter.Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionRecover {
|
|
t.Errorf("Action = %v, want recover", res.Action())
|
|
}
|
|
if res.FilterID() != "f.1" || res.RuleID() != "r.1" {
|
|
t.Errorf("FilterID/RuleID = %s/%s, want f.1/r.1", res.FilterID(), res.RuleID())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultMultipleRecoverCandidatesSort verifies that multiple
|
|
// recover candidates are sorted by priority desc, filterID asc.
|
|
func TestArbitrationResultMultipleRecoverCandidatesSort(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
makeOutcome := func(id string, priority int, decision FilterDecision) EpochFilterOutcome {
|
|
oc, _ := NewFilterOutcomeEvaluated(decision)
|
|
o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
|
|
return o
|
|
}
|
|
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent100, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
violDec1, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "z.filter", "r.1", evidence, &intent100)
|
|
violDec2, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "a.filter", "r.2", evidence, &intent100)
|
|
|
|
// Same priority, different filter IDs: a.filter should win over z.filter.
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{
|
|
makeOutcome("z.filter", 100, violDec1),
|
|
makeOutcome("a.filter", 100, violDec2),
|
|
})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.FilterID() != "a.filter" {
|
|
t.Errorf("FilterID = %s, want a.filter (asc tie-breaker)", res.FilterID())
|
|
}
|
|
if res.RuleID() != "r.2" {
|
|
t.Errorf("RuleID = %s, want r.2", res.RuleID())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultEvaluationErrorTerminal verifies that evaluation errors
|
|
// produce terminal outcomes.
|
|
func TestArbitrationResultEvaluationErrorTerminal(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
evalError, _ := NewFilterOutcomeEvaluationError("runtime.guardrail.timeout")
|
|
ef, _ := NewEpochFilterOutcome("err.filter", 100, evalError, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionTerminal {
|
|
t.Errorf("Action = %v, want terminal", res.Action())
|
|
}
|
|
if res.FilterID() != "err.filter" {
|
|
t.Errorf("FilterID = %s, want err.filter", res.FilterID())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultViolationNoIntentTerminal verifies that a violation
|
|
// decision without a recovery intent produces terminal.
|
|
func TestArbitrationResultViolationNoIntentTerminal(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "f.1", "r.1", evidence, nil)
|
|
oc, _ := NewFilterOutcomeEvaluated(violDec)
|
|
ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionTerminal {
|
|
t.Errorf("Action = %v, want terminal (violation without intent)", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultPassDecisionDoesNotOverride verifies that pass decisions
|
|
// do not override the base disposition.
|
|
func TestArbitrationResultPassDecisionDoesNotOverride(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
passDec, _ := NewFilterDecision(FilterDecisionKindPass, "c.1", "f.1", "r.1", evidence, nil)
|
|
oc, _ := NewFilterOutcomeEvaluated(passDec)
|
|
ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
// Terminal batch with pass -> terminal.
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionTerminal {
|
|
t.Errorf("Action = %v, want terminal (pass does not override terminal base)", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultNotApplicableDoesNotBlock verifies that not_applicable
|
|
// outcomes do not block release.
|
|
func TestArbitrationResultNotApplicableDoesNotBlock(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
oc := NewFilterOutcomeNotApplicableForEpoch()
|
|
ef, _ := NewEpochFilterOutcome("na.filter", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionRelease {
|
|
t.Errorf("Action = %v, want release (not_applicable does not block)", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultStagedStartHold verifies that a staged response start
|
|
// batch with no active outcomes yields hold.
|
|
func TestArbitrationResultStagedStartHold(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
st, _ := NewResponseStart("ch", 200, map[string]string{}, ts)
|
|
eq, _ := NewEvidenceBatch(nil, map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{}, &st, false, CommitStateTransportUncommitted, ts)
|
|
set, _ := NewEvaluationSet(nil)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionHold {
|
|
t.Errorf("Action = %v, want hold", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultReplacementProposalEventsPreserved verifies that the
|
|
// replacement proposal events are deep-copied and accessible.
|
|
func TestArbitrationResultReplacementProposalEventsPreserved(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
repEv1, _ := NewTextDeltaEvent("ch", "event1", ts)
|
|
repEv2, _ := NewTextDeltaEvent("ch", "event2", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv1, repEv2})
|
|
|
|
res, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
|
|
events := res.ReplacementProposal().Events()
|
|
if len(events) != 2 {
|
|
t.Fatalf("Events count = %d, want 2", len(events))
|
|
}
|
|
text1, _ := events[0].AsTextDelta()
|
|
text2, _ := events[1].AsTextDelta()
|
|
if text1 != "event1" || text2 != "event2" {
|
|
t.Errorf("Events = [%q, %q], want [event1, event2]", text1, text2)
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultRecoverIntentDefensiveCopy verifies that the recovery
|
|
// intent returned by RecoveryIntent() is a defensive copy.
|
|
func TestArbitrationResultRecoverIntentDefensiveCopy(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
|
|
res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
|
|
// Get the intent twice; they should be independent copies.
|
|
ri1 := res.RecoveryIntent()
|
|
ri2 := res.RecoveryIntent()
|
|
if ri1 == ri2 {
|
|
t.Error("RecoveryIntent() should return independent copies")
|
|
}
|
|
if ri1.Priority() != ri2.Priority() {
|
|
t.Errorf("Priorities differ: %d vs %d", ri1.Priority(), ri2.Priority())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultReplacementDefensiveCopy verifies that the replacement
|
|
// proposal returned by ReplacementProposal() is a defensive copy.
|
|
func TestArbitrationResultReplacementDefensiveCopy(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
res, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
|
|
rp1 := res.ReplacementProposal()
|
|
rp2 := res.ReplacementProposal()
|
|
if rp1 == rp2 {
|
|
t.Error("ReplacementProposal() should return independent copies")
|
|
}
|
|
if len(rp1.Events()) != len(rp2.Events()) {
|
|
t.Errorf("Event counts differ: %d vs %d", len(rp1.Events()), len(rp2.Events()))
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultFilterIDAndRuleIDAccessors verifies that FilterID() and
|
|
// RuleID() return the correct values.
|
|
func TestArbitrationResultFilterIDAndRuleIDAccessors(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
|
|
res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "my.filter.id", "my.rule.id", &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
|
|
if res.FilterID() != "my.filter.id" {
|
|
t.Errorf("FilterID() = %q, want 'my.filter.id'", res.FilterID())
|
|
}
|
|
if res.RuleID() != "my.rule.id" {
|
|
t.Errorf("RuleID() = %q, want 'my.rule.id'", res.RuleID())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultEmptyFilterRuleIDForRelease verifies that release action
|
|
// returns empty filterID and ruleID.
|
|
func TestArbitrationResultEmptyFilterRuleIDForRelease(t *testing.T) {
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
res, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "", "", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
|
|
if res.FilterID() != "" {
|
|
t.Errorf("FilterID() = %q, want empty", res.FilterID())
|
|
}
|
|
if res.RuleID() != "" {
|
|
t.Errorf("RuleID() = %q, want empty", res.RuleID())
|
|
}
|
|
if res.RecoveryIntent() != nil {
|
|
t.Error("RecoveryIntent() should be nil for release")
|
|
}
|
|
if res.ReplacementProposal() != nil {
|
|
t.Error("ReplacementProposal() should be nil for release")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultHoldActionNoIntentReplacement verifies that hold action
|
|
// rejects intent and replacement.
|
|
func TestArbitrationResultHoldActionNoIntentReplacement(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionHold
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
_, err := NewArbitrationResult(ArbitrationActionHold, baseDisp, "", "", &intent, nil)
|
|
if err == nil {
|
|
t.Error("Hold with intent should return error")
|
|
}
|
|
|
|
_, err = NewArbitrationResult(ArbitrationActionHold, baseDisp, "", "", nil, &prop)
|
|
if err == nil {
|
|
t.Error("Hold with replacement should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultRecoverWithReplacementError verifies that recover with
|
|
// replacement proposal is rejected.
|
|
func TestArbitrationResultRecoverWithReplacementError(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, &prop)
|
|
if err == nil {
|
|
t.Error("Recover with replacement should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultReplacementWithIntentError verifies that replacement
|
|
// with intent is rejected.
|
|
func TestArbitrationResultReplacementWithIntentError(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
_, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", &intent, &prop)
|
|
if err == nil {
|
|
t.Error("Replacement with intent should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultValidateConsistentWithConstructor verifies that the
|
|
// constructor and Validate agree on what is valid and invalid.
|
|
func TestArbitrationResultValidateConsistentWithConstructor(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
baseDisp := BaseDispositionReleaseCandidate
|
|
dir, _ := NewRecoveryDirectiveExact("req.ref")
|
|
intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
|
|
repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
|
|
prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
|
|
|
|
// Valid recover.
|
|
res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult: %v", err)
|
|
}
|
|
if err := res.Validate(); err != nil {
|
|
t.Errorf("Validate() on valid recover = %v", err)
|
|
}
|
|
|
|
// Valid replacement.
|
|
resRep, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
|
|
if err != nil {
|
|
t.Fatalf("NewArbitrationResult replacement: %v", err)
|
|
}
|
|
if err := resRep.Validate(); err != nil {
|
|
t.Errorf("Validate() on valid replacement = %v", err)
|
|
}
|
|
|
|
// Invalid recover (no ruleID) must fail Validate.
|
|
badRecover := ArbitrationResult{
|
|
action: ArbitrationActionRecover,
|
|
baseDisposition: baseDisp,
|
|
filterID: "f.1",
|
|
ruleID: "",
|
|
intent: &intent,
|
|
}
|
|
if err := badRecover.Validate(); err == nil {
|
|
t.Error("Validate() on recover without ruleID should return error")
|
|
}
|
|
|
|
// Invalid replacement (no ruleID) must fail Validate.
|
|
badReplace := ArbitrationResult{
|
|
action: ArbitrationActionReplacement,
|
|
baseDisposition: baseDisp,
|
|
filterID: "f.1",
|
|
ruleID: "",
|
|
replacement: &prop,
|
|
}
|
|
if err := badReplace.Validate(); err == nil {
|
|
t.Error("Validate() on replacement without ruleID should return error")
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultDeferredByRequirementTerminalBatch verifies that
|
|
// blocking deferred in a terminal batch yields terminal.
|
|
func TestArbitrationResultDeferredByRequirementTerminalBatch(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, true, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
oc := NewFilterOutcomeDeferredByRequirement()
|
|
ef, _ := NewEpochFilterOutcome("defer.filter", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionTerminal {
|
|
t.Errorf("Action = %v, want terminal", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultNonBlockingDeferredDoesNotBlock verifies that a
|
|
// non-blocking deferred outcome does not produce hold.
|
|
func TestArbitrationResultNonBlockingDeferredDoesNotBlock(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
// Non-blocking deferred (subscribed + trigger not ready + !blocksRelease).
|
|
oc := NewFilterOutcomeNotApplicableForEpoch()
|
|
ef, _ := NewEpochFilterOutcome("nonblock.defer", 100, oc, FilterEnforcementBlocking)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionRelease {
|
|
t.Errorf("Action = %v, want release (non-blocking deferred does not block)", res.Action())
|
|
}
|
|
}
|
|
|
|
// TestArbitrationResultObserveOnlyViolationDoesNotOverride verifies that
|
|
// observe-only violations do not override the base action.
|
|
func TestArbitrationResultObserveOnlyViolationDoesNotOverride(t *testing.T) {
|
|
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
evidence := buildValidEvidence(t, ts)
|
|
|
|
violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "obs.filter", "r.1", evidence, nil)
|
|
oc, _ := NewFilterOutcomeEvaluated(violDec)
|
|
ef, _ := NewEpochFilterOutcome("obs.filter", 100, oc, FilterEnforcementObserveOnly)
|
|
set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})
|
|
|
|
eq, _ := NewEvidenceBatch(
|
|
[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
|
|
map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
|
|
nil, false, CommitStateTransportUncommitted, ts,
|
|
)
|
|
|
|
res, err := Arbitrate(context.Background(), eq, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != ArbitrationActionRelease {
|
|
t.Errorf("Action = %v, want release (observe-only violation does not override)", res.Action())
|
|
}
|
|
}
|