iop/packages/go/streamgate/terminal.go
toki c90bb755a9 feat: streamline plan/code-review/finalize router, add stream gate SDDs, sync dev-test inventory, update roadmap milestones
- Refactor plan, code-review, finalize-task-routing, refine-local-plans, router skills
- Add agent-workflow-loop-orchestration skill and plan agent configs
- Update roadmap: knowledge-tool-optimization milestones, stream-evidence-gate-core SDD
- Add stream-evidence-gate-core task, archive, and Go streamgate package
- Update dev-test inventory (edge/node smoke), agent-contract, edge-local-dev-guide
- Deprecate USER_REVIEW for output-validation-filters SDD
2026-07-24 15:11:00 +09:00

562 lines
18 KiB
Go

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