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()) } }