package streamgate import ( "context" "errors" "strconv" "time" ) // CommitState records the commit status of a release payload to the // downstream sink. Values follow the transport lifecycle: uncommitted, // stream open after first user-visible write, and terminal committed. type CommitState string const ( // CommitStateTransportUncommitted indicates the payload has not yet been // committed to the downstream sink. CommitStateTransportUncommitted CommitState = "transport_uncommitted" // CommitStateStreamOpen indicates the first user-visible write (HTTP // status/header flush, SSE/agent opening event, or first body chunk) // has occurred. CommitStateStreamOpen CommitState = "stream_open" // CommitStateTerminalCommitted indicates the terminal event has been // definitively sent to the downstream sink. CommitStateTerminalCommitted CommitState = "terminal_committed" ) // Validate returns nil when the CommitState is a known lifecycle value. func (cs CommitState) Validate() error { switch cs { case CommitStateTransportUncommitted, CommitStateStreamOpen, CommitStateTerminalCommitted: return nil } return errors.New("streamgate: unknown commit state: " + string(cs)) } // MaxFailureCauses is the maximum number of failure causes retained in a // FailureCauseChain. When the chain exceeds this limit, the oldest entry is // dropped. const MaxFailureCauses = 4 // StableToken is a bounded, ASCII-only identifier used across the event // contract for stable references such as consumer ids, filter ids, rule ids, // descriptor types/codes, template ids, param names, and recovery directive // references. The grammar is [a-z0-9][a-z0-9._:-]* and the encoded value is // at most 128 bytes. Accessors return only string; the type itself is // unexported storage. type StableToken struct { value string } // NewStableTokenRequired creates a StableToken that must be non-empty. It // enforces the ASCII grammar and 128-byte limit. func NewStableTokenRequired(name, v string) (StableToken, error) { if v == "" { return StableToken{}, errors.New("streamgate: " + name + " stable token is required") } return validateStableToken(name, v) } // NewStableTokenOptional creates a StableToken that may be empty. When empty // the accessor returns "". func NewStableTokenOptional(name, v string) (StableToken, error) { if v == "" { return StableToken{}, nil } return validateStableToken(name, v) } func validateStableToken(name, v string) (StableToken, error) { if len(v) > maxStableTokenBytes { return StableToken{}, errors.New("streamgate: " + name + " stable token exceeds maximum length") } for i := 0; i < len(v); i++ { b := v[i] if i == 0 { if !isAlphaLower(b) && !isDigit(b) { return StableToken{}, errors.New("streamgate: " + name + " stable token must start with [a-z0-9]") } } else { if !isAlphaLower(b) && !isDigit(b) && b != '.' && b != '_' && b != ':' && b != '-' { return StableToken{}, errors.New("streamgate: " + name + " stable token contains invalid character") } } } return StableToken{value: v}, nil } // validateStableTokenIfSet re-validates a non-empty StableToken value against // the same grammar used at construction. Empty values pass through. func validateStableTokenIfSet(name, v string) error { if v == "" { return nil } if _, err := validateStableToken(name, v); err != nil { return err } return nil } // validateStableTokenRequired re-validates a non-empty StableToken value against // the same grammar used at construction. Empty values return a required error. func validateStableTokenRequired(name, v string) error { if v == "" { return errors.New("streamgate: " + name + " stable token is required") } if _, err := validateStableToken(name, v); err != nil { return err } return nil } func isAlphaLower(b byte) bool { return b >= 'a' && b <= 'z' } func isDigit(b byte) bool { return b >= '0' && b <= '9' } const maxStableTokenBytes = 128 // String returns the token value. func (t StableToken) String() string { return t.value } // FailureCause records a single step in a terminal failure with bounded, // sanitized information. It contains only stage, code, consumer, filter, and // rule id fields. Raw stack traces, provider body content, prompts, outputs, // and authentication material are not permitted. type FailureCause struct { stage StableToken code StableToken consumer StableToken filter StableToken ruleID StableToken } // NewFailureCause creates a FailureCause with validation. The returned value // is immutable to callers. func NewFailureCause(stage, code, consumer, filter, ruleID string) (FailureCause, error) { s, err := NewStableTokenRequired("stage", stage) if err != nil { return FailureCause{}, err } c, err := NewStableTokenRequired("code", code) if err != nil { return FailureCause{}, err } con, err := NewStableTokenOptional("consumer", consumer) if err != nil { return FailureCause{}, err } f, err := NewStableTokenOptional("filter", filter) if err != nil { return FailureCause{}, err } r, err := NewStableTokenOptional("ruleID", ruleID) if err != nil { return FailureCause{}, err } cause := FailureCause{ stage: s, code: c, consumer: con, filter: f, ruleID: r, } if err := cause.Validate(); err != nil { return FailureCause{}, err } return cause, nil } // Validate returns nil when the FailureCause is in a consistent state. // Re-validates nested StableToken values against the same grammar. func (fc FailureCause) Validate() error { if err := validateStableTokenRequired("stage", fc.stage.value); err != nil { return err } if err := validateStableTokenRequired("code", fc.code.value); err != nil { return err } if err := validateStableTokenIfSet("consumer", fc.consumer.value); err != nil { return err } if err := validateStableTokenIfSet("filter", fc.filter.value); err != nil { return err } if err := validateStableTokenIfSet("ruleID", fc.ruleID.value); err != nil { return err } return nil } // Stage returns the failure cause stage token. func (fc FailureCause) Stage() string { return fc.stage.value } // Code returns the failure cause code token. func (fc FailureCause) Code() string { return fc.code.value } // Consumer returns the failure cause consumer token, or "" if not set. func (fc FailureCause) Consumer() string { return fc.consumer.value } // Filter returns the failure cause filter token, or "" if not set. func (fc FailureCause) Filter() string { return fc.filter.value } // RuleID returns the failure cause rule id token, or "" if not set. func (fc FailureCause) RuleID() string { return fc.ruleID.value } // FailureCauseChain is a bounded sequence of FailureCause entries that // describes the full chain of a terminal failure. It retains at most // MaxFailureCauses entries; when the chain exceeds this limit, the oldest // entry is dropped. All public accessors return defensive copies. type FailureCauseChain struct { causes []FailureCause } // NewFailureCauseChain creates a FailureCauseChain from a slice of causes. // All causes are validated, and the chain is capped to the latest // MaxFailureCauses entries. The returned chain is a defensive copy. func NewFailureCauseChain(causes []FailureCause) (FailureCauseChain, error) { if len(causes) == 0 { return FailureCauseChain{}, nil } cp := make([]FailureCause, len(causes)) copy(cp, causes) for i, c := range cp { if err := c.Validate(); err != nil { return FailureCauseChain{}, errors.New("streamgate: failure cause chain entry " + strconv.Itoa(i) + ": " + err.Error()) } } // Cap to latest MaxFailureCauses. if len(cp) > MaxFailureCauses { cp = cp[len(cp)-MaxFailureCauses:] } return FailureCauseChain{causes: cp}, nil } // Append adds a cause to the chain. The input cause is validated. If the // chain already contains MaxFailureCauses entries, the oldest entry is dropped // first. The returned chain is a defensive copy. func (c FailureCauseChain) Append(cause FailureCause) (FailureCauseChain, error) { if err := cause.Validate(); err != nil { return FailureCauseChain{}, err } cp := make([]FailureCause, len(c.causes)) copy(cp, c.causes) cp = append(cp, cause) if len(cp) > MaxFailureCauses { cp = cp[len(cp)-MaxFailureCauses:] } return FailureCauseChain{causes: cp}, nil } // Copy returns a defensive copy of the failure cause chain. func (c FailureCauseChain) Copy() FailureCauseChain { if c.causes == nil { return FailureCauseChain{} } out := make([]FailureCause, len(c.causes)) copy(out, c.causes) return FailureCauseChain{causes: out} } // Len returns the number of causes in the chain. func (c FailureCauseChain) Len() int { return len(c.causes) } // At returns a defensive copy of the cause at the given index. func (c FailureCauseChain) At(i int) (FailureCause, error) { if i < 0 || i >= len(c.causes) { return FailureCause{}, errors.New("streamgate: failure cause chain index out of range") } cp := c.causes[i] return cp, nil } // All returns a defensive copy of all causes in the chain. func (c FailureCauseChain) All() []FailureCause { if c.causes == nil { return nil } out := make([]FailureCause, len(c.causes)) copy(out, c.causes) return out } // ExternalDescriptor carries only safe, external-facing information about a // terminal failure. It must not contain raw stack traces, provider body // content, prompts, outputs, or authentication material. The type field is a // stable token, the code is an optional stable code, the message is a safe // template, and the param is an optional sanitized parameter. type ExternalDescriptor struct { errorType StableToken code StableToken message StableToken param StableToken } // NewExternalDescriptor creates an ExternalDescriptor with validation. The // returned value is immutable to callers. func NewExternalDescriptor(errorType, code, message, param string) (ExternalDescriptor, error) { et, err := NewStableTokenRequired("errorType", errorType) if err != nil { return ExternalDescriptor{}, err } c, err := NewStableTokenOptional("code", code) if err != nil { return ExternalDescriptor{}, err } m, err := NewStableTokenRequired("message", message) if err != nil { return ExternalDescriptor{}, err } p, err := NewStableTokenOptional("param", param) if err != nil { return ExternalDescriptor{}, err } desc := ExternalDescriptor{ errorType: et, code: c, message: m, param: p, } if err := desc.Validate(); err != nil { return ExternalDescriptor{}, err } return desc, nil } // Validate returns nil when the ExternalDescriptor is in a consistent state. // Re-validates nested StableToken values against the same grammar. func (ed ExternalDescriptor) Validate() error { if err := validateStableTokenRequired("errorType", ed.errorType.value); err != nil { return err } if err := validateStableTokenIfSet("code", ed.code.value); err != nil { return err } if err := validateStableTokenRequired("message", ed.message.value); err != nil { return err } if err := validateStableTokenIfSet("param", ed.param.value); err != nil { return err } return nil } // Type returns the error type token. func (ed ExternalDescriptor) Type() string { return ed.errorType.value } // Code returns the optional stable code. func (ed ExternalDescriptor) Code() string { return ed.code.value } // Message returns the safe message template. func (ed ExternalDescriptor) Message() string { return ed.message.value } // Param returns the optional sanitized parameter. func (ed ExternalDescriptor) Param() string { return ed.param.value } // TerminalResult delivers a single, typed terminal outcome for a channel. It // carries either success or error with a safe external descriptor. Exactly // one of Success or Error must be set; both or neither is invalid. All fields // are privately stored with defensive copies on access. type TerminalResult struct { channel string success bool err bool externalDesc *ExternalDescriptor failureCauses FailureCauseChain occurredAt time.Time } // NewSuccessTerminalResult creates a TerminalResult of kind success. The // result must not carry an external descriptor or failure causes. func NewSuccessTerminalResult(channel string, occurredAt time.Time) (TerminalResult, error) { if channel == "" { return TerminalResult{}, errors.New("streamgate: terminal result channel is required") } if occurredAt.IsZero() { return TerminalResult{}, errors.New("streamgate: terminal result occurred timestamp is required") } tr := TerminalResult{ channel: channel, success: true, occurredAt: occurredAt, } if err := tr.Validate(); err != nil { return TerminalResult{}, err } return tr, nil } // NewErrorTerminalResult creates a TerminalResult of kind error. It requires // a validated external descriptor and an optional bounded failure cause chain. func NewErrorTerminalResult( channel string, desc ExternalDescriptor, causes FailureCauseChain, occurredAt time.Time, ) (TerminalResult, error) { if channel == "" { return TerminalResult{}, errors.New("streamgate: terminal result channel is required") } if occurredAt.IsZero() { return TerminalResult{}, errors.New("streamgate: terminal result occurred timestamp is required") } if err := desc.Validate(); err != nil { return TerminalResult{}, err } tr := TerminalResult{ channel: channel, err: true, externalDesc: copyExternalDescriptor(&desc), failureCauses: causes.Copy(), occurredAt: occurredAt, } if err := tr.Validate(); err != nil { return TerminalResult{}, err } return tr, nil } // NewTerminalResult is retained for internal use only. Prefer // NewSuccessTerminalResult and NewErrorTerminalResult for new code. func newTerminalResult( channel string, success, errResult bool, desc *ExternalDescriptor, causes FailureCauseChain, occurredAt time.Time, ) (TerminalResult, error) { if channel == "" { return TerminalResult{}, errors.New("streamgate: terminal result channel is required") } if !success && !errResult { return TerminalResult{}, errors.New("streamgate: terminal result must be either success or error") } if success && errResult { return TerminalResult{}, errors.New("streamgate: terminal result cannot be both success and error") } if errResult && desc == nil { return TerminalResult{}, errors.New("streamgate: terminal result error requires an external descriptor") } if occurredAt.IsZero() { return TerminalResult{}, errors.New("streamgate: terminal result occurred timestamp is required") } cpDesc := desc if desc != nil { descCopy := *desc cpDesc = &descCopy } cpCauses := causes.Copy() tr := TerminalResult{ channel: channel, success: success, err: errResult, externalDesc: cpDesc, failureCauses: cpCauses, occurredAt: occurredAt, } if err := tr.Validate(); err != nil { return TerminalResult{}, err } return tr, nil } // Validate returns nil when the TerminalResult is in a consistent state. func (tr TerminalResult) Validate() error { if tr.channel == "" { return errors.New("streamgate: terminal result channel is required") } if !tr.success && !tr.err { return errors.New("streamgate: terminal result must be either success or error") } if tr.success && tr.err { return errors.New("streamgate: terminal result cannot be both success and error") } if tr.err { if tr.externalDesc == nil { return errors.New("streamgate: terminal result error requires an external descriptor") } if err := tr.externalDesc.Validate(); err != nil { return err } if tr.failureCauses.Len() > MaxFailureCauses { return errors.New("streamgate: terminal result failure cause chain exceeds maximum") } for i := 0; i < tr.failureCauses.Len(); i++ { c, err := tr.failureCauses.At(i) if err != nil { return errors.New("streamgate: terminal result failure cause chain: " + err.Error()) } if err := c.Validate(); err != nil { return errors.New("streamgate: terminal result failure cause chain entry " + strconv.Itoa(i) + ": " + err.Error()) } } } if tr.success { if tr.externalDesc != nil { return errors.New("streamgate: terminal result success must not have an external descriptor") } if tr.failureCauses.Len() > 0 { return errors.New("streamgate: terminal result success must not have failure causes") } } if tr.occurredAt.IsZero() { return errors.New("streamgate: terminal result occurred timestamp is required") } return nil } // Channel returns the terminal result channel. func (tr TerminalResult) Channel() string { return tr.channel } // Success returns whether this is a success terminal result. func (tr TerminalResult) Success() bool { return tr.success } // Error returns whether this is an error terminal result. func (tr TerminalResult) Error() bool { return tr.err } // ExternalDesc returns a defensive copy of the external descriptor, or nil // for success results. func (tr TerminalResult) ExternalDesc() *ExternalDescriptor { if tr.externalDesc == nil { return nil } cp := *tr.externalDesc return &cp } // FailureCauses returns a defensive copy of the failure cause chain. func (tr TerminalResult) FailureCauses() FailureCauseChain { return tr.failureCauses.Copy() } // OccurredAt returns the timestamp when the terminal result occurred. func (tr TerminalResult) OccurredAt() time.Time { return tr.occurredAt } // ReleaseSink is the host interface that the stream gate calls to commit // release payloads to the downstream sink. The actual state machine // implementation belongs to a subsequent Milestone Task; this interface // definition provides the contract only. // // CommitResponseStart, Release, and CommitTerminal are mutually exclusive // roles: response-start, safe event, and terminal each have a dedicated // entry point. ReleaseEvent must never carry response-start or terminal // payloads; those are handled exclusively through the dedicated methods. type ReleaseSink interface { // CommitResponseStart commits a staged response start to the downstream // sink. It returns the resulting commit state and any error. CommitResponseStart(ctx context.Context, rs ResponseStart) (CommitState, error) // Release commits a release event to the downstream sink. It returns the // resulting commit state and any error. Release(ctx context.Context, ev ReleaseEvent) (CommitState, error) // CommitTerminal commits a terminal result to the downstream sink. It // returns the resulting commit state and any error. CommitTerminal(ctx context.Context, tr TerminalResult) (CommitState, error) }