package streamgate import ( "context" "errors" "fmt" "sort" ) // ArbitrationAction identifies the single deterministic action returned by // the decision arbiter for an epoch. type ArbitrationAction string const ( // ArbitrationActionRelease indicates the batch is eligible for release. ArbitrationActionRelease ArbitrationAction = "release" // ArbitrationActionHold indicates the batch must be buffered downstream. ArbitrationActionHold ArbitrationAction = "hold" // ArbitrationActionTerminal indicates the stream must be terminated. ArbitrationActionTerminal ArbitrationAction = "terminal" // ArbitrationActionRecover indicates a recovery workflow must be triggered. ArbitrationActionRecover ArbitrationAction = "recover" // ArbitrationActionReplacement indicates replacement content must be substituted. ArbitrationActionReplacement ArbitrationAction = "replacement" ) var knownArbitrationActions = map[ArbitrationAction]struct{}{ ArbitrationActionRelease: {}, ArbitrationActionHold: {}, ArbitrationActionTerminal: {}, ArbitrationActionRecover: {}, ArbitrationActionReplacement: {}, } // Validate returns nil when the ArbitrationAction is a known value. func (a ArbitrationAction) Validate() error { if _, ok := knownArbitrationActions[a]; ok { return nil } return errors.New("streamgate: unknown arbitration action: " + string(a)) } // ArbitrationResult is the immutable, closed output produced by DecisionArbiter. type ArbitrationResult struct { action ArbitrationAction baseDisposition BaseEventDisposition filterID string ruleID string intent *RecoveryIntent replacement *ReplacementProposal } // NewArbitrationResult constructs a validated, immutable ArbitrationResult. func NewArbitrationResult( action ArbitrationAction, baseDisp BaseEventDisposition, filterID string, ruleID string, intent *RecoveryIntent, replacement *ReplacementProposal, ) (ArbitrationResult, error) { if err := action.Validate(); err != nil { return ArbitrationResult{}, errors.New("streamgate: arbitration result action: " + err.Error()) } if err := baseDisp.Validate(); err != nil { return ArbitrationResult{}, errors.New("streamgate: arbitration result base disposition: " + err.Error()) } if filterID != "" { if err := validateStableTokenRequired("filterID", filterID); err != nil { return ArbitrationResult{}, errors.New("streamgate: arbitration result filter id: " + err.Error()) } } if ruleID != "" { if filterID == "" { return ArbitrationResult{}, errors.New("streamgate: arbitration result rule id requires filter id") } if err := validateStableTokenRequired("ruleID", ruleID); err != nil { return ArbitrationResult{}, errors.New("streamgate: arbitration result rule id: " + err.Error()) } } var intentCopy *RecoveryIntent if intent != nil { if err := intent.Validate(); err != nil { return ArbitrationResult{}, errors.New("streamgate: arbitration result recovery intent: " + err.Error()) } cp := *intent intentCopy = &cp } var replacementCopy *ReplacementProposal if replacement != nil { if err := replacement.Validate(); err != nil { return ArbitrationResult{}, errors.New("streamgate: arbitration result replacement proposal: " + err.Error()) } cp := cloneReplacementProposal(replacement) replacementCopy = cp } switch action { case ArbitrationActionRelease, ArbitrationActionHold: if filterID != "" { return ArbitrationResult{}, errors.New("streamgate: release and hold actions must not carry filter id") } if ruleID != "" { return ArbitrationResult{}, errors.New("streamgate: release and hold actions must not carry rule id") } if intentCopy != nil { return ArbitrationResult{}, errors.New("streamgate: release and hold actions must not carry recovery intent") } if replacementCopy != nil { return ArbitrationResult{}, errors.New("streamgate: release and hold actions must not carry replacement proposal") } case ArbitrationActionTerminal: if intentCopy != nil { return ArbitrationResult{}, errors.New("streamgate: terminal action must not carry recovery intent") } if replacementCopy != nil { return ArbitrationResult{}, errors.New("streamgate: terminal action must not carry replacement proposal") } case ArbitrationActionRecover: if filterID == "" { return ArbitrationResult{}, errors.New("streamgate: recover action requires filter id") } if ruleID == "" { return ArbitrationResult{}, errors.New("streamgate: recover action requires rule id") } if intentCopy == nil { return ArbitrationResult{}, errors.New("streamgate: recover action requires recovery intent") } if replacementCopy != nil { return ArbitrationResult{}, errors.New("streamgate: recover action must not carry replacement proposal") } case ArbitrationActionReplacement: if filterID == "" { return ArbitrationResult{}, errors.New("streamgate: replacement action requires filter id") } if ruleID == "" { return ArbitrationResult{}, errors.New("streamgate: replacement action requires rule id") } if replacementCopy == nil { return ArbitrationResult{}, errors.New("streamgate: replacement action requires replacement proposal") } if intentCopy != nil { return ArbitrationResult{}, errors.New("streamgate: replacement action must not carry recovery intent") } } return ArbitrationResult{ action: action, baseDisposition: baseDisp, filterID: filterID, ruleID: ruleID, intent: intentCopy, replacement: replacementCopy, }, nil } // Validate returns nil when the ArbitrationResult is in a consistent state. func (r ArbitrationResult) Validate() error { if err := r.action.Validate(); err != nil { return err } if err := r.baseDisposition.Validate(); err != nil { return err } if r.filterID != "" { if err := validateStableTokenRequired("filterID", r.filterID); err != nil { return err } } if r.ruleID != "" { if r.filterID == "" { return errors.New("streamgate: arbitration result rule id requires filter id") } if err := validateStableTokenRequired("ruleID", r.ruleID); err != nil { return err } } switch r.action { case ArbitrationActionRelease, ArbitrationActionHold: if r.filterID != "" || r.ruleID != "" || r.intent != nil || r.replacement != nil { return errors.New("streamgate: release and hold actions must not carry filter, rule, intent, or replacement") } case ArbitrationActionTerminal: if r.intent != nil || r.replacement != nil { return errors.New("streamgate: terminal action must not carry intent or replacement") } case ArbitrationActionRecover: if r.filterID == "" || r.ruleID == "" || r.intent == nil || r.replacement != nil { return errors.New("streamgate: recover action requires filter id, rule id, and intent, and no replacement") } if err := r.intent.Validate(); err != nil { return err } case ArbitrationActionReplacement: if r.filterID == "" || r.ruleID == "" || r.replacement == nil || r.intent != nil { return errors.New("streamgate: replacement action requires filter id, rule id, and replacement proposal, and no intent") } if err := r.replacement.Validate(); err != nil { return err } } return nil } // Action returns the derived arbitration action. func (r ArbitrationResult) Action() ArbitrationAction { return r.action } // BaseDisposition returns the original base event disposition. func (r ArbitrationResult) BaseDisposition() BaseEventDisposition { return r.baseDisposition } // FilterID returns the selected filter id, or empty. func (r ArbitrationResult) FilterID() string { return r.filterID } // RuleID returns the selected rule id, or empty. func (r ArbitrationResult) RuleID() string { return r.ruleID } // RecoveryIntent returns a defensive copy of the recovery intent, or nil. func (r ArbitrationResult) RecoveryIntent() *RecoveryIntent { if r.intent == nil { return nil } cp := *r.intent return &cp } // ReplacementProposal returns a defensive copy of the replacement proposal, or nil. func (r ArbitrationResult) ReplacementProposal() *ReplacementProposal { return cloneReplacementProposal(r.replacement) } // DecisionArbiter implements EpochArbiter using the closed S13 decision matrix. type DecisionArbiter struct{} // NewDecisionArbiter creates a new DecisionArbiter instance. func NewDecisionArbiter() *DecisionArbiter { return &DecisionArbiter{} } // Arbitrate synthesizes the evidence batch base disposition and all active // filter outcomes into a single deterministic ArbitrationResult. func (a *DecisionArbiter) Arbitrate( ctx context.Context, batch EvidenceBatch, set EvaluationSet, ) (ArbitrationResult, error) { return Arbitrate(ctx, batch, set) } // Arbitrate synthesizes the evidence batch base disposition and all active // filter outcomes into a single deterministic ArbitrationResult. func Arbitrate( ctx context.Context, batch EvidenceBatch, set EvaluationSet, ) (ArbitrationResult, error) { if ctx.Err() != nil { return ArbitrationResult{}, ctx.Err() } baseDisp, err := batch.BaseDisposition() if err != nil { return ArbitrationResult{}, fmt.Errorf("streamgate: arbitrate base disposition: %w", err) } if err := set.Validate(); err != nil { return ArbitrationResult{}, fmt.Errorf("streamgate: arbitrate evaluation set: %w", err) } type candidate struct { filterID string ruleID string priority int kind string // "terminal", "recover", "replacement" intent *RecoveryIntent replacement *ReplacementProposal } var terminalFailures []candidate var overrideCandidates []candidate hasBlockingDeferred := false for _, o := range set.All() { if o.Enforcement() != FilterEnforcementBlocking { // Observe-only outcomes are not blocking action candidates. continue } switch o.Outcome().Kind() { case FilterOutcomeKindEvaluated: decision := o.Outcome().Decision() if decision == nil { return ArbitrationResult{}, errors.New("streamgate: arbitrate evaluated outcome missing decision") } switch decision.Kind() { case FilterDecisionKindFatal: terminalFailures = append(terminalFailures, candidate{ filterID: o.FilterID(), ruleID: decision.RuleID(), priority: o.Priority(), kind: "terminal", }) case FilterDecisionKindViolation: intent := decision.RecoveryIntent() if intent != nil { if intent.Priority() != o.Priority() { return ArbitrationResult{}, fmt.Errorf( "streamgate: recovery intent priority %d mismatch with filter %s effective priority %d", intent.Priority(), o.FilterID(), o.Priority(), ) } overrideCandidates = append(overrideCandidates, candidate{ filterID: o.FilterID(), ruleID: decision.RuleID(), priority: o.Priority(), kind: "recover", intent: intent, }) } else { terminalFailures = append(terminalFailures, candidate{ filterID: o.FilterID(), ruleID: decision.RuleID(), priority: o.Priority(), kind: "terminal", }) } case FilterDecisionKindReplacement: proposal := decision.ReplacementProposal() overrideCandidates = append(overrideCandidates, candidate{ filterID: o.FilterID(), ruleID: decision.RuleID(), priority: o.Priority(), kind: "replacement", replacement: proposal, }) case FilterDecisionKindPass, FilterDecisionKindObserve: // Pass and observe do not override. } case FilterOutcomeKindEvaluationError: terminalFailures = append(terminalFailures, candidate{ filterID: o.FilterID(), ruleID: "", priority: o.Priority(), kind: "terminal", }) case FilterOutcomeKindDeferredByRequirement: if batch.TerminalFlag() { terminalFailures = append(terminalFailures, candidate{ filterID: o.FilterID(), ruleID: "", priority: o.Priority(), kind: "terminal", }) } else { hasBlockingDeferred = true } case FilterOutcomeKindNotApplicableForEpoch: // Not applicable does not block release. } } sortCandidates := func(c []candidate) { sort.SliceStable(c, func(i, j int) bool { if c[i].priority != c[j].priority { return c[i].priority > c[j].priority // Descending priority } return c[i].filterID < c[j].filterID // Ascending filter id token }) } // 1. Any blocking terminal failure takes absolute precedence over recover/replacement. if len(terminalFailures) > 0 { sortCandidates(terminalFailures) winner := terminalFailures[0] return NewArbitrationResult( ArbitrationActionTerminal, baseDisp, winner.filterID, winner.ruleID, nil, nil, ) } // 2. Recover / Replacement overrides. if len(overrideCandidates) > 0 { sortCandidates(overrideCandidates) winner := overrideCandidates[0] if winner.kind == "recover" { return NewArbitrationResult( ArbitrationActionRecover, baseDisp, winner.filterID, winner.ruleID, winner.intent, nil, ) } else if winner.kind == "replacement" { return NewArbitrationResult( ArbitrationActionReplacement, baseDisp, winner.filterID, winner.ruleID, nil, winner.replacement, ) } } // 3. Blocking deferred requirement in a non-terminal batch yields hold. if hasBlockingDeferred { return NewArbitrationResult( ArbitrationActionHold, baseDisp, "", "", nil, nil, ) } // 4. Default base disposition action. var defaultAction ArbitrationAction switch baseDisp { case BaseDispositionHold: defaultAction = ArbitrationActionHold case BaseDispositionReleaseCandidate: defaultAction = ArbitrationActionRelease case BaseDispositionTerminalSuccessCandidate, BaseDispositionTerminalErrorCandidate: defaultAction = ArbitrationActionTerminal default: return ArbitrationResult{}, fmt.Errorf("streamgate: unhandled base disposition: %s", baseDisp) } return NewArbitrationResult( defaultAction, baseDisp, "", "", nil, nil, ) }