iop/packages/go/streamgate/decision_arbiter_test.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())
}
}