iop/packages/go/streamgate/recovery_plan.go
toki d182eafeff feat: stream evidence gate core - full implementation
- Stream evidence gate routing in edge config and runtime
- Ingress snapshot and allocation
- Recovery coordinator and plan
- Runtime contract for gate filters
- OpenAI-compatible request rebuilder and stream gate dispatcher
- Comprehensive tests for all new components
- Updated contracts and roadmap milestones
2026-07-26 20:48:53 +09:00

1355 lines
49 KiB
Go

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 &copyBudget, 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)
}