package streamgate import ( "context" "errors" "fmt" "slices" ) const ( // MaxRequestFaultRecoveryAttempts is the MVP absolute request-total cap. // The initial provider attempt is not counted. MaxRequestFaultRecoveryAttempts = 3 ) var ( // ErrRecoveryCallerCanceled indicates that request cancellation won the // recovery race. ErrRecoveryCallerCanceled = errors.New("streamgate: recovery caller canceled") // ErrRecoveryTerminalCommitted indicates that the downstream terminal was // already committed. ErrRecoveryTerminalCommitted = errors.New("streamgate: recovery terminal already committed") // ErrRecoverySideEffect indicates that retry could duplicate a completed // tool call or another externally visible side effect. ErrRecoverySideEffect = errors.New("streamgate: recovery blocked by side effect") // ErrRecoveryPlanReentry indicates that the same plan id was already used // for this request. ErrRecoveryPlanReentry = errors.New("streamgate: recovery plan re-entry") // ErrRecoveryCommitIneligible indicates that the strategy cannot run in the // current downstream commit state. ErrRecoveryCommitIneligible = errors.New("streamgate: recovery strategy is not eligible for commit state") // ErrRecoveryRequestBudgetExhausted is checked before any strategy-specific // cap so changing strategies cannot bypass the request-total cap. ErrRecoveryRequestBudgetExhausted = errors.New("streamgate: request fault recovery budget exhausted") // ErrRecoveryPlanNotReady indicates that optional preparation has not // produced a final typed directive yet. ErrRecoveryPlanNotReady = errors.New("streamgate: recovery plan is not ready for rebuild or dispatch") // ErrRecoveryStrategyBudgetExhausted indicates the selected fault strategy // has no remaining dispatches. ErrRecoveryStrategyBudgetExhausted = errors.New("streamgate: strategy fault recovery budget exhausted") // ErrManagedTrajectoryExhausted indicates that attempt, caller, or context // allowance leaves no tokens for another managed continuation. ErrManagedTrajectoryExhausted = errors.New("streamgate: managed continuation trajectory exhausted") // ErrRecoverySnapshotLimitExceeded indicates that a snapshot reference or // rebuilt request exceeded its retained or peak memory limits. ErrRecoverySnapshotLimitExceeded = errors.New("streamgate: recovery snapshot limit exceeded") ) var faultRecoveryStrategies = []RecoveryStrategy{ RecoveryStrategyExactReplay, RecoveryStrategyContinuationRepair, RecoveryStrategySchemaRepair, } func isFaultRecoveryStrategy(strategy RecoveryStrategy) bool { switch strategy { case RecoveryStrategyExactReplay, RecoveryStrategyContinuationRepair, RecoveryStrategySchemaRepair: return true default: return false } } // RecoveryResumeMode identifies whether a recovery replaces an uncommitted // provider attempt or continues an already-open downstream stream. type RecoveryResumeMode string const ( RecoveryResumeModeReplaceAttempt RecoveryResumeMode = "replace_attempt" RecoveryResumeModeContinueStream RecoveryResumeMode = "continue_stream" ) // Validate returns nil for a known recovery resume mode. func (m RecoveryResumeMode) Validate() error { switch m { case RecoveryResumeModeReplaceAttempt, RecoveryResumeModeContinueStream: return nil default: return errors.New("streamgate: unknown recovery resume mode: " + string(m)) } } // RecoveryPreparationStatus is the immutable lifecycle state of optional // plan preparation. type RecoveryPreparationStatus string const ( RecoveryPreparationNotRequired RecoveryPreparationStatus = "not_required" RecoveryPreparationRequired RecoveryPreparationStatus = "required" RecoveryPreparationPrepared RecoveryPreparationStatus = "prepared" ) // Validate returns nil for a known preparation status. func (s RecoveryPreparationStatus) Validate() error { switch s { case RecoveryPreparationNotRequired, RecoveryPreparationRequired, RecoveryPreparationPrepared: return nil default: return errors.New("streamgate: unknown recovery preparation status: " + string(s)) } } // RecoveryPolicySnapshot is the immutable request-start snapshot of fault // recovery limits. Missing strategy limits inherit the request-total limit // only when the supplied map is empty; in a non-empty map, missing strategies // are disabled. type RecoveryPolicySnapshot struct { maxRequestFaultRecoveries int strategyLimits map[RecoveryStrategy]int } // NewRecoveryPolicySnapshot validates and snapshots request-total and // strategy-specific fault recovery limits. func NewRecoveryPolicySnapshot(maxTotal int, strategyLimits map[RecoveryStrategy]int) (RecoveryPolicySnapshot, error) { if maxTotal < 0 || maxTotal > MaxRequestFaultRecoveryAttempts { return RecoveryPolicySnapshot{}, fmt.Errorf( "streamgate: max request fault recoveries must be between 0 and %d", MaxRequestFaultRecoveryAttempts, ) } limits := make(map[RecoveryStrategy]int, len(strategyLimits)) if len(strategyLimits) == 0 { for _, strategy := range faultRecoveryStrategies { limits[strategy] = maxTotal } } else { for strategy, limit := range strategyLimits { if !isFaultRecoveryStrategy(strategy) { return RecoveryPolicySnapshot{}, errors.New("streamgate: recovery policy contains non-fault strategy") } if limit < 0 || limit > maxTotal { return RecoveryPolicySnapshot{}, errors.New("streamgate: recovery strategy limit must be between zero and request-total limit") } limits[strategy] = limit } } snapshot := RecoveryPolicySnapshot{ maxRequestFaultRecoveries: maxTotal, strategyLimits: limits, } if err := snapshot.Validate(); err != nil { return RecoveryPolicySnapshot{}, err } return snapshot, nil } // Validate returns nil when the policy snapshot is internally consistent. func (p RecoveryPolicySnapshot) Validate() error { if p.maxRequestFaultRecoveries < 0 || p.maxRequestFaultRecoveries > MaxRequestFaultRecoveryAttempts { return errors.New("streamgate: recovery policy request-total limit is out of range") } for strategy, limit := range p.strategyLimits { if !isFaultRecoveryStrategy(strategy) { return errors.New("streamgate: recovery policy contains non-fault strategy") } if limit < 0 || limit > p.maxRequestFaultRecoveries { return errors.New("streamgate: recovery policy strategy limit is out of range") } } return nil } // MaxRequestFaultRecoveries returns the request-total fault recovery cap. func (p RecoveryPolicySnapshot) MaxRequestFaultRecoveries() int { return p.maxRequestFaultRecoveries } // StrategyLimit returns a fault strategy limit. A missing strategy in an // explicitly partial policy is disabled and therefore returns zero. func (p RecoveryPolicySnapshot) StrategyLimit(strategy RecoveryStrategy) int { return p.strategyLimits[strategy] } // StrategyLimits returns a defensive copy of all configured strategy limits. func (p RecoveryPolicySnapshot) StrategyLimits() map[RecoveryStrategy]int { return copyRecoveryStrategyCounts(p.strategyLimits) } func copyRecoveryPolicySnapshot(p RecoveryPolicySnapshot) RecoveryPolicySnapshot { return RecoveryPolicySnapshot{ maxRequestFaultRecoveries: p.maxRequestFaultRecoveries, strategyLimits: copyRecoveryStrategyCounts(p.strategyLimits), } } // RecoveryUsageSnapshot is the immutable request-local fault dispatch ledger. // Managed continuation is deliberately absent from this ledger. type RecoveryUsageSnapshot struct { requestFaultRecoveries int strategyUsage map[RecoveryStrategy]int } // NewRecoveryUsageSnapshot validates and snapshots total and per-strategy // fault recovery dispatch usage. func NewRecoveryUsageSnapshot(totalUsed int, strategyUsage map[RecoveryStrategy]int) (RecoveryUsageSnapshot, error) { usage := RecoveryUsageSnapshot{ requestFaultRecoveries: totalUsed, strategyUsage: copyRecoveryStrategyCounts(strategyUsage), } if err := usage.Validate(); err != nil { return RecoveryUsageSnapshot{}, err } return usage, nil } // Validate returns nil when total usage exactly accounts for each fault // strategy dispatch. func (u RecoveryUsageSnapshot) Validate() error { if u.requestFaultRecoveries < 0 { return errors.New("streamgate: recovery request usage must not be negative") } sum := 0 for strategy, used := range u.strategyUsage { if !isFaultRecoveryStrategy(strategy) { return errors.New("streamgate: recovery usage contains non-fault strategy") } if used < 0 { return errors.New("streamgate: recovery strategy usage must not be negative") } sum += used } if sum != u.requestFaultRecoveries { return errors.New("streamgate: recovery request usage must equal summed strategy usage") } return nil } // ValidateAgainst checks this usage snapshot against a policy snapshot. func (u RecoveryUsageSnapshot) ValidateAgainst(policy RecoveryPolicySnapshot) error { if err := policy.Validate(); err != nil { return err } if err := u.Validate(); err != nil { return err } if u.requestFaultRecoveries > policy.maxRequestFaultRecoveries { return errors.New("streamgate: recovery request usage exceeds policy") } for strategy, used := range u.strategyUsage { if used > policy.StrategyLimit(strategy) { return errors.New("streamgate: recovery strategy usage exceeds policy") } } return nil } // RequestFaultRecoveries returns total fault dispatches already consumed. func (u RecoveryUsageSnapshot) RequestFaultRecoveries() int { return u.requestFaultRecoveries } // StrategyUsage returns dispatches already consumed by a fault strategy. func (u RecoveryUsageSnapshot) StrategyUsage(strategy RecoveryStrategy) int { return u.strategyUsage[strategy] } // StrategyUsages returns a defensive copy of all per-strategy usage. func (u RecoveryUsageSnapshot) StrategyUsages() map[RecoveryStrategy]int { return copyRecoveryStrategyCounts(u.strategyUsage) } // RecoveryBudget combines the immutable request-start policy and current // request-local fault usage snapshots. Managed continuation is intentionally // excluded and uses ManagedTrajectoryBudget. type RecoveryBudget struct { policy RecoveryPolicySnapshot usage RecoveryUsageSnapshot } // NewRecoveryBudget creates a validated immutable fault budget snapshot. func NewRecoveryBudget(policy RecoveryPolicySnapshot, usage RecoveryUsageSnapshot) (RecoveryBudget, error) { budget := RecoveryBudget{ policy: copyRecoveryPolicySnapshot(policy), usage: copyRecoveryUsageSnapshot(usage), } if err := budget.Validate(); err != nil { return RecoveryBudget{}, err } return budget, nil } // Validate returns nil when usage is valid under the snapshotted policy. func (b RecoveryBudget) Validate() error { return b.usage.ValidateAgainst(b.policy) } // PolicySnapshot returns a defensive copy of the policy half. func (b RecoveryBudget) PolicySnapshot() RecoveryPolicySnapshot { return copyRecoveryPolicySnapshot(b.policy) } // UsageSnapshot returns a defensive copy of the usage half. func (b RecoveryBudget) UsageSnapshot() RecoveryUsageSnapshot { return copyRecoveryUsageSnapshot(b.usage) } func copyRecoveryUsageSnapshot(u RecoveryUsageSnapshot) RecoveryUsageSnapshot { return RecoveryUsageSnapshot{ requestFaultRecoveries: u.requestFaultRecoveries, strategyUsage: copyRecoveryStrategyCounts(u.strategyUsage), } } func copyRecoveryStrategyCounts(in map[RecoveryStrategy]int) map[RecoveryStrategy]int { if in == nil { return nil } out := make(map[RecoveryStrategy]int, len(in)) for strategy, count := range in { out[strategy] = count } return out } func (u RecoveryUsageSnapshot) nextFaultDispatch( strategy RecoveryStrategy, policy RecoveryPolicySnapshot, ) (RecoveryUsageSnapshot, int, int, error) { if !isFaultRecoveryStrategy(strategy) { return RecoveryUsageSnapshot{}, 0, 0, errors.New("streamgate: managed continuation does not consume fault budget") } if err := u.ValidateAgainst(policy); err != nil { return RecoveryUsageSnapshot{}, 0, 0, err } // Request-total is deliberately checked first to prevent strategy changes // from bypassing the shared cap. if u.requestFaultRecoveries >= policy.maxRequestFaultRecoveries { return RecoveryUsageSnapshot{}, 0, 0, ErrRecoveryRequestBudgetExhausted } if u.StrategyUsage(strategy) >= policy.StrategyLimit(strategy) { return RecoveryUsageSnapshot{}, 0, 0, ErrRecoveryStrategyBudgetExhausted } next := copyRecoveryUsageSnapshot(u) if next.strategyUsage == nil { next.strategyUsage = make(map[RecoveryStrategy]int) } next.requestFaultRecoveries++ next.strategyUsage[strategy]++ return next, next.requestFaultRecoveries, next.strategyUsage[strategy], nil } // ManagedTrajectoryBudget is an immutable request-local allowance snapshot for // provider output-cap continuation. RebuiltPromptTokens already includes the // preserved assistant prefix, so cumulative output is not subtracted again // from context allowance. type ManagedTrajectoryBudget struct { providerAttemptCap int contextWindow int reserve int rebuiltPromptTokens int callerLogicalOutputCap *int callerLogicalOutputUsage int } // NewManagedTrajectoryBudget creates a validated trajectory budget. A nil // callerLogicalOutputCap means the caller did not set a logical output cap. func NewManagedTrajectoryBudget( providerAttemptCap int, contextWindow int, reserve int, rebuiltPromptTokens int, callerLogicalOutputCap *int, callerLogicalOutputUsage int, ) (ManagedTrajectoryBudget, error) { var capCopy *int if callerLogicalOutputCap != nil { value := *callerLogicalOutputCap capCopy = &value } budget := ManagedTrajectoryBudget{ providerAttemptCap: providerAttemptCap, contextWindow: contextWindow, reserve: reserve, rebuiltPromptTokens: rebuiltPromptTokens, callerLogicalOutputCap: capCopy, callerLogicalOutputUsage: callerLogicalOutputUsage, } if err := budget.Validate(); err != nil { return ManagedTrajectoryBudget{}, err } return budget, nil } // Validate returns nil for a consistent trajectory ledger. Exhaustion is a // valid state and is reported by NextAllowance, not by validation. func (b ManagedTrajectoryBudget) Validate() error { if b.providerAttemptCap <= 0 { return errors.New("streamgate: managed provider attempt cap must be positive") } if b.contextWindow <= 0 { return errors.New("streamgate: managed context window must be positive") } if b.reserve < 0 { return errors.New("streamgate: managed context reserve must not be negative") } if b.rebuiltPromptTokens < 0 { return errors.New("streamgate: managed rebuilt prompt tokens must not be negative") } if b.callerLogicalOutputUsage < 0 { return errors.New("streamgate: managed caller output usage must not be negative") } if b.callerLogicalOutputCap != nil { if *b.callerLogicalOutputCap < 0 { return errors.New("streamgate: managed caller output cap must not be negative") } if b.callerLogicalOutputUsage > *b.callerLogicalOutputCap { return errors.New("streamgate: managed caller output usage exceeds cap") } } return nil } // NextAllowance returns min(attempt cap, optional remaining caller logical // cap, context window - rebuilt prompt tokens - reserve), clamped at zero. func (b ManagedTrajectoryBudget) NextAllowance() int { allowance := b.providerAttemptCap contextRemaining := b.contextWindow - b.rebuiltPromptTokens - b.reserve if contextRemaining < allowance { allowance = contextRemaining } if b.callerLogicalOutputCap != nil { callerRemaining := *b.callerLogicalOutputCap - b.callerLogicalOutputUsage if callerRemaining < allowance { allowance = callerRemaining } } if allowance < 0 { return 0 } return allowance } func (b ManagedTrajectoryBudget) ProviderAttemptCap() int { return b.providerAttemptCap } func (b ManagedTrajectoryBudget) ContextWindow() int { return b.contextWindow } func (b ManagedTrajectoryBudget) Reserve() int { return b.reserve } func (b ManagedTrajectoryBudget) RebuiltPromptTokens() int { return b.rebuiltPromptTokens } func (b ManagedTrajectoryBudget) CallerLogicalOutputUsage() int { return b.callerLogicalOutputUsage } // CallerLogicalOutputCap returns the cap and whether the caller set one. func (b ManagedTrajectoryBudget) CallerLogicalOutputCap() (int, bool) { if b.callerLogicalOutputCap == nil { return 0, false } return *b.callerLogicalOutputCap, true } func copyManagedTrajectoryBudget(b ManagedTrajectoryBudget) ManagedTrajectoryBudget { out := b if b.callerLogicalOutputCap != nil { value := *b.callerLogicalOutputCap out.callerLogicalOutputCap = &value } return out } // RecoveryContributor identifies the selected semantic consumer/filter/rule // without carrying evidence or raw content. type RecoveryContributor struct { consumerID StableToken filterID StableToken ruleID StableToken } func NewRecoveryContributor(consumerID, filterID, ruleID string) (RecoveryContributor, error) { consumer, err := NewStableTokenRequired("consumerID", consumerID) if err != nil { return RecoveryContributor{}, err } filter, err := NewStableTokenRequired("filterID", filterID) if err != nil { return RecoveryContributor{}, err } rule, err := NewStableTokenRequired("ruleID", ruleID) if err != nil { return RecoveryContributor{}, err } return RecoveryContributor{consumerID: consumer, filterID: filter, ruleID: rule}, nil } func (c RecoveryContributor) Validate() error { if err := validateStableTokenRequired("consumerID", c.consumerID.value); err != nil { return err } if err := validateStableTokenRequired("filterID", c.filterID.value); err != nil { return err } return validateStableTokenRequired("ruleID", c.ruleID.value) } func (c RecoveryContributor) ConsumerID() string { return c.consumerID.value } func (c RecoveryContributor) FilterID() string { return c.filterID.value } func (c RecoveryContributor) RuleID() string { return c.ruleID.value } // RecoveryRequestSnapshotRef is a raw-free, bounded reference to a // request-local ingress snapshot. type RecoveryRequestSnapshotRef struct { snapshotRef StableToken retainedBytes uint64 peakBytes uint64 maxBytes uint64 } // NewRecoveryRequestSnapshotRef validates and creates an immutable snapshot reference. func NewRecoveryRequestSnapshotRef( snapshotRef string, retainedBytes, peakBytes, maxBytes uint64, ) (RecoveryRequestSnapshotRef, error) { refToken, err := NewStableTokenRequired("snapshotRef", snapshotRef) if err != nil { return RecoveryRequestSnapshotRef{}, err } ref := RecoveryRequestSnapshotRef{ snapshotRef: refToken, retainedBytes: retainedBytes, peakBytes: peakBytes, maxBytes: maxBytes, } if err := ref.Validate(); err != nil { return RecoveryRequestSnapshotRef{}, err } return ref, nil } // Validate returns nil when the snapshot reference accounting is internally consistent. func (r RecoveryRequestSnapshotRef) Validate() error { if err := validateStableTokenRequired("snapshotRef", r.snapshotRef.value); err != nil { return err } if r.maxBytes == 0 { return errors.New("streamgate: recovery request snapshot max bytes must be positive") } if r.retainedBytes > r.peakBytes || r.peakBytes > r.maxBytes { return ErrRecoverySnapshotLimitExceeded } return nil } func (r RecoveryRequestSnapshotRef) SnapshotRef() string { return r.snapshotRef.value } func (r RecoveryRequestSnapshotRef) RetainedBytes() uint64 { return r.retainedBytes } func (r RecoveryRequestSnapshotRef) PeakBytes() uint64 { return r.peakBytes } func (r RecoveryRequestSnapshotRef) MaxBytes() uint64 { return r.maxBytes } // RecoveryEligibilityInput is the mutable call-site input to plan creation. // NewRecoveryPlan validates and defensively snapshots every mutable member. type RecoveryEligibilityInput struct { PlanID string IdempotencyKey string Intent RecoveryIntent Contributors []RecoveryContributor CommitState CommitState CallerCanceled bool HasToolSideEffect bool HasCompleteToolCall bool PreviouslyUsedPlanIDs []string Policy RecoveryPolicySnapshot Usage RecoveryUsageSnapshot ManagedTrajectory *ManagedTrajectoryBudget TerminalReason TerminalReason PreparerID string PreparationSnapshotRef string RequiredCapabilities []string FailureCauses FailureCauseChain } // RecoveryPlan is an immutable, transport-agnostic recovery decision. It does // not contain raw request/auth data, provider selection, or executable callbacks. type RecoveryPlan struct { planID StableToken idempotencyKey StableToken intent RecoveryIntent contributors []RecoveryContributor resumeMode RecoveryResumeMode commitState CommitState policy RecoveryPolicySnapshot usage RecoveryUsageSnapshot requestAttempt int strategyAttempt int managedTrajectory *ManagedTrajectoryBudget managedAllowance int managedFinalized bool terminalReason TerminalReason preparerID StableToken preparationSnapshotRef StableToken preparationStatus RecoveryPreparationStatus requiredCapabilities []StableToken failureCauses FailureCauseChain } // NewRecoveryPlan validates eligibility and returns an immutable plan. Merely // creating or preparing a plan never consumes recovery budget. func NewRecoveryPlan(input RecoveryEligibilityInput) (RecoveryPlan, error) { planID, err := NewStableTokenRequired("planID", input.PlanID) if err != nil { return RecoveryPlan{}, err } idempotencyKey, err := NewStableTokenRequired("idempotencyKey", input.IdempotencyKey) if err != nil { return RecoveryPlan{}, err } if err := input.Intent.Validate(); err != nil { return RecoveryPlan{}, err } if len(input.Contributors) == 0 { return RecoveryPlan{}, errors.New("streamgate: recovery plan requires at least one contributor") } contributors := make([]RecoveryContributor, len(input.Contributors)) copy(contributors, input.Contributors) for _, contributor := range contributors { if err := contributor.Validate(); err != nil { return RecoveryPlan{}, err } } if err := input.CommitState.Validate(); err != nil { return RecoveryPlan{}, err } if err := input.Policy.Validate(); err != nil { return RecoveryPlan{}, err } if err := input.Usage.ValidateAgainst(input.Policy); err != nil { return RecoveryPlan{}, err } if input.CallerCanceled { return RecoveryPlan{}, ErrRecoveryCallerCanceled } if input.CommitState == CommitStateTerminalCommitted { return RecoveryPlan{}, ErrRecoveryTerminalCommitted } if input.HasToolSideEffect || input.HasCompleteToolCall { return RecoveryPlan{}, ErrRecoverySideEffect } for _, usedID := range input.PreviouslyUsedPlanIDs { if err := validateStableTokenRequired("previousPlanID", usedID); err != nil { return RecoveryPlan{}, err } if usedID == input.PlanID { return RecoveryPlan{}, ErrRecoveryPlanReentry } } preparerID, err := NewStableTokenOptional("preparerID", input.PreparerID) if err != nil { return RecoveryPlan{}, err } preparationRef, err := NewStableTokenOptional("preparationSnapshotRef", input.PreparationSnapshotRef) if err != nil { return RecoveryPlan{}, err } if (preparerID.value == "") != (preparationRef.value == "") { return RecoveryPlan{}, errors.New("streamgate: recovery preparer id and snapshot ref must be set together") } requiredCapabilities, err := snapshotStableTokens("requiredCapability", input.RequiredCapabilities) if err != nil { return RecoveryPlan{}, err } if input.FailureCauses.Len() > MaxFailureCauses { return RecoveryPlan{}, errors.New("streamgate: recovery failure cause chain exceeds maximum") } for _, cause := range input.FailureCauses.All() { if err := cause.Validate(); err != nil { return RecoveryPlan{}, err } } strategy := input.Intent.Strategy() var ( resumeMode RecoveryResumeMode requestAttempt int strategyAttempt int managed *ManagedTrajectoryBudget allowance int ) switch strategy { case RecoveryStrategyExactReplay, RecoveryStrategySchemaRepair: if input.CommitState != CommitStateTransportUncommitted { return RecoveryPlan{}, ErrRecoveryCommitIneligible } resumeMode = RecoveryResumeModeReplaceAttempt case RecoveryStrategyContinuationRepair, RecoveryStrategyManagedContinuation: if input.CommitState != CommitStateStreamOpen { return RecoveryPlan{}, ErrRecoveryCommitIneligible } resumeMode = RecoveryResumeModeContinueStream default: return RecoveryPlan{}, errors.New("streamgate: recovery plan unknown strategy") } if strategy == RecoveryStrategyManagedContinuation { if input.TerminalReason != TerminalReasonOutputCap { return RecoveryPlan{}, errors.New("streamgate: managed continuation requires output-cap terminal reason") } if input.ManagedTrajectory == nil { return RecoveryPlan{}, errors.New("streamgate: managed continuation requires trajectory budget") } budgetCopy := copyManagedTrajectoryBudget(*input.ManagedTrajectory) if err := budgetCopy.Validate(); err != nil { return RecoveryPlan{}, err } managed = &budgetCopy allowance = 0 } else { if input.TerminalReason != "" { return RecoveryPlan{}, errors.New("streamgate: fault recovery must not carry managed terminal reason") } if input.ManagedTrajectory != nil { return RecoveryPlan{}, errors.New("streamgate: fault recovery must not carry managed trajectory budget") } _, requestAttempt, strategyAttempt, err = input.Usage.nextFaultDispatch(strategy, input.Policy) if err != nil { return RecoveryPlan{}, err } } preparationStatus := RecoveryPreparationNotRequired if preparerID.value != "" { preparationStatus = RecoveryPreparationRequired } plan := RecoveryPlan{ planID: planID, idempotencyKey: idempotencyKey, intent: input.Intent, contributors: contributors, resumeMode: resumeMode, commitState: input.CommitState, policy: copyRecoveryPolicySnapshot(input.Policy), usage: copyRecoveryUsageSnapshot(input.Usage), requestAttempt: requestAttempt, strategyAttempt: strategyAttempt, managedTrajectory: managed, managedAllowance: allowance, managedFinalized: false, terminalReason: input.TerminalReason, preparerID: preparerID, preparationSnapshotRef: preparationRef, preparationStatus: preparationStatus, requiredCapabilities: requiredCapabilities, failureCauses: input.FailureCauses.Copy(), } if err := plan.Validate(); err != nil { return RecoveryPlan{}, err } return plan, nil } // Validate returns nil when the plan remains internally consistent. func (p RecoveryPlan) Validate() error { if err := validateStableTokenRequired("planID", p.planID.value); err != nil { return err } if err := validateStableTokenRequired("idempotencyKey", p.idempotencyKey.value); err != nil { return err } if err := p.intent.Validate(); err != nil { return err } if err := p.resumeMode.Validate(); err != nil { return err } if err := p.commitState.Validate(); err != nil { return err } if len(p.contributors) == 0 { return errors.New("streamgate: recovery plan requires contributors") } seenContributors := make(map[string]struct{}, len(p.contributors)) for _, contributor := range p.contributors { if err := contributor.Validate(); err != nil { return err } key := contributor.ConsumerID() + "\x00" + contributor.FilterID() + "\x00" + contributor.RuleID() if _, exists := seenContributors[key]; exists { return errors.New("streamgate: recovery plan contains duplicate contributor") } seenContributors[key] = struct{}{} } if err := p.usage.ValidateAgainst(p.policy); err != nil { return err } if (p.preparerID.value == "") != (p.preparationSnapshotRef.value == "") { return errors.New("streamgate: recovery plan preparer fields mismatch") } if err := p.preparationStatus.Validate(); err != nil { return err } if err := validateStableTokenIfSet("preparerID", p.preparerID.value); err != nil { return err } if err := validateStableTokenIfSet("preparationSnapshotRef", p.preparationSnapshotRef.value); err != nil { return err } if p.preparerID.value == "" && p.preparationStatus != RecoveryPreparationNotRequired { return errors.New("streamgate: recovery plan without preparer has invalid preparation status") } if p.preparerID.value != "" && p.preparationStatus == RecoveryPreparationNotRequired { return errors.New("streamgate: recovery plan with preparer has invalid preparation status") } if err := validateUniqueStableTokens("requiredCapability", p.requiredCapabilities); err != nil { return err } for _, cause := range p.failureCauses.All() { if err := cause.Validate(); err != nil { return err } } strategy := p.intent.Strategy() switch strategy { case RecoveryStrategyExactReplay, RecoveryStrategySchemaRepair: if p.resumeMode != RecoveryResumeModeReplaceAttempt || p.commitState != CommitStateTransportUncommitted { return errors.New("streamgate: replace recovery plan has invalid commit contract") } case RecoveryStrategyContinuationRepair, RecoveryStrategyManagedContinuation: if p.resumeMode != RecoveryResumeModeContinueStream || p.commitState != CommitStateStreamOpen { return errors.New("streamgate: continuation recovery plan has invalid commit contract") } default: return errors.New("streamgate: recovery plan unknown strategy") } if strategy == RecoveryStrategyManagedContinuation { if p.managedTrajectory == nil || p.terminalReason != TerminalReasonOutputCap { return errors.New("streamgate: managed recovery plan missing trajectory contract") } if err := p.managedTrajectory.Validate(); err != nil { return err } if p.managedFinalized { if p.managedAllowance <= 0 || p.managedAllowance != p.managedTrajectory.NextAllowance() { return errors.New("streamgate: managed recovery plan allowance mismatch") } } else { if p.managedAllowance != 0 { return errors.New("streamgate: unfinalized managed recovery plan allowance must be zero") } } if p.requestAttempt != 0 || p.strategyAttempt != 0 { return errors.New("streamgate: managed recovery plan must not consume fault attempt ordinals") } } else { if p.managedTrajectory != nil || p.managedAllowance != 0 || p.terminalReason != "" { return errors.New("streamgate: fault recovery plan contains managed trajectory fields") } _, requestAttempt, strategyAttempt, err := p.usage.nextFaultDispatch(strategy, p.policy) if err != nil { return err } if p.requestAttempt != requestAttempt || p.strategyAttempt != strategyAttempt { return errors.New("streamgate: fault recovery plan attempt ordinal mismatch") } } return nil } // UsageAfterDispatch returns the immutable usage snapshot that must be stored // immediately before outbound dispatch. Managed continuation returns the same // fault ledger because it uses a separate trajectory budget. func (p RecoveryPlan) UsageAfterDispatch() (RecoveryUsageSnapshot, error) { if err := p.Validate(); err != nil { return RecoveryUsageSnapshot{}, err } if !p.ReadyForDispatch() { return RecoveryUsageSnapshot{}, ErrRecoveryPlanNotReady } if p.intent.Strategy() == RecoveryStrategyManagedContinuation { return copyRecoveryUsageSnapshot(p.usage), nil } next, _, _, err := p.usage.nextFaultDispatch(p.intent.Strategy(), p.policy) return next, err } func (p RecoveryPlan) PlanID() string { return p.planID.value } func (p RecoveryPlan) IdempotencyKey() string { return p.idempotencyKey.value } func (p RecoveryPlan) Strategy() RecoveryStrategy { return p.intent.Strategy() } func (p RecoveryPlan) Intent() RecoveryIntent { return p.intent } func (p RecoveryPlan) Directive() RecoveryDirective { return p.intent.Directive() } func (p RecoveryPlan) Reason() string { return p.intent.Reason() } func (p RecoveryPlan) Priority() int { return p.intent.Priority() } func (p RecoveryPlan) ResumeMode() RecoveryResumeMode { return p.resumeMode } func (p RecoveryPlan) CommitState() CommitState { return p.commitState } func (p RecoveryPlan) RequestAttempt() int { return p.requestAttempt } func (p RecoveryPlan) StrategyAttempt() int { return p.strategyAttempt } func (p RecoveryPlan) ManagedAllowance() int { return p.managedAllowance } func (p RecoveryPlan) ManagedFinalized() bool { return p.managedFinalized } func (p RecoveryPlan) TerminalReason() TerminalReason { return p.terminalReason } func (p RecoveryPlan) PreparerID() string { return p.preparerID.value } func (p RecoveryPlan) PreparationSnapshotRef() string { return p.preparationSnapshotRef.value } func (p RecoveryPlan) PreparationStatus() RecoveryPreparationStatus { return p.preparationStatus } func (p RecoveryPlan) ReadyForRebuild() bool { return p.preparationStatus != RecoveryPreparationRequired } func (p RecoveryPlan) ReadyForDispatch() bool { if p.intent.Strategy() == RecoveryStrategyManagedContinuation { return p.ReadyForRebuild() && p.managedFinalized && p.managedAllowance > 0 } return p.ReadyForRebuild() } // FinalizeRebuiltRequest validates a measured rebuilt request draft against the plan, // calculates the final managed allowance for managed continuation strategies (or 0 // for fault recovery strategies), and returns the finalized RecoveryPlan together // with an allowance-bound dispatchable RebuiltRequest. func (p RecoveryPlan) FinalizeRebuiltRequest(draft RebuiltRequestDraft) (RecoveryPlan, RebuiltRequest, error) { if err := p.Validate(); err != nil { return RecoveryPlan{}, RebuiltRequest{}, err } if !p.ReadyForRebuild() { return RecoveryPlan{}, RebuiltRequest{}, ErrRecoveryPlanNotReady } if err := draft.Validate(); err != nil { return RecoveryPlan{}, RebuiltRequest{}, err } if draft.PlanID() != p.PlanID() || draft.IdempotencyKey() != p.IdempotencyKey() { return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: rebuilt request plan or idempotency key mismatch") } if !slices.Equal(draft.RequiredCapabilities(), p.RequiredCapabilities()) { return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: rebuilt request required capabilities mismatch") } strategy := p.intent.Strategy() if strategy == RecoveryStrategyManagedContinuation { if p.managedTrajectory == nil { return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: managed recovery plan missing trajectory budget") } updatedBudget, err := NewManagedTrajectoryBudget( p.managedTrajectory.providerAttemptCap, p.managedTrajectory.contextWindow, p.managedTrajectory.reserve, draft.RebuiltPromptTokens(), p.managedTrajectory.callerLogicalOutputCap, p.managedTrajectory.callerLogicalOutputUsage, ) if err != nil { return RecoveryPlan{}, RebuiltRequest{}, err } allowance := updatedBudget.NextAllowance() if allowance <= 0 { return RecoveryPlan{}, RebuiltRequest{}, ErrManagedTrajectoryExhausted } out := p out.managedTrajectory = &updatedBudget out.managedAllowance = allowance out.managedFinalized = true if err := out.Validate(); err != nil { return RecoveryPlan{}, RebuiltRequest{}, err } request, err := newRebuiltRequest(draft, allowance) if err != nil { return RecoveryPlan{}, RebuiltRequest{}, err } return out, request, nil } request, err := newRebuiltRequest(draft, 0) if err != nil { return RecoveryPlan{}, RebuiltRequest{}, err } return p, request, nil } func (p RecoveryPlan) PolicySnapshot() RecoveryPolicySnapshot { return copyRecoveryPolicySnapshot(p.policy) } func (p RecoveryPlan) UsageSnapshot() RecoveryUsageSnapshot { return copyRecoveryUsageSnapshot(p.usage) } // FaultBudget returns the immutable fault policy/usage pair used to select // this plan. func (p RecoveryPlan) FaultBudget() RecoveryBudget { return RecoveryBudget{policy: copyRecoveryPolicySnapshot(p.policy), usage: copyRecoveryUsageSnapshot(p.usage)} } func (p RecoveryPlan) ManagedTrajectoryBudget() (*ManagedTrajectoryBudget, bool) { if p.managedTrajectory == nil { return nil, false } copyBudget := copyManagedTrajectoryBudget(*p.managedTrajectory) return ©Budget, true } func (p RecoveryPlan) Contributors() []RecoveryContributor { out := make([]RecoveryContributor, len(p.contributors)) copy(out, p.contributors) return out } func (p RecoveryPlan) RequiredCapabilities() []string { if p.requiredCapabilities == nil { return nil } out := make([]string, len(p.requiredCapabilities)) for i, capability := range p.requiredCapabilities { out[i] = capability.value } return out } func (p RecoveryPlan) FailureCauses() FailureCauseChain { return p.failureCauses.Copy() } func (p RecoveryPlan) RequiresPreparation() bool { return p.preparerID.value != "" } // WithPreparedDirective returns a new final plan after successful one-shot // preparation. The original plan remains in required state. func (p RecoveryPlan) WithPreparedDirective(directive RecoveryDirective) (RecoveryPlan, error) { if err := p.Validate(); err != nil { return RecoveryPlan{}, err } if p.preparationStatus != RecoveryPreparationRequired { return RecoveryPlan{}, errors.New("streamgate: recovery plan is not awaiting preparation") } if err := directive.Validate(); err != nil { return RecoveryPlan{}, err } if !directive.MatchStrategy(p.intent.Strategy()) { return RecoveryPlan{}, errors.New("streamgate: prepared directive does not match recovery strategy") } intent, err := NewRecoveryIntent(p.intent.Strategy(), directive, p.intent.Reason(), p.intent.Priority()) if err != nil { return RecoveryPlan{}, err } out := p out.intent = intent out.preparationStatus = RecoveryPreparationPrepared if err := out.Validate(); err != nil { return RecoveryPlan{}, err } return out, nil } func snapshotStableTokens(name string, values []string) ([]StableToken, error) { if values == nil { return nil, nil } seen := make(map[string]struct{}, len(values)) out := make([]StableToken, len(values)) for i, value := range values { if _, ok := seen[value]; ok { return nil, errors.New("streamgate: duplicate " + name) } token, err := NewStableTokenRequired(name, value) if err != nil { return nil, err } seen[value] = struct{}{} out[i] = token } return out, nil } func validateUniqueStableTokens(name string, values []StableToken) error { seen := make(map[string]struct{}, len(values)) for _, value := range values { if err := validateStableTokenRequired(name, value.value); err != nil { return err } if _, exists := seen[value.value]; exists { return errors.New("streamgate: duplicate " + name) } seen[value.value] = struct{}{} } return nil } // RebuiltRequestDraft is an unfinalized, raw-free reference to an endpoint/family-specific // rebuilt request retained by the host. It carries prompt measurements, peak/retained memory // limits, and required capabilities before post-rebuild allowance finalization. type RebuiltRequestDraft struct { planID StableToken idempotencyKey StableToken requestRef StableToken endpoint string family string retainedBytes uint64 peakBytes uint64 maxBytes uint64 rebuiltPromptTokens int requiredCapabilities []StableToken } // NewRebuiltRequestDraft validates and creates an unfinalized rebuilt request draft. func NewRebuiltRequestDraft( planID, requestRef, endpoint, family string, retainedBytes, peakBytes, maxBytes uint64, rebuiltPromptTokens int, requiredCapabilities []string, ) (RebuiltRequestDraft, error) { return NewRebuiltRequestDraftWithIdempotency( planID, planID, requestRef, endpoint, family, retainedBytes, peakBytes, maxBytes, rebuiltPromptTokens, requiredCapabilities, ) } // NewRebuiltRequestDraftWithIdempotency creates a rebuilt request draft that // preserves the selected plan's independent outbound idempotency key. func NewRebuiltRequestDraftWithIdempotency( planID, idempotencyKey, requestRef, endpoint, family string, retainedBytes, peakBytes, maxBytes uint64, rebuiltPromptTokens int, requiredCapabilities []string, ) (RebuiltRequestDraft, error) { planToken, err := NewStableTokenRequired("planID", planID) if err != nil { return RebuiltRequestDraft{}, err } idempotencyToken, err := NewStableTokenRequired("idempotencyKey", idempotencyKey) if err != nil { return RebuiltRequestDraft{}, err } requestToken, err := NewStableTokenRequired("requestRef", requestRef) if err != nil { return RebuiltRequestDraft{}, err } capabilities, err := snapshotStableTokens("requiredCapability", requiredCapabilities) if err != nil { return RebuiltRequestDraft{}, err } draft := RebuiltRequestDraft{ planID: planToken, idempotencyKey: idempotencyToken, requestRef: requestToken, endpoint: endpoint, family: family, retainedBytes: retainedBytes, peakBytes: peakBytes, maxBytes: maxBytes, rebuiltPromptTokens: rebuiltPromptTokens, requiredCapabilities: capabilities, } if err := draft.Validate(); err != nil { return RebuiltRequestDraft{}, err } return draft, nil } func (d RebuiltRequestDraft) Validate() error { if err := validateStableTokenRequired("planID", d.planID.value); err != nil { return err } if err := validateStableTokenRequired("idempotencyKey", d.idempotencyKey.value); err != nil { return err } if err := validateStableTokenRequired("requestRef", d.requestRef.value); err != nil { return err } if d.endpoint == "" || d.family == "" { return errors.New("streamgate: rebuilt request endpoint and family are required") } if d.maxBytes == 0 { return errors.New("streamgate: rebuilt request max bytes must be positive") } if d.retainedBytes > d.peakBytes || d.peakBytes > d.maxBytes { return ErrRecoverySnapshotLimitExceeded } if d.rebuiltPromptTokens < 0 { return errors.New("streamgate: rebuilt request prompt tokens must not be negative") } if err := validateUniqueStableTokens("requiredCapability", d.requiredCapabilities); err != nil { return err } return nil } func (d RebuiltRequestDraft) PlanID() string { return d.planID.value } func (d RebuiltRequestDraft) IdempotencyKey() string { return d.idempotencyKey.value } func (d RebuiltRequestDraft) RequestRef() string { return d.requestRef.value } func (d RebuiltRequestDraft) Endpoint() string { return d.endpoint } func (d RebuiltRequestDraft) Family() string { return d.family } func (d RebuiltRequestDraft) RetainedBytes() uint64 { return d.retainedBytes } func (d RebuiltRequestDraft) PeakBytes() uint64 { return d.peakBytes } func (d RebuiltRequestDraft) MaxBytes() uint64 { return d.maxBytes } func (d RebuiltRequestDraft) RebuiltPromptTokens() int { return d.rebuiltPromptTokens } func (d RebuiltRequestDraft) RequiredCapabilities() []string { out := make([]string, len(d.requiredCapabilities)) for i, capability := range d.requiredCapabilities { out[i] = capability.value } return out } // RebuiltRequest is an allowance-bound, dispatchable reference to a rebuilt request. // It couples measured request parameters with the Core-calculated managed allowance. type RebuiltRequest struct { draft RebuiltRequestDraft managedAllowance int } // newRebuiltRequest creates an allowance-bound final rebuilt request from a validated draft // and managed allowance. func newRebuiltRequest(draft RebuiltRequestDraft, managedAllowance int) (RebuiltRequest, error) { if err := draft.Validate(); err != nil { return RebuiltRequest{}, err } if managedAllowance < 0 { return RebuiltRequest{}, errors.New("streamgate: rebuilt request managed allowance must not be negative") } request := RebuiltRequest{ draft: draft, managedAllowance: managedAllowance, } if err := request.Validate(); err != nil { return RebuiltRequest{}, err } return request, nil } func (r RebuiltRequest) Validate() error { if err := r.draft.Validate(); err != nil { return err } if r.managedAllowance < 0 { return errors.New("streamgate: rebuilt request managed allowance must not be negative") } return nil } func (r RebuiltRequest) PlanID() string { return r.draft.PlanID() } func (r RebuiltRequest) IdempotencyKey() string { return r.draft.IdempotencyKey() } func (r RebuiltRequest) RequestRef() string { return r.draft.RequestRef() } func (r RebuiltRequest) Endpoint() string { return r.draft.Endpoint() } func (r RebuiltRequest) Family() string { return r.draft.Family() } func (r RebuiltRequest) RetainedBytes() uint64 { return r.draft.RetainedBytes() } func (r RebuiltRequest) PeakBytes() uint64 { return r.draft.PeakBytes() } func (r RebuiltRequest) MaxBytes() uint64 { return r.draft.MaxBytes() } func (r RebuiltRequest) RebuiltPromptTokens() int { return r.draft.RebuiltPromptTokens() } func (r RebuiltRequest) ManagedAllowance() int { return r.managedAllowance } func (r RebuiltRequest) RequiredCapabilities() []string { return r.draft.RequiredCapabilities() } // NormalizedEventSource is the host-owned source of codec-normalized events // for one provider attempt. type NormalizedEventSource interface { NextEvent(ctx context.Context) (NormalizedEvent, error) } // AttemptController owns only the current provider transport. AbortAttempt // must be idempotent; nil means ownership is closed, including already-closed. type AttemptController interface { AbortAttempt(ctx context.Context) error } // AttemptBinding binds a dispatched attempt to its actual admission result and // normalized event source without exposing provider auth or request body. type AttemptBinding struct { attemptID StableToken model string provider string executionPath string eventSource NormalizedEventSource controller AttemptController } func NewAttemptBinding( attemptID, model, provider, executionPath string, eventSource NormalizedEventSource, controller AttemptController, ) (AttemptBinding, error) { attemptToken, err := NewStableTokenRequired("attemptID", attemptID) if err != nil { return AttemptBinding{}, err } if model == "" || provider == "" || executionPath == "" { return AttemptBinding{}, errors.New("streamgate: attempt binding model, provider, and execution path are required") } if eventSource == nil || controller == nil { return AttemptBinding{}, errors.New("streamgate: attempt binding event source and controller are required") } binding := AttemptBinding{ attemptID: attemptToken, model: model, provider: provider, executionPath: executionPath, eventSource: eventSource, controller: controller, } if err := binding.Validate(); err != nil { return AttemptBinding{}, err } return binding, nil } // Validate returns nil when the binding contains a complete actual admission // result and both host ownership seams. func (b AttemptBinding) Validate() error { if err := validateStableTokenRequired("attemptID", b.attemptID.value); err != nil { return err } if b.model == "" || b.provider == "" || b.executionPath == "" { return errors.New("streamgate: attempt binding model, provider, and execution path are required") } if b.eventSource == nil || b.controller == nil { return errors.New("streamgate: attempt binding event source and controller are required") } return nil } func (b AttemptBinding) AttemptID() string { return b.attemptID.value } func (b AttemptBinding) Model() string { return b.model } func (b AttemptBinding) Provider() string { return b.provider } func (b AttemptBinding) ExecutionPath() string { return b.executionPath } func (b AttemptBinding) EventSource() NormalizedEventSource { return b.eventSource } func (b AttemptBinding) Controller() AttemptController { return b.controller } // AttemptDispatcher performs host admission and concrete provider/model/auth // calculation for one rebuilt request. type AttemptDispatcher interface { DispatchAttempt(ctx context.Context, request RebuiltRequest) (AttemptBinding, error) } // RecoveryPreparationSnapshot is a bounded request-local snapshot seam. It // exposes only its stable reference and idempotent release lifecycle; a host // preparer may type-assert its own concrete snapshot without adding raw // accessors to the shared Core contract. type RecoveryPreparationSnapshot interface { SnapshotRef() string Release() error } // RecoveryPlanPreparer optionally enriches the typed directive after attempt // ownership is closed. It must be called at most once per plan/idempotency key // under the caller's bounded context and must not dispatch or mutate budgets. type RecoveryPlanPreparer interface { PrepareRecoveryPlan(ctx context.Context, plan RecoveryPlan, snapshot RecoveryPreparationSnapshot) (RecoveryDirective, error) } // RequestRebuilder applies the selected typed plan to a bounded, request-local // ingress snapshot. Concrete auth and provider selection remain outside it. type RequestRebuilder interface { RebuildRequest(ctx context.Context, snapshot RecoveryRequestSnapshotRef, plan RecoveryPlan) (RebuiltRequestDraft, error) }