- OpenAI request rebuilder with tool validation and provider tunnel - Edge config runtime refresh for stream evidence gate - Filter observation contract and runtime with sink/correlation - Stream gate dispatcher, release sink, and vertical slice - Recovery coordinator for evidence tail - Parallel evaluation and commit boundary - E2E test script for OpenAI vLLM - Archive completed task groups to archive/2026/07
2438 lines
84 KiB
Go
2438 lines
84 KiB
Go
// Package streamgate provides transport-agnostic event contract types for the
|
|
// stream evidence gate. It owns the normalized event lifecycle, immutable
|
|
// evidence/filter decision types, and terminal/failure/release payloads.
|
|
//
|
|
// This package must not import apps/, proto/, or packages/go/config/.
|
|
// It depends only on the Go standard library.
|
|
package streamgate
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// FilterHoldMode identifies how a filter holds pending evidence before
|
|
// allowing release. It is a closed set of modes; each mode defines its own
|
|
// trigger condition and field requirements.
|
|
type FilterHoldMode string
|
|
|
|
// knownFilterHoldModes is the closed set of valid FilterHoldMode values.
|
|
var knownFilterHoldModes = map[FilterHoldMode]struct{}{
|
|
FilterHoldModeNone: {},
|
|
FilterHoldModeRolling: {},
|
|
FilterHoldModeTerminalGate: {},
|
|
FilterHoldModeFragmentGate: {},
|
|
}
|
|
|
|
const (
|
|
// FilterHoldModeNone indicates no hold. Events pass through immediately.
|
|
FilterHoldModeNone FilterHoldMode = "none"
|
|
|
|
// FilterHoldModeRolling indicates the filter holds until the rolling
|
|
// rune evidence window meets a configured threshold. Time is not a
|
|
// release condition.
|
|
FilterHoldModeRolling FilterHoldMode = "rolling_window"
|
|
|
|
// FilterHoldModeTerminalGate indicates the filter holds all events until
|
|
// a terminal event is received. Terminal or provider-error events serve
|
|
// as the trigger.
|
|
FilterHoldModeTerminalGate FilterHoldMode = "terminal_gate"
|
|
|
|
// FilterHoldModeFragmentGate indicates the filter holds tool-call
|
|
// fragments until a CompleteFragment signal arrives for the keyed ID.
|
|
FilterHoldModeFragmentGate FilterHoldMode = "fragment_gate"
|
|
)
|
|
|
|
// Validate returns nil when the mode is a known lifecycle value.
|
|
func (m FilterHoldMode) Validate() error {
|
|
switch m {
|
|
case FilterHoldModeNone, FilterHoldModeRolling, FilterHoldModeTerminalGate, FilterHoldModeFragmentGate:
|
|
return nil
|
|
}
|
|
return errors.New("streamgate: unknown filter hold mode: " + string(m))
|
|
}
|
|
|
|
// IsValidMode returns true when m is a known hold mode value.
|
|
func IsValidMode(m FilterHoldMode) bool {
|
|
_, ok := knownFilterHoldModes[m]
|
|
return ok
|
|
}
|
|
|
|
// defaultEvidenceRunes is the default Unicode rune evidence window used when
|
|
// no policy override is provided.
|
|
const defaultEvidenceRunes = 500
|
|
|
|
// minEvidenceRunes is the absolute minimum allowed rune window.
|
|
const minEvidenceRunes = 1
|
|
|
|
// maxEvidenceRunes is the absolute maximum allowed rune window.
|
|
const maxEvidenceRunes = 65536
|
|
|
|
// defaultMaxBufferRunes is the default hard buffer limit in runes.
|
|
const defaultMaxBufferRunes = 4096
|
|
|
|
// minMaxBufferRunes is the absolute minimum allowed buffer limit.
|
|
const minMaxBufferRunes = 10
|
|
|
|
// maxMaxBufferRunes is the absolute maximum allowed buffer limit.
|
|
const maxMaxBufferRunes = 1048576
|
|
|
|
// FilterHoldRequirement is an immutable specification of how a filter holds
|
|
// pending evidence before releasing it. The fields match the SDD naming and
|
|
// semantics exactly. A requirement is mode-specific: rolling requires a
|
|
// positive rune threshold, terminal gate requires a terminal trigger kind,
|
|
// fragment gate requires a fragment trigger kind, and none is a no-op.
|
|
type FilterHoldRequirement struct {
|
|
channel string
|
|
mode FilterHoldMode
|
|
subscribedKinds []EventKind
|
|
evidenceRunes int
|
|
triggerKind EventKind
|
|
maxBufferRunes int
|
|
}
|
|
|
|
// NewFilterHoldRequirementNone creates a FilterHoldRequirement of mode none.
|
|
// None mode has no rune threshold, no trigger, and releases immediately.
|
|
// Subscribed kinds are required but unused.
|
|
func NewFilterHoldRequirementNone(channel string, subscribedKinds []EventKind) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("none", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
kinds, err := validateSubscribedKinds("none", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeNone,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: 0,
|
|
triggerKind: "",
|
|
maxBufferRunes: 0,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementRollingWithMaxBuffer creates a FilterHoldRequirement
|
|
// of mode rolling_window with an explicit max_buffer_runes override. The
|
|
// rune threshold must be within the allowed range. The max_buffer_runes must
|
|
// be >= evidence_runes to ensure the buffer can hold the evidence window.
|
|
func NewFilterHoldRequirementRollingWithMaxBuffer(channel string, subscribedKinds []EventKind, evidenceRunes int, maxBufferRunes int) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("rolling_window", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if evidenceRunes < minEvidenceRunes || evidenceRunes > maxEvidenceRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: rolling_window evidence_runes must be between %d and %d, got %d", minEvidenceRunes, maxEvidenceRunes, evidenceRunes)
|
|
}
|
|
if maxBufferRunes < evidenceRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: rolling_window max_buffer_runes must be >= evidence_runes, got evidence=%d buffer=%d", evidenceRunes, maxBufferRunes)
|
|
}
|
|
if maxBufferRunes < minMaxBufferRunes || maxBufferRunes > maxMaxBufferRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: rolling_window max_buffer_runes must be between %d and %d, got %d", minMaxBufferRunes, maxMaxBufferRunes, maxBufferRunes)
|
|
}
|
|
kinds, err := validateSubscribedKinds("rolling_window", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeRolling,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: evidenceRunes,
|
|
triggerKind: "",
|
|
maxBufferRunes: maxBufferRunes,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementRolling creates a FilterHoldRequirement of mode
|
|
// rolling_window. The rune threshold must be within the allowed range. The
|
|
// subscribed kinds define which event types contribute to the rolling count.
|
|
func NewFilterHoldRequirementRolling(channel string, subscribedKinds []EventKind, evidenceRunes int) (FilterHoldRequirement, error) {
|
|
return NewFilterHoldRequirementRollingWithMaxBuffer(channel, subscribedKinds, evidenceRunes, defaultMaxBufferRunes)
|
|
}
|
|
|
|
// NewFilterHoldRequirementTerminalGateWithMaxBuffer creates a FilterHoldRequirement
|
|
// of mode terminal_gate with an explicit max_buffer_runes override.
|
|
func NewFilterHoldRequirementTerminalGateWithMaxBuffer(channel string, subscribedKinds []EventKind, triggerKind EventKind, maxBufferRunes int) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("terminal_gate", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if triggerKind != EventKindTerminal && triggerKind != EventKindProviderError {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: terminal_gate trigger must be terminal or provider_error, got %q", triggerKind)
|
|
}
|
|
if maxBufferRunes < minMaxBufferRunes || maxBufferRunes > maxMaxBufferRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: terminal_gate max_buffer_runes must be between %d and %d, got %d", minMaxBufferRunes, maxMaxBufferRunes, maxBufferRunes)
|
|
}
|
|
kinds, err := validateSubscribedKinds("terminal_gate", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeTerminalGate,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: 0,
|
|
triggerKind: triggerKind,
|
|
maxBufferRunes: maxBufferRunes,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementTerminalGate creates a FilterHoldRequirement of
|
|
// mode terminal_gate. The trigger kind must be terminal or provider_error.
|
|
func NewFilterHoldRequirementTerminalGate(channel string, subscribedKinds []EventKind, triggerKind EventKind) (FilterHoldRequirement, error) {
|
|
return NewFilterHoldRequirementTerminalGateWithMaxBuffer(channel, subscribedKinds, triggerKind, defaultMaxBufferRunes)
|
|
}
|
|
|
|
// NewFilterHoldRequirementFragmentGateWithMaxBuffer creates a FilterHoldRequirement
|
|
// of mode fragment_gate with an explicit max_buffer_runes override.
|
|
func NewFilterHoldRequirementFragmentGateWithMaxBuffer(channel string, subscribedKinds []EventKind, triggerKind EventKind, maxBufferRunes int) (FilterHoldRequirement, error) {
|
|
if err := validateHoldChannel("fragment_gate", channel); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
if triggerKind != EventKindToolCallFragment {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: fragment_gate trigger must be tool_call_fragment, got %q", triggerKind)
|
|
}
|
|
if maxBufferRunes < minMaxBufferRunes || maxBufferRunes > maxMaxBufferRunes {
|
|
return FilterHoldRequirement{}, fmt.Errorf("streamgate: fragment_gate max_buffer_runes must be between %d and %d, got %d", minMaxBufferRunes, maxMaxBufferRunes, maxBufferRunes)
|
|
}
|
|
kinds, err := validateSubscribedKinds("fragment_gate", subscribedKinds)
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
req := FilterHoldRequirement{
|
|
channel: channel,
|
|
mode: FilterHoldModeFragmentGate,
|
|
subscribedKinds: kinds,
|
|
evidenceRunes: 0,
|
|
triggerKind: triggerKind,
|
|
maxBufferRunes: maxBufferRunes,
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldRequirement{}, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// NewFilterHoldRequirementFragmentGate creates a FilterHoldRequirement of
|
|
// mode fragment_gate. The trigger kind must be tool_call_fragment.
|
|
func NewFilterHoldRequirementFragmentGate(channel string, subscribedKinds []EventKind, triggerKind EventKind) (FilterHoldRequirement, error) {
|
|
return NewFilterHoldRequirementFragmentGateWithMaxBuffer(channel, subscribedKinds, triggerKind, defaultMaxBufferRunes)
|
|
}
|
|
|
|
func validateHoldChannel(mode, channel string) error {
|
|
if channel == "" {
|
|
return errors.New("streamgate: hold requirement channel is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSubscribedKinds(mode string, kinds []EventKind) ([]EventKind, error) {
|
|
if len(kinds) == 0 {
|
|
return nil, fmt.Errorf("streamgate: %s hold requirement subscribed kinds must not be empty", mode)
|
|
}
|
|
seen := make(map[EventKind]struct{}, len(kinds))
|
|
for _, k := range kinds {
|
|
if err := k.Validate(); err != nil {
|
|
return nil, fmt.Errorf("streamgate: %s hold requirement subscribed kind: %v", mode, err)
|
|
}
|
|
if _, dup := seen[k]; dup {
|
|
return nil, fmt.Errorf("streamgate: %s hold requirement duplicate subscribed kind: %s", mode, k)
|
|
}
|
|
seen[k] = struct{}{}
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out, nil
|
|
}
|
|
|
|
// Validate returns nil when the requirement is in a consistent state.
|
|
// Mode-specific field rules are enforced.
|
|
func (r FilterHoldRequirement) Validate() error {
|
|
if r.channel == "" {
|
|
return errors.New("streamgate: hold requirement channel is required")
|
|
}
|
|
if err := r.mode.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if len(r.subscribedKinds) == 0 {
|
|
return errors.New("streamgate: hold requirement subscribed kinds must not be empty")
|
|
}
|
|
switch r.mode {
|
|
case FilterHoldModeNone:
|
|
if r.evidenceRunes != 0 {
|
|
return errors.New("streamgate: none mode must not have evidence_runes")
|
|
}
|
|
if r.triggerKind != "" {
|
|
return errors.New("streamgate: none mode must not have a trigger")
|
|
}
|
|
if r.maxBufferRunes != 0 {
|
|
return errors.New("streamgate: none mode must not have max_buffer_runes")
|
|
}
|
|
case FilterHoldModeRolling:
|
|
if r.evidenceRunes < minEvidenceRunes || r.evidenceRunes > maxEvidenceRunes {
|
|
return errors.New("streamgate: rolling_window evidence_runes out of range")
|
|
}
|
|
if r.triggerKind != "" {
|
|
return errors.New("streamgate: rolling_window mode must not have a trigger")
|
|
}
|
|
if r.maxBufferRunes < minMaxBufferRunes || r.maxBufferRunes > maxMaxBufferRunes {
|
|
return errors.New("streamgate: rolling_window max_buffer_runes out of range")
|
|
}
|
|
case FilterHoldModeTerminalGate:
|
|
if r.evidenceRunes != 0 {
|
|
return errors.New("streamgate: terminal_gate mode must not have evidence_runes")
|
|
}
|
|
if r.triggerKind != EventKindTerminal && r.triggerKind != EventKindProviderError {
|
|
return errors.New("streamgate: terminal_gate trigger must be terminal or provider_error")
|
|
}
|
|
if r.maxBufferRunes < minMaxBufferRunes || r.maxBufferRunes > maxMaxBufferRunes {
|
|
return errors.New("streamgate: terminal_gate max_buffer_runes out of range")
|
|
}
|
|
case FilterHoldModeFragmentGate:
|
|
if r.evidenceRunes != 0 {
|
|
return errors.New("streamgate: fragment_gate mode must not have evidence_runes")
|
|
}
|
|
if r.triggerKind != EventKindToolCallFragment {
|
|
return errors.New("streamgate: fragment_gate trigger must be tool_call_fragment")
|
|
}
|
|
if r.maxBufferRunes < minMaxBufferRunes || r.maxBufferRunes > maxMaxBufferRunes {
|
|
return errors.New("streamgate: fragment_gate max_buffer_runes out of range")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Channel returns the requirement channel key.
|
|
func (r FilterHoldRequirement) Channel() string { return r.channel }
|
|
|
|
// Mode returns the hold mode.
|
|
func (r FilterHoldRequirement) Mode() FilterHoldMode { return r.mode }
|
|
|
|
// SubscribedKinds returns a defensive copy of the subscribed event kinds.
|
|
func (r FilterHoldRequirement) SubscribedKinds() []EventKind {
|
|
if r.subscribedKinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(r.subscribedKinds))
|
|
copy(out, r.subscribedKinds)
|
|
return out
|
|
}
|
|
|
|
// EvidenceRunes returns the rolling rune threshold. Returns 0 for non-rolling
|
|
// modes.
|
|
func (r FilterHoldRequirement) EvidenceRunes() int { return r.evidenceRunes }
|
|
|
|
// TriggerKind returns the trigger event kind. Returns "" for modes that do
|
|
// not use a trigger.
|
|
func (r FilterHoldRequirement) TriggerKind() EventKind { return r.triggerKind }
|
|
|
|
// MaxBufferRunes returns the hard buffer limit in runes.
|
|
func (r FilterHoldRequirement) MaxBufferRunes() int { return r.maxBufferRunes }
|
|
|
|
// IsBlocking returns true when this requirement's mode blocks channel release.
|
|
// None and observe-only modes are non-blocking; rolling, terminal_gate, and
|
|
// fragment_gate are blocking.
|
|
func (r FilterHoldRequirement) IsBlocking() bool {
|
|
switch r.mode {
|
|
case FilterHoldModeRolling, FilterHoldModeTerminalGate, FilterHoldModeFragmentGate:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// FilterHoldBinding pairs a stable filter ID with its requirement and an
|
|
// explicit FilterEnforcement resolved by the caller. The binding is
|
|
// immutable to callers.
|
|
type FilterHoldBinding struct {
|
|
filterID StableToken
|
|
requirement FilterHoldRequirement
|
|
enforcement FilterEnforcement
|
|
blocksRelease bool
|
|
}
|
|
|
|
// NewFilterHoldBinding creates a FilterHoldBinding with validation. The
|
|
// returned value is immutable to callers.
|
|
func NewFilterHoldBinding(filterID string, req FilterHoldRequirement, enforcement FilterEnforcement) (FilterHoldBinding, error) {
|
|
f, err := NewStableTokenRequired("filterID", filterID)
|
|
if err != nil {
|
|
return FilterHoldBinding{}, err
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return FilterHoldBinding{}, err
|
|
}
|
|
if err := enforcement.Validate(); err != nil {
|
|
return FilterHoldBinding{}, err
|
|
}
|
|
blocksRelease := (enforcement == FilterEnforcementBlocking && req.Mode() != FilterHoldModeNone)
|
|
return FilterHoldBinding{
|
|
filterID: f,
|
|
requirement: req,
|
|
enforcement: enforcement,
|
|
blocksRelease: blocksRelease,
|
|
}, nil
|
|
}
|
|
|
|
// NewFilterHoldBindingFromResolvedFilter constructs a FilterHoldBinding from a ResolvedFilter.
|
|
func NewFilterHoldBindingFromResolvedFilter(rf ResolvedFilter) (FilterHoldBinding, error) {
|
|
return NewFilterHoldBinding(rf.FilterID(), rf.HoldRequirement(), rf.Enforcement())
|
|
}
|
|
|
|
// FilterID returns the stable filter id token.
|
|
func (b FilterHoldBinding) FilterID() string { return b.filterID.value }
|
|
|
|
// Requirement returns the hold requirement.
|
|
func (b FilterHoldBinding) Requirement() FilterHoldRequirement { return b.requirement }
|
|
|
|
// Enforcement returns the effective enforcement.
|
|
func (b FilterHoldBinding) Enforcement() FilterEnforcement { return b.enforcement }
|
|
|
|
// BlocksRelease returns whether this binding blocks channel release.
|
|
func (b FilterHoldBinding) BlocksRelease() bool { return b.blocksRelease }
|
|
|
|
// FilterApplicability captures typed readiness and subscription status for a single filter in an epoch.
|
|
type FilterApplicability struct {
|
|
filterID StableToken
|
|
subscribedEventPresent bool
|
|
triggerReady bool
|
|
}
|
|
|
|
// NewFilterApplicability creates a validated FilterApplicability.
|
|
func NewFilterApplicability(filterID string, subscribedEventPresent, triggerReady bool) (FilterApplicability, error) {
|
|
st, err := NewStableTokenRequired("filterID", filterID)
|
|
if err != nil {
|
|
return FilterApplicability{}, fmt.Errorf("streamgate: filter applicability id: %w", err)
|
|
}
|
|
return FilterApplicability{
|
|
filterID: st,
|
|
subscribedEventPresent: subscribedEventPresent,
|
|
triggerReady: triggerReady,
|
|
}, nil
|
|
}
|
|
|
|
// FilterID returns the stable filter id.
|
|
func (a FilterApplicability) FilterID() string { return a.filterID.value }
|
|
|
|
// SubscribedEventPresent returns whether subscribed events were present for this filter in the epoch.
|
|
func (a FilterApplicability) SubscribedEventPresent() bool { return a.subscribedEventPresent }
|
|
|
|
// TriggerReady returns whether the filter trigger condition is satisfied in the epoch.
|
|
func (a FilterApplicability) TriggerReady() bool { return a.triggerReady }
|
|
|
|
// evidencePlan holds the compiled per-channel hold plan derived from bindings.
|
|
type evidencePlan struct {
|
|
bindingsByChannel map[string][]FilterHoldBinding
|
|
allBindings map[string]FilterHoldBinding
|
|
observeOnlyKinds map[string][]EventKind
|
|
allKinds map[string][]EventKind
|
|
blockingKinds map[string][]EventKind
|
|
maxBufferRunes map[string]int
|
|
rollingEvidenceRunes map[string]int
|
|
}
|
|
|
|
// compileEvidencePlan produces a compiled plan from a slice of bindings.
|
|
func compileEvidencePlan(bindings []FilterHoldBinding) (evidencePlan, error) {
|
|
plan := evidencePlan{
|
|
bindingsByChannel: make(map[string][]FilterHoldBinding),
|
|
allBindings: make(map[string]FilterHoldBinding),
|
|
observeOnlyKinds: make(map[string][]EventKind),
|
|
allKinds: make(map[string][]EventKind),
|
|
blockingKinds: make(map[string][]EventKind),
|
|
maxBufferRunes: make(map[string]int),
|
|
rollingEvidenceRunes: make(map[string]int),
|
|
}
|
|
|
|
for _, b := range bindings {
|
|
fID := b.FilterID()
|
|
if fID == "" {
|
|
return evidencePlan{}, errors.New("streamgate: binding filter id is required")
|
|
}
|
|
if _, dup := plan.allBindings[fID]; dup {
|
|
return evidencePlan{}, fmt.Errorf("streamgate: duplicate binding filter id: %s", fID)
|
|
}
|
|
req := b.requirement
|
|
if err := req.Validate(); err != nil {
|
|
return evidencePlan{}, fmt.Errorf("streamgate: binding requirement invalid: %w", err)
|
|
}
|
|
ch := req.Channel()
|
|
|
|
plan.allBindings[fID] = b
|
|
plan.bindingsByChannel[ch] = append(plan.bindingsByChannel[ch], b)
|
|
|
|
plan.allKinds[ch] = mergeKinds(plan.allKinds[ch], req.SubscribedKinds())
|
|
|
|
if b.BlocksRelease() && req.IsBlocking() {
|
|
plan.blockingKinds[ch] = mergeKinds(plan.blockingKinds[ch], req.SubscribedKinds())
|
|
plan.maxBufferRunes[ch] = minPositiveBound(plan.maxBufferRunes[ch], req.MaxBufferRunes())
|
|
if req.Mode() == FilterHoldModeRolling {
|
|
plan.rollingEvidenceRunes[ch] = maxInt(plan.rollingEvidenceRunes[ch], req.EvidenceRunes())
|
|
}
|
|
} else {
|
|
plan.observeOnlyKinds[ch] = mergeKinds(plan.observeOnlyKinds[ch], req.SubscribedKinds())
|
|
}
|
|
}
|
|
|
|
// Sort bindings per channel by filter id ascending
|
|
for ch := range plan.bindingsByChannel {
|
|
sort.SliceStable(plan.bindingsByChannel[ch], func(i, j int) bool {
|
|
return plan.bindingsByChannel[ch][i].FilterID() < plan.bindingsByChannel[ch][j].FilterID()
|
|
})
|
|
}
|
|
|
|
// Validate bounds per channel: max rolling evidence must be <= shared hard bound
|
|
for ch := range plan.bindingsByChannel {
|
|
maxBuf := plan.maxBufferRunes[ch]
|
|
rollEv := plan.rollingEvidenceRunes[ch]
|
|
if rollEv > 0 && maxBuf > 0 && rollEv > maxBuf {
|
|
return evidencePlan{}, fmt.Errorf("streamgate: max rolling evidence %d > shared hard bound %d for channel %s", rollEv, maxBuf, ch)
|
|
}
|
|
}
|
|
|
|
return plan, nil
|
|
}
|
|
|
|
// isKindSubscribed returns true when kind is in the subscribed kinds slice.
|
|
func isKindSubscribed(kind EventKind, subscribed []EventKind) bool {
|
|
for _, k := range subscribed {
|
|
if k == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// appendUniqueKind appends kind to existing only if not already present.
|
|
func appendUniqueKind(existing []EventKind, kind EventKind) []EventKind {
|
|
for _, k := range existing {
|
|
if k == kind {
|
|
return existing
|
|
}
|
|
}
|
|
return append(existing, kind)
|
|
}
|
|
|
|
// strongerMode returns true when a is strictly stronger than b in the
|
|
// hold priority: terminal_gate > fragment_gate > rolling_window.
|
|
func strongerMode(a, b FilterHoldMode) bool {
|
|
priority := map[FilterHoldMode]int{
|
|
FilterHoldModeRolling: 1,
|
|
FilterHoldModeFragmentGate: 2,
|
|
FilterHoldModeTerminalGate: 3,
|
|
}
|
|
return priority[a] > priority[b]
|
|
}
|
|
|
|
// minPositiveBound returns the smaller of two positive values; if either is
|
|
// zero (e.g. none mode has no hard bound) it returns the other.
|
|
func minPositiveBound(a, b int) int {
|
|
if a == 0 {
|
|
return b
|
|
}
|
|
if b == 0 {
|
|
return a
|
|
}
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// maxInt returns the larger of two ints.
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func mergeKinds(a, b []EventKind) []EventKind {
|
|
seen := make(map[EventKind]struct{}, len(a)+len(b))
|
|
var out []EventKind
|
|
for _, k := range a {
|
|
if _, ok := seen[k]; !ok {
|
|
seen[k] = struct{}{}
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
for _, k := range b {
|
|
if _, ok := seen[k]; !ok {
|
|
seen[k] = struct{}{}
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
// EvidencePlan is the compiled channel plan for evidence holding. It is
|
|
// immutable to callers once created. The plan only includes blocking
|
|
// requirements; observe-only and none-mode bindings never create a channel
|
|
// hold on their own.
|
|
type EvidencePlan struct {
|
|
plan evidencePlan
|
|
}
|
|
|
|
// NewEvidencePlan compiles a channel hold plan from the provided bindings.
|
|
// Only blocking bindings affect the hold decision. Returns an error if any
|
|
// binding's requirement fails validation.
|
|
func NewEvidencePlan(bindings []FilterHoldBinding) (EvidencePlan, error) {
|
|
if len(bindings) == 0 {
|
|
return EvidencePlan{plan: evidencePlan{
|
|
bindingsByChannel: make(map[string][]FilterHoldBinding),
|
|
allBindings: make(map[string]FilterHoldBinding),
|
|
observeOnlyKinds: make(map[string][]EventKind),
|
|
allKinds: make(map[string][]EventKind),
|
|
blockingKinds: make(map[string][]EventKind),
|
|
maxBufferRunes: make(map[string]int),
|
|
rollingEvidenceRunes: make(map[string]int),
|
|
}}, nil
|
|
}
|
|
compiled, err := compileEvidencePlan(bindings)
|
|
if err != nil {
|
|
return EvidencePlan{}, err
|
|
}
|
|
return EvidencePlan{plan: compiled}, nil
|
|
}
|
|
|
|
// NewEvidencePlanFromResolvedFilters compiles an EvidencePlan from ResolvedFilters.
|
|
func NewEvidencePlanFromResolvedFilters(resolved []ResolvedFilter) (EvidencePlan, error) {
|
|
if len(resolved) == 0 {
|
|
return NewEvidencePlan(nil)
|
|
}
|
|
seen := make(map[string]struct{}, len(resolved))
|
|
bindings := make([]FilterHoldBinding, len(resolved))
|
|
for i, rf := range resolved {
|
|
fID := rf.FilterID()
|
|
if fID == "" {
|
|
return EvidencePlan{}, errors.New("streamgate: resolved filter id is required")
|
|
}
|
|
if _, dup := seen[fID]; dup {
|
|
return EvidencePlan{}, fmt.Errorf("streamgate: duplicate resolved filter id: %s", fID)
|
|
}
|
|
seen[fID] = struct{}{}
|
|
|
|
b, err := NewFilterHoldBindingFromResolvedFilter(rf)
|
|
if err != nil {
|
|
return EvidencePlan{}, fmt.Errorf("streamgate: build binding from resolved filter: %w", err)
|
|
}
|
|
bindings[i] = b
|
|
}
|
|
return NewEvidencePlan(bindings)
|
|
}
|
|
|
|
// BindingsForChannel returns a defensive copy of all bindings for the channel in stable filter id order.
|
|
func (p EvidencePlan) BindingsForChannel(channel string) []FilterHoldBinding {
|
|
bindings := p.plan.bindingsByChannel[channel]
|
|
if len(bindings) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]FilterHoldBinding, len(bindings))
|
|
copy(out, bindings)
|
|
return out
|
|
}
|
|
|
|
// Bindings returns a defensive copy of all bindings in stable filter id order.
|
|
func (p EvidencePlan) Bindings() []FilterHoldBinding {
|
|
var all []FilterHoldBinding
|
|
for _, b := range p.plan.allBindings {
|
|
all = append(all, b)
|
|
}
|
|
sort.SliceStable(all, func(i, j int) bool {
|
|
return all[i].FilterID() < all[j].FilterID()
|
|
})
|
|
return all
|
|
}
|
|
|
|
// HasModeForChannel returns true when channel has at least one binding with the given mode.
|
|
func (p EvidencePlan) HasModeForChannel(channel string, mode FilterHoldMode) bool {
|
|
bindings := p.plan.bindingsByChannel[channel]
|
|
for _, b := range bindings {
|
|
if b.Requirement().Mode() == mode {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsTerminalTriggerForChannel returns true when eventKind is a configured terminal trigger for any binding on channel.
|
|
func (p EvidencePlan) IsTerminalTriggerForChannel(channel string, kind EventKind) bool {
|
|
bindings := p.plan.bindingsByChannel[channel]
|
|
for _, b := range bindings {
|
|
if b.BlocksRelease() && b.Requirement().Mode() == FilterHoldModeTerminalGate {
|
|
if b.Requirement().TriggerKind() == kind {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// BlockingRequirementFor returns a representative storage envelope requirement for the channel.
|
|
func (p EvidencePlan) BlockingRequirementFor(channel string) (FilterHoldRequirement, bool) {
|
|
bindings := p.plan.bindingsByChannel[channel]
|
|
if len(bindings) == 0 {
|
|
return FilterHoldRequirement{}, false
|
|
}
|
|
var strongestMode FilterHoldMode = FilterHoldModeNone
|
|
var triggerKind EventKind
|
|
for _, b := range bindings {
|
|
if !b.BlocksRelease() || !b.Requirement().IsBlocking() {
|
|
continue
|
|
}
|
|
m := b.Requirement().Mode()
|
|
if strongerMode(m, strongestMode) {
|
|
strongestMode = m
|
|
triggerKind = b.Requirement().TriggerKind()
|
|
}
|
|
}
|
|
if strongestMode == FilterHoldModeNone {
|
|
return FilterHoldRequirement{}, false
|
|
}
|
|
maxBuf := p.plan.maxBufferRunes[channel]
|
|
rollEv := p.plan.rollingEvidenceRunes[channel]
|
|
blockingKinds := p.plan.blockingKinds[channel]
|
|
|
|
var req FilterHoldRequirement
|
|
var err error
|
|
switch strongestMode {
|
|
case FilterHoldModeRolling:
|
|
if rollEv <= 0 {
|
|
rollEv = defaultEvidenceRunes
|
|
}
|
|
if maxBuf <= 0 {
|
|
maxBuf = defaultMaxBufferRunes
|
|
}
|
|
if maxBuf < rollEv {
|
|
maxBuf = rollEv
|
|
}
|
|
req, err = NewFilterHoldRequirementRollingWithMaxBuffer(channel, blockingKinds, rollEv, maxBuf)
|
|
case FilterHoldModeTerminalGate:
|
|
if maxBuf <= 0 {
|
|
maxBuf = defaultMaxBufferRunes
|
|
}
|
|
req, err = NewFilterHoldRequirementTerminalGateWithMaxBuffer(channel, blockingKinds, triggerKind, maxBuf)
|
|
case FilterHoldModeFragmentGate:
|
|
if maxBuf <= 0 {
|
|
maxBuf = defaultMaxBufferRunes
|
|
}
|
|
req, err = NewFilterHoldRequirementFragmentGateWithMaxBuffer(channel, blockingKinds, triggerKind, maxBuf)
|
|
}
|
|
if err != nil {
|
|
return FilterHoldRequirement{}, false
|
|
}
|
|
return req, true
|
|
}
|
|
|
|
// HasBlockingRequirement returns true when the given channel has at least one
|
|
// blocking requirement in the plan.
|
|
func (p EvidencePlan) HasBlockingRequirement(channel string) bool {
|
|
bindings := p.plan.bindingsByChannel[channel]
|
|
for _, b := range bindings {
|
|
if b.BlocksRelease() && b.Requirement().IsBlocking() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// BlockingRequirement returns the representative blocking requirement for the
|
|
// channel, or an error if no blocking requirement exists.
|
|
func (p EvidencePlan) BlockingRequirement(channel string) (FilterHoldRequirement, error) {
|
|
req, ok := p.BlockingRequirementFor(channel)
|
|
if !ok {
|
|
return FilterHoldRequirement{}, errors.New("streamgate: no blocking requirement for channel: " + channel)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// SubscribeKinds returns a defensive copy of the merged subscribed kinds for the channel.
|
|
func (p EvidencePlan) SubscribeKinds(channel string) []EventKind {
|
|
kinds := p.plan.allKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// BlockingSubscribeKinds returns a defensive copy of the blocking subscribed
|
|
// kinds for the channel. These are the kinds that contribute to pending state.
|
|
// Returns nil when the channel has no blocking requirement.
|
|
func (p EvidencePlan) BlockingSubscribeKinds(channel string) []EventKind {
|
|
kinds := p.plan.blockingKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// BlockingKinds returns the union of event kinds that contribute to the
|
|
// channel's blocking pending state. Returns nil when the channel has no
|
|
// blocking requirement.
|
|
func (p EvidencePlan) BlockingKinds(channel string) []EventKind {
|
|
kinds := p.plan.blockingKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// ObserveKinds returns the union of event kinds that contribute to
|
|
// observation-only subscriptions but never to blocking pending state.
|
|
// Returns nil when the channel has no observe-only subscription.
|
|
func (p EvidencePlan) ObserveKinds(channel string) []EventKind {
|
|
kinds := p.plan.observeOnlyKinds[channel]
|
|
if kinds == nil {
|
|
return nil
|
|
}
|
|
out := make([]EventKind, len(kinds))
|
|
copy(out, kinds)
|
|
return out
|
|
}
|
|
|
|
// AllBindings returns a defensive copy of all bindings in stable filter ID order.
|
|
func (p EvidencePlan) AllBindings() []FilterHoldBinding {
|
|
if p.plan.allBindings == nil {
|
|
return nil
|
|
}
|
|
out := make([]FilterHoldBinding, 0, len(p.plan.allBindings))
|
|
for _, b := range p.plan.allBindings {
|
|
out = append(out, b)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
return out[i].FilterID() < out[j].FilterID()
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (p EvidencePlan) bindingIDs() []string {
|
|
bindings := p.AllBindings()
|
|
ids := make([]string, len(bindings))
|
|
for i, b := range bindings {
|
|
ids[i] = b.FilterID()
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// MaxBufferRunes returns the compiled max buffer runes for the channel.
|
|
func (p EvidencePlan) MaxBufferRunes(channel string) int {
|
|
return p.plan.maxBufferRunes[channel]
|
|
}
|
|
|
|
// RollingEvidenceRunes returns the compiled rolling evidence runes for the channel.
|
|
func (p EvidencePlan) RollingEvidenceRunes(channel string) int {
|
|
return p.plan.rollingEvidenceRunes[channel]
|
|
}
|
|
|
|
// BindEpochFilters evaluates epoch applicability and binds resolved filters to EpochFilters in stable filter ID order.
|
|
func (p EvidencePlan) BindEpochFilters(epoch EvidenceEpoch, resolvedFilters []ResolvedFilter) ([]EpochFilter, error) {
|
|
if epoch.ID() == 0 {
|
|
return nil, errors.New("streamgate: epoch id must be positive")
|
|
}
|
|
planIDs := p.bindingIDs()
|
|
if len(planIDs) == 0 && len(resolvedFilters) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
resolvedByID, err := indexResolvedFilters(resolvedFilters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
appsByID, err := indexApplicabilities(epoch.Applicabilities())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := requireExactFilterIDs(planIDs, resolvedByID, appsByID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]EpochFilter, 0, len(planIDs))
|
|
for _, id := range planIDs {
|
|
rf := resolvedByID[id]
|
|
app := appsByID[id]
|
|
ef, err := rf.BindEpoch(epoch.ID(), app)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("streamgate: bind epoch filter %s: %w", id, err)
|
|
}
|
|
out = append(out, ef)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func indexResolvedFilters(filters []ResolvedFilter) (map[string]ResolvedFilter, error) {
|
|
out := make(map[string]ResolvedFilter, len(filters))
|
|
for _, rf := range filters {
|
|
fID := rf.FilterID()
|
|
if fID == "" {
|
|
return nil, errors.New("streamgate: resolved filter id is required")
|
|
}
|
|
if _, dup := out[fID]; dup {
|
|
return nil, fmt.Errorf("streamgate: duplicate resolved filter id in bind epoch: %s", fID)
|
|
}
|
|
out[fID] = rf
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func indexApplicabilities(apps []FilterApplicability) (map[string]FilterApplicability, error) {
|
|
out := make(map[string]FilterApplicability, len(apps))
|
|
for _, app := range apps {
|
|
fID := app.FilterID()
|
|
if fID == "" {
|
|
return nil, errors.New("streamgate: applicability filter id is required")
|
|
}
|
|
if _, dup := out[fID]; dup {
|
|
return nil, fmt.Errorf("streamgate: duplicate applicability filter id in bind epoch: %s", fID)
|
|
}
|
|
out[fID] = app
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func requireExactFilterIDs(planIDs []string, resolvedByID map[string]ResolvedFilter, appsByID map[string]FilterApplicability) error {
|
|
if len(resolvedByID) != len(planIDs) {
|
|
return fmt.Errorf("streamgate: resolved filter count mismatch: got %d, expected %d", len(resolvedByID), len(planIDs))
|
|
}
|
|
if len(appsByID) != len(planIDs) {
|
|
return fmt.Errorf("streamgate: applicability count mismatch: got %d, expected %d", len(appsByID), len(planIDs))
|
|
}
|
|
for _, id := range planIDs {
|
|
if _, ok := resolvedByID[id]; !ok {
|
|
return fmt.Errorf("streamgate: missing resolved filter for id %s", id)
|
|
}
|
|
if _, ok := appsByID[id]; !ok {
|
|
return fmt.Errorf("streamgate: missing applicability for id %s", id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EvidenceEpoch is an immutable snapshot of evidence state at the moment of
|
|
// a state transition (e.g. threshold reached, trigger fired). It carries
|
|
// only safe, externally-facing identification and filter applicabilities.
|
|
type EvidenceEpoch struct {
|
|
id uint64
|
|
channel string
|
|
mode FilterHoldMode
|
|
triggered bool
|
|
reason string
|
|
applicabilities map[string]FilterApplicability
|
|
}
|
|
|
|
// NewEvidenceEpoch creates an EvidenceEpoch with the given parameters.
|
|
func NewEvidenceEpoch(id uint64, channel string, mode FilterHoldMode, triggered bool, reason string) (EvidenceEpoch, error) {
|
|
return NewEvidenceEpochWithApplicabilities(id, channel, mode, triggered, reason, nil)
|
|
}
|
|
|
|
// NewEvidenceEpochWithApplicabilities creates an EvidenceEpoch carrying filter applicabilities.
|
|
func NewEvidenceEpochWithApplicabilities(
|
|
id uint64, channel string, mode FilterHoldMode, triggered bool, reason string,
|
|
apps []FilterApplicability,
|
|
) (EvidenceEpoch, error) {
|
|
if channel == "" {
|
|
return EvidenceEpoch{}, errors.New("streamgate: epoch channel is required")
|
|
}
|
|
if err := mode.Validate(); err != nil {
|
|
return EvidenceEpoch{}, err
|
|
}
|
|
appMap := make(map[string]FilterApplicability, len(apps))
|
|
for _, app := range apps {
|
|
fID := app.FilterID()
|
|
if fID == "" {
|
|
return EvidenceEpoch{}, errors.New("streamgate: epoch applicability filter id is required")
|
|
}
|
|
if _, dup := appMap[fID]; dup {
|
|
return EvidenceEpoch{}, fmt.Errorf("streamgate: duplicate epoch applicability filter id: %s", fID)
|
|
}
|
|
appMap[fID] = app
|
|
}
|
|
return EvidenceEpoch{
|
|
id: id,
|
|
channel: channel,
|
|
mode: mode,
|
|
triggered: triggered,
|
|
reason: reason,
|
|
applicabilities: appMap,
|
|
}, nil
|
|
}
|
|
|
|
// ID returns the epoch identifier.
|
|
func (e EvidenceEpoch) ID() uint64 { return e.id }
|
|
|
|
// Channel returns the epoch channel.
|
|
func (e EvidenceEpoch) Channel() string { return e.channel }
|
|
|
|
// Mode returns the epoch hold mode.
|
|
func (e EvidenceEpoch) Mode() FilterHoldMode { return e.mode }
|
|
|
|
// Triggered returns whether the epoch was a trigger event.
|
|
func (e EvidenceEpoch) Triggered() bool { return e.triggered }
|
|
|
|
// Reason returns the epoch reason string.
|
|
func (e EvidenceEpoch) Reason() string { return e.reason }
|
|
|
|
// ApplicabilityFor returns the applicability for the filter ID, if present.
|
|
func (e EvidenceEpoch) ApplicabilityFor(filterID string) (FilterApplicability, bool) {
|
|
if e.applicabilities == nil {
|
|
return FilterApplicability{}, false
|
|
}
|
|
app, ok := e.applicabilities[filterID]
|
|
return app, ok
|
|
}
|
|
|
|
// Applicabilities returns a defensive copy of all applicabilities in stable filter ID order.
|
|
func (e EvidenceEpoch) Applicabilities() []FilterApplicability {
|
|
if e.applicabilities == nil {
|
|
return nil
|
|
}
|
|
out := make([]FilterApplicability, 0, len(e.applicabilities))
|
|
for _, app := range e.applicabilities {
|
|
out = append(out, app)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].filterID.value < out[j].filterID.value })
|
|
return out
|
|
}
|
|
|
|
// EvidenceTailSignal identifies the kind of signal emitted by evidence tail
|
|
// operations.
|
|
type EvidenceTailSignal string
|
|
|
|
const (
|
|
// EvidenceTailSignalNone indicates no signal was produced.
|
|
EvidenceTailSignalNone EvidenceTailSignal = "none"
|
|
|
|
// EvidenceTailSignalThreshold indicates the rolling rune threshold was
|
|
// reached and a batch is ready.
|
|
EvidenceTailSignalThreshold EvidenceTailSignal = "threshold"
|
|
|
|
// EvidenceTailSignalTrigger indicates the trigger event (terminal or
|
|
// fragment) was received.
|
|
EvidenceTailSignalTrigger EvidenceTailSignal = "trigger"
|
|
|
|
// EvidenceTailSignalBufferOverflow indicates the hard buffer limit was
|
|
// exceeded. No release is produced.
|
|
EvidenceTailSignalBufferOverflow EvidenceTailSignal = "overflow"
|
|
|
|
// EvidenceTailSignalReady indicates the state is ready for release
|
|
// (used when all accumulated events are eligible).
|
|
EvidenceTailSignalReady EvidenceTailSignal = "ready"
|
|
)
|
|
|
|
// EvidenceTailSignalOverflow is a typed signal for buffer overflow. It carries
|
|
// only the channel, bound, and cause code; no raw content.
|
|
type EvidenceTailSignalOverflow struct {
|
|
channel string
|
|
bound int
|
|
code string
|
|
}
|
|
|
|
// NewEvidenceTailSignalOverflow creates a typed overflow signal with the
|
|
// given parameters.
|
|
func NewEvidenceTailSignalOverflow(channel string, bound int, code string) (EvidenceTailSignalOverflow, error) {
|
|
if channel == "" {
|
|
return EvidenceTailSignalOverflow{}, errors.New("streamgate: overflow signal channel is required")
|
|
}
|
|
if bound <= 0 {
|
|
return EvidenceTailSignalOverflow{}, errors.New("streamgate: overflow signal bound must be positive")
|
|
}
|
|
if code == "" {
|
|
return EvidenceTailSignalOverflow{}, errors.New("streamgate: overflow signal cause code is required")
|
|
}
|
|
return EvidenceTailSignalOverflow{
|
|
channel: channel,
|
|
bound: bound,
|
|
code: code,
|
|
}, nil
|
|
}
|
|
|
|
// Channel returns the overflow channel.
|
|
func (s EvidenceTailSignalOverflow) Channel() string { return s.channel }
|
|
|
|
// Bound returns the overflow rune bound.
|
|
func (s EvidenceTailSignalOverflow) Bound() int { return s.bound }
|
|
|
|
// Code returns the overflow cause code.
|
|
func (s EvidenceTailSignalOverflow) Code() string { return s.code }
|
|
|
|
// EvidenceTail manages per-channel pending/look-behind state and tool-call
|
|
// fragment state for evidence holding. It is the core state machine that
|
|
// accumulates normalized events, validates UTF-8, counts Unicode runes, and
|
|
// produces epochs when thresholds or triggers are met.
|
|
type EvidenceTail struct {
|
|
plan EvidencePlan
|
|
epochCounter uint64
|
|
|
|
// channelState is keyed by channel name.
|
|
channelState map[string]*channelState
|
|
|
|
// epochs tracks epoch records by ID for validation during PrepareRelease
|
|
// and ConfirmRelease. Each epoch is bound to a specific channel and can
|
|
// only be prepared once.
|
|
epochs map[uint64]*epochRecord
|
|
|
|
// preparedByChannel tracks which channel has a pending prepared release
|
|
// for each epoch, preventing overlapping prepared tokens. Prepared
|
|
// replacements share this slot so a replacement and a release can never be
|
|
// outstanding on the same channel at once.
|
|
preparedByChannel map[string]string
|
|
|
|
// replacements tracks in-flight prepared replacements by token. Each record
|
|
// binds the replacement payload to the exact epoch, channel, and pending
|
|
// sequence range it settles.
|
|
replacements map[string]*replacementRecord
|
|
|
|
// tokenNonce is a monotonic counter that ensures every generated release
|
|
// token is globally unique. It prevents stale token reuse when the same
|
|
// epoch is re-prepared after zero or partial confirm.
|
|
tokenNonce uint64
|
|
}
|
|
|
|
// channelState holds the runtime state for a single channel.
|
|
type channelState struct {
|
|
// pendingEntries is the ordered list of pending entry records for this channel.
|
|
// Each entry tracks the event, its rune count, and associated fragment ID (if any).
|
|
pendingEntries []pendingEntry
|
|
|
|
// nextSequence is a monotonic counter that assigns an immutable identity
|
|
// to each pending entry. It is never decremented or reused, even after
|
|
// confirm removes entries from the front. This allows epoch records to
|
|
// anchor their snapshot to a fixed sequence range rather than a mutable
|
|
// slice index.
|
|
nextSequence int
|
|
|
|
// committedLookBehind is the bounded list of events already confirmed
|
|
// released downstream. Used for cross-boundary repeat detection.
|
|
committedLookBehind []NormalizedEvent
|
|
|
|
// committedCursor is the total number of events confirmed released
|
|
// downstream. This is a monotonic counter that increases with each
|
|
// successful ConfirmRelease and resets on replace.
|
|
committedCursor int
|
|
|
|
// pendingRunes is the cumulative Unicode rune count of pendingEntries.
|
|
pendingRunes int
|
|
|
|
// effectiveEvidenceRunes is the rolling evidence window used for
|
|
// look-behind trimming on this channel. For non-rolling modes it equals
|
|
// maxBufferRunes.
|
|
effectiveEvidenceRunes int
|
|
|
|
// fragmentState tracks incomplete tool-call fragments keyed by toolCallID.
|
|
fragmentState map[string]*fragmentState
|
|
|
|
// maxBufferRunes is the hard buffer limit for this channel.
|
|
maxBufferRunes int
|
|
}
|
|
|
|
// pendingEntry represents a single pending event with its metadata.
|
|
type pendingEntry struct {
|
|
event NormalizedEvent
|
|
runes int
|
|
kind EventKind
|
|
toolCallID string // empty if not a fragment
|
|
sequence int // monotonic sequence number
|
|
}
|
|
|
|
// fragmentState tracks an incomplete tool-call fragment.
|
|
// entries is the ordered list of pending entry sequences for this fragment ID.
|
|
// runes is the total rune count of all entries.
|
|
type fragmentState struct {
|
|
toolCallID string
|
|
entries []int // sequence numbers of pending entries
|
|
runes int
|
|
completed bool // true when CompleteFragment has been called for this ID
|
|
}
|
|
|
|
// epochRecord tracks an epoch's state through its lifecycle. Each epoch
|
|
// is bound to an exact pending-entry sequence range captured at creation
|
|
// time; remainingSnapshot returns only entries within that range that
|
|
// have not yet been confirmed.
|
|
//
|
|
// A record is valid for PrepareRelease only when:
|
|
// - consumed is false
|
|
// - invalidated is false
|
|
// - prepared and token carry a pending token whose channel matches
|
|
//
|
|
// Once invalidated (by recovery/continuation/terminal discard or a newer
|
|
// completion epoch), all token/prepared state is cleared and ConfirmRelease
|
|
// will reject any stale outstanding token with a deterministic error.
|
|
type epochRecord struct {
|
|
epochID uint64
|
|
channel string
|
|
mode FilterHoldMode // the hold mode at epoch creation
|
|
consumed bool // true after full confirm or replace
|
|
prepared bool // true after successful PrepareRelease
|
|
token string // the prepared release token, if any
|
|
snapshotSize int // number of events captured in the prepared snapshot
|
|
snapStartSeq int // first pending-entry monotonic sequence in the snapshot
|
|
snapEndSeq int // last pending-entry monotonic sequence (exclusive) in the snapshot
|
|
confirmedCount int // total events confirmed for this epoch across partial confirms
|
|
invalidated bool // true after ResetForReplace/PrepareContinuation/DiscardPendingForTerminal
|
|
}
|
|
|
|
// replacementRecord tracks a prepared replacement through its lifecycle. It
|
|
// anchors the substituted payload to one epoch, that epoch's channel, and the
|
|
// immutable pending-entry sequence range the epoch captured, so confirm can
|
|
// settle exactly that range and nothing else.
|
|
type replacementRecord struct {
|
|
token string
|
|
epochID uint64
|
|
channel string
|
|
snapStartSeq int // first pending-entry sequence being replaced
|
|
snapEndSeq int // last pending-entry sequence (exclusive) being replaced
|
|
events []NormalizedEvent // normalized replacement payload snapshot
|
|
}
|
|
|
|
// NewEvidenceTail creates a new EvidenceTail from the given compiled plan.
|
|
// It initializes per-channel state on first use.
|
|
func NewEvidenceTail(plan EvidencePlan) (*EvidenceTail, error) {
|
|
if err := plan.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &EvidenceTail{
|
|
plan: plan,
|
|
epochCounter: 0,
|
|
channelState: make(map[string]*channelState),
|
|
epochs: make(map[uint64]*epochRecord),
|
|
preparedByChannel: make(map[string]string),
|
|
replacements: make(map[string]*replacementRecord),
|
|
tokenNonce: 0,
|
|
}, nil
|
|
}
|
|
|
|
// validate checks the plan is consistent.
|
|
func (p EvidencePlan) validate() error {
|
|
for fID, b := range p.plan.allBindings {
|
|
if err := b.Requirement().Validate(); err != nil {
|
|
return fmt.Errorf("streamgate: compiled plan filter %s: %v", fID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
// errStalePreparedRelease is returned by ConfirmRelease when the token
|
|
// has already been consumed, invalidated, or does not match the current
|
|
// prepared state for the epoch's channel.
|
|
errStalePreparedRelease = errors.New("streamgate: stale prepared release token")
|
|
|
|
// errPreparedSnapshotMismatch is returned when the current pending
|
|
// prefix does not match the sequence range captured at PrepareRelease.
|
|
errPreparedSnapshotMismatch = errors.New("streamgate: prepared snapshot prefix mismatch")
|
|
)
|
|
|
|
// nextObservationEpoch generates an epoch ID for a non-blocking observation
|
|
// event. No epoch record is created; the epoch cannot be prepared or
|
|
// confirmed. This is used for events that pass through (unsubscribed,
|
|
// observe-only, sub-threshold, overflow) and must never acquire release
|
|
// capability.
|
|
func (t *EvidenceTail) nextObservationEpoch(channel string, mode FilterHoldMode, triggered bool, reason string) (EvidenceEpoch, error) {
|
|
return t.nextObservationEpochWithApps(channel, mode, triggered, reason, nil)
|
|
}
|
|
|
|
func (t *EvidenceTail) nextObservationEpochWithApps(channel string, mode FilterHoldMode, triggered bool, reason string, apps []FilterApplicability) (EvidenceEpoch, error) {
|
|
t.epochCounter++
|
|
epoch, err := NewEvidenceEpochWithApplicabilities(t.epochCounter, channel, mode, triggered, reason, apps)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, err
|
|
}
|
|
return epoch, nil
|
|
}
|
|
|
|
func (t *EvidenceTail) nextReadyEpochRecord(channel string, mode FilterHoldMode, triggered bool, reason string, firstSeq, lastSeqExclusive int) (EvidenceEpoch, *epochRecord, error) {
|
|
return t.nextReadyEpochRecordWithApps(channel, mode, triggered, reason, firstSeq, lastSeqExclusive, nil)
|
|
}
|
|
|
|
func (t *EvidenceTail) nextReadyEpochRecordWithApps(channel string, mode FilterHoldMode, triggered bool, reason string, firstSeq, lastSeqExclusive int, apps []FilterApplicability) (EvidenceEpoch, *epochRecord, error) {
|
|
t.epochCounter++
|
|
epoch, err := NewEvidenceEpochWithApplicabilities(t.epochCounter, channel, mode, triggered, reason, apps)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, nil, err
|
|
}
|
|
record := &epochRecord{
|
|
epochID: t.epochCounter,
|
|
channel: channel,
|
|
mode: mode,
|
|
consumed: false,
|
|
prepared: false,
|
|
token: "",
|
|
snapshotSize: 0,
|
|
snapStartSeq: firstSeq,
|
|
snapEndSeq: lastSeqExclusive,
|
|
}
|
|
t.epochs[t.epochCounter] = record
|
|
return epoch, record, nil
|
|
}
|
|
|
|
// computeApplicabilities derives typed readiness and subscription status for each filter in the plan.
|
|
type applicabilityInput struct {
|
|
event *NormalizedEvent
|
|
isTerminalTrigger bool
|
|
isFragmentComplete bool
|
|
isHardBound bool
|
|
toolCallID string
|
|
eventBuffered bool
|
|
}
|
|
|
|
type pendingStats struct {
|
|
present bool
|
|
runes int
|
|
}
|
|
|
|
func subscribedPendingStats(cs *channelState, subscribedKinds []EventKind) pendingStats {
|
|
var stats pendingStats
|
|
if cs == nil || len(cs.pendingEntries) == 0 {
|
|
return stats
|
|
}
|
|
for _, entry := range cs.pendingEntries {
|
|
if isKindSubscribed(entry.kind, subscribedKinds) {
|
|
stats.present = true
|
|
stats.runes += entry.runes
|
|
}
|
|
}
|
|
return stats
|
|
}
|
|
|
|
func deriveTriggerReady(
|
|
b FilterHoldBinding,
|
|
req FilterHoldRequirement,
|
|
stats pendingStats,
|
|
cs *channelState,
|
|
input applicabilityInput,
|
|
) bool {
|
|
if input.isHardBound {
|
|
if b.BlocksRelease() && req.IsBlocking() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
if input.isTerminalTrigger {
|
|
switch req.Mode() {
|
|
case FilterHoldModeTerminalGate:
|
|
return input.event != nil && (input.event.Kind() == req.TriggerKind() || input.event.Kind() == EventKindTerminal || input.event.Kind() == EventKindProviderError)
|
|
case FilterHoldModeRolling, FilterHoldModeFragmentGate, FilterHoldModeNone:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
if input.isFragmentComplete {
|
|
switch req.Mode() {
|
|
case FilterHoldModeFragmentGate:
|
|
return cs != nil && cs.isFragmentCompleted(input.toolCallID)
|
|
case FilterHoldModeRolling:
|
|
return stats.runes >= req.EvidenceRunes()
|
|
}
|
|
return false
|
|
}
|
|
switch req.Mode() {
|
|
case FilterHoldModeRolling:
|
|
return stats.runes >= req.EvidenceRunes()
|
|
case FilterHoldModeNone:
|
|
return input.event != nil && input.event.Kind() == EventKindProviderError && isKindSubscribed(EventKindProviderError, req.SubscribedKinds())
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *EvidenceTail) computeApplicabilities(
|
|
channel string,
|
|
event *NormalizedEvent,
|
|
isTerminalTrigger bool,
|
|
isFragmentComplete bool,
|
|
isHardBound bool,
|
|
toolCallID string,
|
|
eventBuffered bool,
|
|
) ([]FilterApplicability, error) {
|
|
allBindings := t.plan.AllBindings()
|
|
if len(allBindings) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
cs := t.channelState[channel]
|
|
input := applicabilityInput{
|
|
event: event,
|
|
isTerminalTrigger: isTerminalTrigger,
|
|
isFragmentComplete: isFragmentComplete,
|
|
isHardBound: isHardBound,
|
|
toolCallID: toolCallID,
|
|
eventBuffered: eventBuffered,
|
|
}
|
|
|
|
apps := make([]FilterApplicability, 0, len(allBindings))
|
|
for _, b := range allBindings {
|
|
fID := b.FilterID()
|
|
req := b.Requirement()
|
|
|
|
if req.Channel() != channel {
|
|
app, err := NewFilterApplicability(fID, false, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
apps = append(apps, app)
|
|
continue
|
|
}
|
|
|
|
stats := subscribedPendingStats(cs, req.SubscribedKinds())
|
|
if input.event != nil && !input.eventBuffered && isKindSubscribed(input.event.Kind(), req.SubscribedKinds()) {
|
|
stats.present = true
|
|
stats.runes += runeCountForEvent(*input.event)
|
|
}
|
|
|
|
subscribed := stats.present
|
|
ready := deriveTriggerReady(b, req, stats, cs, input)
|
|
|
|
app, err := NewFilterApplicability(fID, subscribed, ready)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
apps = append(apps, app)
|
|
}
|
|
|
|
return apps, nil
|
|
}
|
|
|
|
func (cs *channelState) unconfirmedSnapshot(record *epochRecord) []ReleaseEvent {
|
|
var releaseEvents []ReleaseEvent
|
|
for _, entry := range cs.pendingEntries {
|
|
if entry.sequence >= record.snapStartSeq && entry.sequence < record.snapEndSeq {
|
|
re, err := normalizedToReleaseEvent(entry.event)
|
|
if err == nil {
|
|
releaseEvents = append(releaseEvents, re)
|
|
}
|
|
}
|
|
}
|
|
return releaseEvents
|
|
}
|
|
|
|
func (t *EvidenceTail) validatePreparedOwnership(record *epochRecord, token string) error {
|
|
if record.invalidated {
|
|
return errStalePreparedRelease
|
|
}
|
|
if t.preparedByChannel[record.channel] != token {
|
|
return errStalePreparedRelease
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cs *channelState) pendingPrefixMatchesRange(n int, snapStartSeq int) bool {
|
|
if n > len(cs.pendingEntries) {
|
|
return false
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if cs.pendingEntries[i].sequence != snapStartSeq+i {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// rangeEntryCount returns how many pending entries still fall inside the
|
|
// half-open sequence range captured by an epoch snapshot.
|
|
func (cs *channelState) rangeEntryCount(startSeq, endSeqExclusive int) int {
|
|
count := 0
|
|
for _, entry := range cs.pendingEntries {
|
|
if entry.sequence >= startSeq && entry.sequence < endSeqExclusive {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// dropFragmentEntriesBelow removes fragment bookkeeping for pending entries
|
|
// whose sequence is already settled. Absolute sequences are preserved for the
|
|
// remaining entries so subsequent partial/full settlements stay correct.
|
|
func (cs *channelState) dropFragmentEntriesBelow(settledEndSeq int) {
|
|
for id, fragment := range cs.fragmentState {
|
|
var remaining []int
|
|
for _, seq := range fragment.entries {
|
|
if seq >= settledEndSeq {
|
|
remaining = append(remaining, seq)
|
|
}
|
|
}
|
|
if len(remaining) == 0 {
|
|
delete(cs.fragmentState, id)
|
|
} else {
|
|
fragment.entries = remaining
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *EvidenceTail) getOrCreateChannel(channel string, maxBuffer int, evidenceRunes int) (*channelState, error) {
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
effective := evidenceRunes
|
|
if effective <= 0 {
|
|
effective = maxBuffer
|
|
}
|
|
cs = &channelState{
|
|
pendingEntries: make([]pendingEntry, 0),
|
|
fragmentState: make(map[string]*fragmentState),
|
|
maxBufferRunes: maxBuffer,
|
|
effectiveEvidenceRunes: effective,
|
|
}
|
|
t.channelState[channel] = cs
|
|
}
|
|
return cs, nil
|
|
}
|
|
|
|
func (t *EvidenceTail) Append(event NormalizedEvent) (EvidenceEpoch, EvidenceTailSignal, error) {
|
|
if err := event.Validate(); err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, fmt.Errorf("streamgate: append event validate: %v", err)
|
|
}
|
|
|
|
channel := event.Channel()
|
|
req, hasBlocking := t.plan.BlockingRequirementFor(channel)
|
|
|
|
if err := validateEventUTF8(event); err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
|
|
if !hasBlocking {
|
|
apps, err := t.computeApplicabilities(channel, &event, false, false, false, "", false)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
epoch, err := t.nextObservationEpochWithApps(channel, FilterHoldModeNone, false, "pass_through_no_requirement", apps)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
return epoch, EvidenceTailSignalReady, nil
|
|
}
|
|
|
|
if event.Kind() == EventKindTerminal || event.Kind() == EventKindProviderError || t.plan.IsTerminalTriggerForChannel(channel, event.Kind()) {
|
|
cs, err := t.getOrCreateChannel(channel, t.plan.MaxBufferRunes(channel), t.plan.RollingEvidenceRunes(channel))
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
safeEnd := cs.nextSequence
|
|
firstSeq := cs.firstPendingSequence()
|
|
apps, err := t.computeApplicabilities(channel, &event, true, false, false, "", false)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
epoch, _, err := t.nextReadyEpochRecordWithApps(
|
|
channel, req.Mode(), true, "configured_trigger",
|
|
firstSeq, safeEnd, apps,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
return epoch, EvidenceTailSignalTrigger, nil
|
|
}
|
|
|
|
blockingKinds := t.plan.BlockingKinds(channel)
|
|
observeKinds := t.plan.ObserveKinds(channel)
|
|
|
|
if !isKindSubscribed(event.Kind(), blockingKinds) {
|
|
reason := "event_not_subscribed"
|
|
if isKindSubscribed(event.Kind(), observeKinds) {
|
|
reason = "observe_only_pass_through"
|
|
}
|
|
apps, err := t.computeApplicabilities(channel, &event, false, false, false, "", false)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
epoch, err := t.nextObservationEpochWithApps(channel, req.Mode(), false, reason, apps)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
return epoch, EvidenceTailSignalNone, nil
|
|
}
|
|
|
|
cs, err := t.getOrCreateChannel(channel, t.plan.MaxBufferRunes(channel), t.plan.RollingEvidenceRunes(channel))
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
|
|
runesAdded := runeCountForEvent(event)
|
|
newPendingRunes := cs.pendingRunes + runesAdded
|
|
|
|
if newPendingRunes > cs.maxBufferRunes {
|
|
apps, err := t.computeApplicabilities(channel, &event, false, false, true, "", false)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
epoch, err := t.nextObservationEpochWithApps(channel, req.Mode(), false, "overflow_beyond_buffer_limit", apps)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
return epoch, EvidenceTailSignalBufferOverflow, nil
|
|
}
|
|
|
|
seq := cs.nextSequence
|
|
cs.nextSequence++
|
|
entry := pendingEntry{
|
|
event: cloneNormalizedEvent(event),
|
|
runes: runesAdded,
|
|
kind: event.Kind(),
|
|
sequence: seq,
|
|
}
|
|
if event.Kind() == EventKindToolCallFragment {
|
|
tc, err := event.AsToolCallFragment()
|
|
if err == nil {
|
|
entry.toolCallID = tc.ID
|
|
fragment := cs.fragmentState[tc.ID]
|
|
if fragment == nil {
|
|
fragment = &fragmentState{
|
|
toolCallID: tc.ID,
|
|
entries: []int{},
|
|
runes: 0,
|
|
completed: false,
|
|
}
|
|
cs.fragmentState[tc.ID] = fragment
|
|
}
|
|
newFragmentRunes := fragment.runes + runesAdded
|
|
if newFragmentRunes > cs.maxBufferRunes {
|
|
return EvidenceEpoch{}, EvidenceTailSignalBufferOverflow, nil
|
|
}
|
|
fragment.entries = append(fragment.entries, entry.sequence)
|
|
fragment.runes = newFragmentRunes
|
|
}
|
|
}
|
|
cs.pendingEntries = append(cs.pendingEntries, entry)
|
|
cs.pendingRunes = newPendingRunes
|
|
|
|
apps, err := t.computeApplicabilities(channel, &event, false, false, false, "", true)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
anyReady := false
|
|
hasRollingReady := false
|
|
for _, app := range apps {
|
|
if app.TriggerReady() {
|
|
anyReady = true
|
|
for _, b := range t.plan.BindingsForChannel(channel) {
|
|
if b.FilterID() == app.FilterID() && b.Requirement().Mode() == FilterHoldModeRolling {
|
|
hasRollingReady = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if anyReady {
|
|
safeEnd := cs.nextSequence
|
|
firstSeq := cs.firstPendingSequence()
|
|
epoch, _, err := t.nextReadyEpochRecordWithApps(
|
|
channel, req.Mode(), true, "threshold_or_trigger_reached",
|
|
firstSeq, safeEnd, apps,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
sig := EvidenceTailSignalTrigger
|
|
if hasRollingReady {
|
|
sig = EvidenceTailSignalThreshold
|
|
}
|
|
return epoch, sig, nil
|
|
}
|
|
|
|
epoch, err := t.nextObservationEpochWithApps(channel, req.Mode(), false, "sub_threshold_accumulating", apps)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, err
|
|
}
|
|
return epoch, EvidenceTailSignalNone, nil
|
|
}
|
|
|
|
func (cs *channelState) firstPendingSequence() int {
|
|
if len(cs.pendingEntries) == 0 {
|
|
return cs.nextSequence
|
|
}
|
|
return cs.pendingEntries[0].sequence
|
|
}
|
|
|
|
func (t *EvidenceTail) CompleteFragment(
|
|
channel, toolCallID string,
|
|
) (EvidenceEpoch, EvidenceTailSignal, bool, error) {
|
|
if channel == "" {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, errors.New("streamgate: complete fragment channel is required")
|
|
}
|
|
if toolCallID == "" {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, errors.New("streamgate: complete fragment tool call id is required")
|
|
}
|
|
|
|
if !t.plan.HasModeForChannel(channel, FilterHoldModeFragmentGate) {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, fmt.Errorf("streamgate: channel %s is not fragment_gate mode", channel)
|
|
}
|
|
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, nil
|
|
}
|
|
|
|
fragment, exists := cs.fragmentState[toolCallID]
|
|
if !exists || fragment.completed {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, false, nil
|
|
}
|
|
|
|
fragment.completed = true
|
|
|
|
safeLen := cs.fragmentSafePrefixLength()
|
|
if safeLen == 0 {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, true, nil
|
|
}
|
|
|
|
t.markChannelEpochsInvalidated(channel)
|
|
delete(t.preparedByChannel, channel)
|
|
t.dropChannelReplacements(channel)
|
|
|
|
firstSeq := cs.firstPendingSequence()
|
|
safeEndSeq := firstSeq + safeLen
|
|
apps, err := t.computeApplicabilities(channel, nil, false, true, false, toolCallID, false)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, true, err
|
|
}
|
|
epoch, _, err := t.nextReadyEpochRecordWithApps(
|
|
channel, FilterHoldModeFragmentGate, true, "fragment_completion_safe_prefix",
|
|
firstSeq, safeEndSeq, apps,
|
|
)
|
|
if err != nil {
|
|
return EvidenceEpoch{}, EvidenceTailSignalNone, true, err
|
|
}
|
|
return epoch, EvidenceTailSignalTrigger, true, nil
|
|
}
|
|
|
|
// isFragmentCompleted returns true when the fragment for the given ID is completed.
|
|
func (cs *channelState) isFragmentCompleted(toolCallID string) bool {
|
|
fragment, exists := cs.fragmentState[toolCallID]
|
|
return exists && fragment.completed
|
|
}
|
|
|
|
// fragmentSafePrefixLength returns the index in pendingEntries up to which
|
|
// all fragments are completed and the prefix is contiguous. Returns 0 if no
|
|
// fragments are completed.
|
|
func (cs *channelState) fragmentSafePrefixLength() int {
|
|
if len(cs.pendingEntries) == 0 {
|
|
return 0
|
|
}
|
|
// Find the first incomplete fragment ID.
|
|
firstIncompleteID := ""
|
|
for _, entry := range cs.pendingEntries {
|
|
if entry.kind == EventKindToolCallFragment && !cs.isFragmentCompleted(entry.toolCallID) {
|
|
firstIncompleteID = entry.toolCallID
|
|
break
|
|
}
|
|
}
|
|
if firstIncompleteID == "" {
|
|
// All fragments are completed.
|
|
return len(cs.pendingEntries)
|
|
}
|
|
// Find the index of the first incomplete fragment.
|
|
for i, entry := range cs.pendingEntries {
|
|
if entry.toolCallID == firstIncompleteID {
|
|
return i
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// PreparedRelease is an opaque token representing a prepared release that
|
|
// has not yet been confirmed. It is immutable and cannot be tampered with.
|
|
type PreparedRelease struct {
|
|
token string
|
|
releaseEvents []ReleaseEvent
|
|
}
|
|
|
|
// Token returns the prepared release token string.
|
|
func (pr PreparedRelease) Token() string { return pr.token }
|
|
|
|
// ReleaseEvents returns a defensive copy of the prepared release events.
|
|
func (pr PreparedRelease) ReleaseEvents() []ReleaseEvent {
|
|
if pr.releaseEvents == nil {
|
|
return nil
|
|
}
|
|
out := make([]ReleaseEvent, len(pr.releaseEvents))
|
|
copy(out, pr.releaseEvents)
|
|
return out
|
|
}
|
|
|
|
// ReleaseConfirmation carries the number of events successfully released
|
|
// during a confirm operation.
|
|
type ReleaseConfirmation struct {
|
|
ReleasedEvents int
|
|
}
|
|
|
|
// PrepareRelease creates a prepared release for the given epoch ID.
|
|
// Only epoch records that were created via a ready transition (threshold
|
|
// reached, trigger fired, fragment completion) carry release capability.
|
|
// Observation-only epochs (unsubscribed, overflow, observe-only, sub-threshold)
|
|
// have no record and cannot be prepared.
|
|
//
|
|
// PrepareRelease validates the epoch record exists and is not consumed or
|
|
// invalidated, generates a release snapshot from the epoch's fixed sequence
|
|
// range using unconfirmedSnapshot (which respects partial confirms), and
|
|
// creates an opaque token. It does NOT modify pending/look-behind/cursor
|
|
// state.
|
|
//
|
|
// PrepareRelease rejects: unknown/observation epochs, already consumed/
|
|
// invalidated epochs, overlapping prepared tokens for the same channel,
|
|
// and already-prepared epochs.
|
|
// HasUnconfirmedEvents returns true if the specified epoch has unconfirmed release payload events.
|
|
func (t *EvidenceTail) HasUnconfirmedEvents(epochID uint64) bool {
|
|
if t == nil || t.epochs == nil {
|
|
return false
|
|
}
|
|
record, ok := t.epochs[epochID]
|
|
if !ok || record.consumed || record.invalidated {
|
|
return false
|
|
}
|
|
channelCS := t.channelState[record.channel]
|
|
if channelCS == nil {
|
|
return false
|
|
}
|
|
return len(channelCS.unconfirmedSnapshot(record)) > 0
|
|
}
|
|
|
|
func (t *EvidenceTail) PrepareRelease(epochID uint64) (PreparedRelease, error) {
|
|
// Validate epoch record exists and is not consumed or invalidated.
|
|
record, ok := t.epochs[epochID]
|
|
if !ok {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release unknown or observation-only epoch %d", epochID)
|
|
}
|
|
if record.consumed || record.invalidated {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release epoch %d already consumed or invalidated", epochID)
|
|
}
|
|
// Overlapping prepared token on the same channel is rejected.
|
|
if t.preparedByChannel[record.channel] != "" {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release channel %s already has prepared token %s", record.channel, t.preparedByChannel[record.channel])
|
|
}
|
|
if record.prepared && record.token != "" {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release epoch %d already prepared with token %s", epochID, record.token)
|
|
}
|
|
|
|
channel := record.channel
|
|
|
|
// Get the release snapshot from the epoch's fixed sequence range.
|
|
// This uses monotonic sequence identity so it is safe after partial confirm.
|
|
channelCS := t.channelState[channel]
|
|
if channelCS == nil {
|
|
return PreparedRelease{}, errors.New("streamgate: prepare release no channel state for epoch channel")
|
|
}
|
|
releaseEvents := channelCS.unconfirmedSnapshot(record)
|
|
|
|
if len(releaseEvents) == 0 {
|
|
return PreparedRelease{}, fmt.Errorf("streamgate: prepare release epoch %d has no unconfirmed events", epochID)
|
|
}
|
|
|
|
// Generate an opaque, globally unique token with a monotonic nonce.
|
|
// This prevents stale token reuse when the same epoch is re-prepared
|
|
// after zero or partial confirm (the snapshot content could be identical).
|
|
t.tokenNonce++
|
|
token := fmt.Sprintf("prepared-%d-%d-%d", epochID, len(releaseEvents), t.tokenNonce)
|
|
|
|
// Store snapshot size for downstream ConfirmRelease validation.
|
|
record.snapshotSize = len(releaseEvents)
|
|
record.prepared = true
|
|
record.token = token
|
|
t.preparedByChannel[channel] = token
|
|
|
|
return PreparedRelease{
|
|
token: token,
|
|
releaseEvents: releaseEvents,
|
|
}, nil
|
|
}
|
|
|
|
// ConfirmRelease applies the confirmed number of released events to the
|
|
// committed look-behind and cursor. Only this method modifies pending/
|
|
// look-behind/cursor state. Zero, partial, and full confirmations are
|
|
// supported. Stale tokens and duplicate confirms are rejected.
|
|
//
|
|
// ConfirmRelease validates: token format and existence, that the token
|
|
// matches the epoch's prepared token, that ReleasedEvents does not exceed
|
|
// the prepared count, and that the epoch has not been invalidated.
|
|
//
|
|
// ConfirmRelease validates:
|
|
// - The token matches the epoch record and matches the current prepared
|
|
// token for the epoch's channel.
|
|
// - The epoch is not invalidated.
|
|
// - The pending prefix starting at the epoch's snapStartSeq matches
|
|
// the expected monotonic sequence.
|
|
// - ReleasedEvents does not exceed the prepared count.
|
|
func (t *EvidenceTail) ConfirmRelease(token string, confirmation ReleaseConfirmation) error {
|
|
if token == "" {
|
|
return errors.New("streamgate: confirm release token is required")
|
|
}
|
|
if !strings.HasPrefix(token, "prepared-") {
|
|
return errors.New("streamgate: confirm release invalid token format")
|
|
}
|
|
|
|
// Find the epoch record associated with this token.
|
|
var targetEpoch *epochRecord
|
|
for _, record := range t.epochs {
|
|
if record.token == token {
|
|
targetEpoch = record
|
|
break
|
|
}
|
|
}
|
|
if targetEpoch == nil {
|
|
return errors.New("streamgate: confirm release token not found")
|
|
}
|
|
if targetEpoch.consumed {
|
|
return fmt.Errorf("streamgate: confirm release epoch %d already consumed", targetEpoch.epochID)
|
|
}
|
|
|
|
// Validate ownership: token must match current prepared state and
|
|
// epoch must not be invalidated.
|
|
if err := t.validatePreparedOwnership(targetEpoch, token); err != nil {
|
|
return err
|
|
}
|
|
|
|
channel := targetEpoch.channel
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return errors.New("streamgate: confirm release channel state not found")
|
|
}
|
|
|
|
n := confirmation.ReleasedEvents
|
|
if n < 0 {
|
|
return errors.New("streamgate: confirm release released events must be non-negative")
|
|
}
|
|
if n > targetEpoch.snapshotSize {
|
|
return fmt.Errorf("streamgate: confirm release %d exceeds prepared snapshot %d", n, targetEpoch.snapshotSize)
|
|
}
|
|
|
|
// Validate that the pending prefix matches the epoch's sequence range.
|
|
// This guards against a prepared token being confirmed after the pending
|
|
// prefix has diverged from the original snapshot (e.g., post-append
|
|
// contamination).
|
|
if !cs.pendingPrefixMatchesRange(n, targetEpoch.snapStartSeq) {
|
|
return errPreparedSnapshotMismatch
|
|
}
|
|
|
|
// Move confirmed events to look-behind.
|
|
confirmed := make([]NormalizedEvent, n)
|
|
for i := 0; i < n; i++ {
|
|
confirmed[i] = cloneNormalizedEvent(cs.pendingEntries[i].event)
|
|
}
|
|
cs.committedLookBehind = append(cs.committedLookBehind, confirmed...)
|
|
|
|
// Remove confirmed from pending.
|
|
cs.pendingEntries = cs.pendingEntries[n:]
|
|
|
|
// Adjust rune count.
|
|
removedRunes := 0
|
|
for i := 0; i < n; i++ {
|
|
removedRunes += runeCountForEvent(confirmed[i])
|
|
}
|
|
cs.pendingRunes -= removedRunes
|
|
|
|
// Update committed cursor.
|
|
cs.committedCursor += n
|
|
|
|
// Remove confirmed fragment states (if all their entries are confirmed).
|
|
// Use the absolute confirmed range so that non-zero sequence fragments
|
|
// clean up correctly: only entries already covered by this confirm are
|
|
// removed, and their absolute sequences are preserved for subsequent
|
|
// partial/full confirms.
|
|
cs.dropFragmentEntriesBelow(targetEpoch.snapStartSeq + n)
|
|
|
|
// Trim look-behind to bounded size using the channel's effective evidence
|
|
// window (evidence_runes for rolling, maxBufferRunes otherwise).
|
|
cs.committedLookBehind = trimLookBehind(cs.committedLookBehind, cs.effectiveEvidenceRunes)
|
|
|
|
// Mark consumed when the full snapshot has been confirmed.
|
|
if n >= targetEpoch.snapshotSize {
|
|
targetEpoch.consumed = true
|
|
}
|
|
// Advance the epoch's unconfirmed start so that a subsequent re-prepare
|
|
// or partial/full confirm sees only the remaining suffix of the same epoch.
|
|
targetEpoch.snapStartSeq += n
|
|
targetEpoch.confirmedCount += n
|
|
// Clear prepared token regardless of completion.
|
|
targetEpoch.prepared = false
|
|
targetEpoch.token = ""
|
|
delete(t.preparedByChannel, channel)
|
|
|
|
return nil
|
|
}
|
|
|
|
// preparedReplacement is an opaque token representing a prepared replacement
|
|
// that has not yet been confirmed. It is immutable and cannot be tampered
|
|
// with, and it is only meaningful for the epoch it was prepared against.
|
|
type preparedReplacement struct {
|
|
token string
|
|
releaseEvents []ReleaseEvent
|
|
}
|
|
|
|
// Token returns the prepared replacement token string.
|
|
func (pr preparedReplacement) Token() string { return pr.token }
|
|
|
|
// ReleaseEvents returns a defensive copy of the replacement release events.
|
|
func (pr preparedReplacement) ReleaseEvents() []ReleaseEvent {
|
|
if pr.releaseEvents == nil {
|
|
return nil
|
|
}
|
|
out := make([]ReleaseEvent, len(pr.releaseEvents))
|
|
copy(out, pr.releaseEvents)
|
|
return out
|
|
}
|
|
|
|
// prepareReplacement validates a replacement payload against the epoch it
|
|
// substitutes and reserves the epoch's channel, without mutating pending,
|
|
// look-behind, or cursor state. Every failure mode is checked here so a caller
|
|
// can hand the payload to the sink knowing the tail is still untouched:
|
|
// the epoch must exist as a releasable record that is neither consumed nor
|
|
// invalidated, its channel must not already carry an outstanding prepared
|
|
// token, the captured sequence range must still match the pending prefix, and
|
|
// the payload must be non-empty with every event releasable and on the epoch's
|
|
// own channel.
|
|
func (t *EvidenceTail) prepareReplacement(epochID uint64, events []NormalizedEvent) (preparedReplacement, error) {
|
|
if len(events) == 0 {
|
|
return preparedReplacement{}, errors.New("streamgate: prepare replacement requires at least one event")
|
|
}
|
|
record, ok := t.epochs[epochID]
|
|
if !ok {
|
|
return preparedReplacement{}, fmt.Errorf("streamgate: prepare replacement unknown or observation-only epoch %d", epochID)
|
|
}
|
|
if record.consumed || record.invalidated {
|
|
return preparedReplacement{}, fmt.Errorf("streamgate: prepare replacement epoch %d already consumed or invalidated", epochID)
|
|
}
|
|
if t.preparedByChannel[record.channel] != "" {
|
|
return preparedReplacement{}, fmt.Errorf("streamgate: prepare replacement channel %s already has prepared token %s", record.channel, t.preparedByChannel[record.channel])
|
|
}
|
|
if record.snapEndSeq < record.snapStartSeq {
|
|
return preparedReplacement{}, fmt.Errorf("streamgate: prepare replacement epoch %d has an inverted sequence range", epochID)
|
|
}
|
|
|
|
channel := record.channel
|
|
cs := t.channelState[channel]
|
|
if cs == nil {
|
|
return preparedReplacement{}, errors.New("streamgate: prepare replacement no channel state for epoch channel")
|
|
}
|
|
expectedCount := record.snapEndSeq - record.snapStartSeq
|
|
targetCount := cs.rangeEntryCount(record.snapStartSeq, record.snapEndSeq)
|
|
if targetCount != expectedCount {
|
|
return preparedReplacement{}, errPreparedSnapshotMismatch
|
|
}
|
|
if expectedCount > 0 && !cs.pendingPrefixMatchesRange(expectedCount, record.snapStartSeq) {
|
|
return preparedReplacement{}, errPreparedSnapshotMismatch
|
|
}
|
|
|
|
releaseEvents := make([]ReleaseEvent, len(events))
|
|
payload := make([]NormalizedEvent, len(events))
|
|
for i, ev := range events {
|
|
if err := ev.Validate(); err != nil {
|
|
return preparedReplacement{}, fmt.Errorf("streamgate: prepare replacement event %d: %v", i, err)
|
|
}
|
|
if ev.Channel() != channel {
|
|
return preparedReplacement{}, fmt.Errorf("streamgate: prepare replacement event %d channel %s does not match epoch channel %s", i, ev.Channel(), channel)
|
|
}
|
|
relEv, err := normalizedToReleaseEvent(ev)
|
|
if err != nil {
|
|
return preparedReplacement{}, err
|
|
}
|
|
releaseEvents[i] = relEv
|
|
payload[i] = cloneNormalizedEvent(ev)
|
|
}
|
|
|
|
// The nonce keeps the token globally unique so a stale replacement token
|
|
// can never confirm a later replacement of the same epoch. The distinct
|
|
// prefix keeps replacement tokens out of ConfirmRelease.
|
|
t.tokenNonce++
|
|
token := fmt.Sprintf("replacement-%d-%d-%d", epochID, len(releaseEvents), t.tokenNonce)
|
|
t.replacements[token] = &replacementRecord{
|
|
token: token,
|
|
epochID: epochID,
|
|
channel: channel,
|
|
snapStartSeq: record.snapStartSeq,
|
|
snapEndSeq: record.snapEndSeq,
|
|
events: payload,
|
|
}
|
|
t.preparedByChannel[channel] = token
|
|
|
|
return preparedReplacement{
|
|
token: token,
|
|
releaseEvents: releaseEvents,
|
|
}, nil
|
|
}
|
|
|
|
// confirmReplacement settles a prepared replacement against the sink progress
|
|
// the boundary actually reported. The whole target range is consumed as
|
|
// replaced regardless of progress - the original events were superseded and can
|
|
// never reach the sink - but only the replacement prefix the sink accepted
|
|
// becomes committed look-behind and advances the cursor, so zero, partial, and
|
|
// full progress all leave the committed view equal to downstream reality.
|
|
//
|
|
// Only the target epoch's own range is touched: post-snapshot entries on the
|
|
// same channel stay pending, unrelated channels keep their pending entries,
|
|
// prepared tokens, and epoch records, and only same-channel epochs whose range
|
|
// overlaps the consumed original range are invalidated.
|
|
func (t *EvidenceTail) confirmReplacement(token string, confirmation ReleaseConfirmation) error {
|
|
if token == "" {
|
|
return errors.New("streamgate: confirm replacement token is required")
|
|
}
|
|
if !strings.HasPrefix(token, "replacement-") {
|
|
return errors.New("streamgate: confirm replacement invalid token format")
|
|
}
|
|
replacement, ok := t.replacements[token]
|
|
if !ok {
|
|
return errStalePreparedRelease
|
|
}
|
|
record, ok := t.epochs[replacement.epochID]
|
|
if !ok || record.consumed || record.invalidated {
|
|
delete(t.replacements, token)
|
|
return errStalePreparedRelease
|
|
}
|
|
if t.preparedByChannel[replacement.channel] != token {
|
|
return errStalePreparedRelease
|
|
}
|
|
|
|
cs := t.channelState[replacement.channel]
|
|
if cs == nil {
|
|
return errors.New("streamgate: confirm replacement channel state not found")
|
|
}
|
|
|
|
n := confirmation.ReleasedEvents
|
|
if n < 0 {
|
|
return errors.New("streamgate: confirm replacement released events must be non-negative")
|
|
}
|
|
if n > len(replacement.events) {
|
|
return fmt.Errorf("streamgate: confirm replacement %d exceeds prepared replacement %d", n, len(replacement.events))
|
|
}
|
|
|
|
expectedCount := replacement.snapEndSeq - replacement.snapStartSeq
|
|
targetCount := cs.rangeEntryCount(replacement.snapStartSeq, replacement.snapEndSeq)
|
|
if targetCount != expectedCount {
|
|
return errPreparedSnapshotMismatch
|
|
}
|
|
if expectedCount > 0 && !cs.pendingPrefixMatchesRange(expectedCount, replacement.snapStartSeq) {
|
|
return errPreparedSnapshotMismatch
|
|
}
|
|
|
|
// Consume the superseded original range without any downstream write.
|
|
if targetCount > 0 {
|
|
removedRunes := 0
|
|
for i := 0; i < targetCount; i++ {
|
|
removedRunes += cs.pendingEntries[i].runes
|
|
}
|
|
cs.pendingEntries = cs.pendingEntries[targetCount:]
|
|
cs.pendingRunes -= removedRunes
|
|
cs.dropFragmentEntriesBelow(replacement.snapEndSeq)
|
|
}
|
|
|
|
// Only the accepted replacement prefix becomes committed evidence.
|
|
if n > 0 {
|
|
released := make([]NormalizedEvent, n)
|
|
for i := 0; i < n; i++ {
|
|
released[i] = cloneNormalizedEvent(replacement.events[i])
|
|
}
|
|
cs.committedLookBehind = append(cs.committedLookBehind, released...)
|
|
cs.committedCursor += n
|
|
cs.committedLookBehind = trimLookBehind(cs.committedLookBehind, cs.effectiveEvidenceRunes)
|
|
}
|
|
|
|
record.consumed = true
|
|
record.prepared = false
|
|
record.token = ""
|
|
record.snapshotSize = 0
|
|
record.snapStartSeq = replacement.snapEndSeq
|
|
t.invalidateOverlappingEpochs(replacement)
|
|
|
|
delete(t.preparedByChannel, replacement.channel)
|
|
delete(t.replacements, token)
|
|
|
|
return nil
|
|
}
|
|
|
|
// invalidateOverlappingEpochs invalidates the still-open epoch records whose
|
|
// captured range on the replaced channel overlaps the consumed original range,
|
|
// so no stale token can confirm content that a replacement already superseded.
|
|
// Records on other channels, already settled records, and same-channel records
|
|
// covering only post-snapshot entries are left untouched.
|
|
func (t *EvidenceTail) invalidateOverlappingEpochs(replacement *replacementRecord) {
|
|
for _, record := range t.epochs {
|
|
if record.epochID == replacement.epochID || record.channel != replacement.channel {
|
|
continue
|
|
}
|
|
if record.consumed || record.invalidated {
|
|
continue
|
|
}
|
|
if record.snapStartSeq >= replacement.snapEndSeq || record.snapEndSeq <= replacement.snapStartSeq {
|
|
continue
|
|
}
|
|
record.invalidated = true
|
|
record.prepared = false
|
|
record.token = ""
|
|
record.snapshotSize = 0
|
|
}
|
|
}
|
|
|
|
// ResetForReplace clears all attempt-local state including pending entries,
|
|
// look-behind, fragment state, prepared tokens, and the token nonce. Used
|
|
// when a new attempt replaces the current one.
|
|
func (t *EvidenceTail) ResetForReplace() {
|
|
t.channelState = make(map[string]*channelState)
|
|
t.epochs = make(map[uint64]*epochRecord)
|
|
t.preparedByChannel = make(map[string]string)
|
|
t.replacements = make(map[string]*replacementRecord)
|
|
t.tokenNonce = 0
|
|
}
|
|
|
|
// PrepareContinuation preserves committed look-behind and cursor while
|
|
// discarding pending entries and prepared tokens. Used when resuming after
|
|
// a recovery continuation.
|
|
func (t *EvidenceTail) PrepareContinuation() {
|
|
for _, cs := range t.channelState {
|
|
cs.pendingEntries = nil
|
|
cs.pendingRunes = 0
|
|
cs.fragmentState = make(map[string]*fragmentState)
|
|
// committedLookBehind, committedCursor are preserved.
|
|
}
|
|
t.markEpochsInvalidated()
|
|
t.preparedByChannel = make(map[string]string)
|
|
t.replacements = make(map[string]*replacementRecord)
|
|
}
|
|
|
|
// DiscardPendingForTerminal discards all pending entries and prepared tokens
|
|
// without releasing anything. Used before terminal error or when the
|
|
// terminal gate overflows. No downstream write occurs.
|
|
func (t *EvidenceTail) DiscardPendingForTerminal() {
|
|
for _, cs := range t.channelState {
|
|
cs.pendingEntries = nil
|
|
cs.pendingRunes = 0
|
|
cs.fragmentState = make(map[string]*fragmentState)
|
|
// committedLookBehind and committedCursor are preserved.
|
|
}
|
|
t.markEpochsInvalidated()
|
|
t.preparedByChannel = make(map[string]string)
|
|
t.replacements = make(map[string]*replacementRecord)
|
|
}
|
|
|
|
// markEpochsInvalidated marks all epoch records as invalidated and clears
|
|
// all token/prepared/snapshot state. This prevents any stale outstanding
|
|
// token from being confirmed after recovery/continuation/terminal discard.
|
|
func (t *EvidenceTail) markEpochsInvalidated() {
|
|
for _, record := range t.epochs {
|
|
record.invalidated = true
|
|
record.prepared = false
|
|
record.token = ""
|
|
record.snapshotSize = 0
|
|
}
|
|
}
|
|
|
|
// dropChannelReplacements removes the prepared replacement records belonging to
|
|
// the given channel. Records on other channels are left untouched.
|
|
func (t *EvidenceTail) dropChannelReplacements(channel string) {
|
|
for token, replacement := range t.replacements {
|
|
if replacement.channel == channel {
|
|
delete(t.replacements, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
// markChannelEpochsInvalidated marks all epoch records belonging to the given
|
|
// channel as invalidated and clears all token/prepared/snapshot state for
|
|
// those records. This prevents stale outstanding tokens on the same channel
|
|
// from being confirmed after fragment completion supersedes an older safe
|
|
// prefix snapshot. It does NOT affect epoch records on other channels.
|
|
func (t *EvidenceTail) markChannelEpochsInvalidated(channel string) {
|
|
for _, record := range t.epochs {
|
|
if record.channel == channel {
|
|
record.invalidated = true
|
|
record.prepared = false
|
|
record.token = ""
|
|
record.snapshotSize = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateEventUTF8 checks that text/reasoning/tool argument content in the
|
|
// event is valid UTF-8. Invalid UTF-8 is rejected with a stable error.
|
|
func validateEventUTF8(event NormalizedEvent) error {
|
|
var texts []string
|
|
switch event.Kind() {
|
|
case EventKindTextDelta:
|
|
text, err := event.AsTextDelta()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
texts = append(texts, text)
|
|
case EventKindReasoningDelta:
|
|
text, err := event.AsReasoningDelta()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
texts = append(texts, text)
|
|
case EventKindToolCallFragment:
|
|
tc, err := event.AsToolCallFragment()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
texts = append(texts, tc.Arguments)
|
|
}
|
|
for _, text := range texts {
|
|
if text != "" && !utf8.ValidString(text) {
|
|
return fmt.Errorf("streamgate: event %s contains invalid UTF-8", event.Kind())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runeCountForEvent returns the Unicode rune count for an event's text
|
|
// content. It operates on valid UTF-8 strings only.
|
|
func runeCountForEvent(event NormalizedEvent) int {
|
|
var total int
|
|
switch event.Kind() {
|
|
case EventKindTextDelta:
|
|
text, _ := event.AsTextDelta()
|
|
total += utf8.RuneCountInString(text)
|
|
case EventKindReasoningDelta:
|
|
text, _ := event.AsReasoningDelta()
|
|
total += utf8.RuneCountInString(text)
|
|
case EventKindToolCallFragment:
|
|
tc, _ := event.AsToolCallFragment()
|
|
total += utf8.RuneCountInString(tc.Arguments)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// normalizedToReleaseEvent converts a NormalizedEvent to a ReleaseEvent.
|
|
// Only releasable kinds (text_delta, reasoning_delta, tool_call_fragment)
|
|
// are supported.
|
|
func normalizedToReleaseEvent(ev NormalizedEvent) (ReleaseEvent, error) {
|
|
switch ev.Kind() {
|
|
case EventKindTextDelta:
|
|
text, err := ev.AsTextDelta()
|
|
if err != nil {
|
|
return ReleaseEvent{}, err
|
|
}
|
|
return NewReleaseTextDeltaEvent(ev.Channel(), text, ev.Timestamp())
|
|
case EventKindReasoningDelta:
|
|
text, err := ev.AsReasoningDelta()
|
|
if err != nil {
|
|
return ReleaseEvent{}, err
|
|
}
|
|
return NewReleaseReasoningDeltaEvent(ev.Channel(), text, ev.Timestamp())
|
|
case EventKindToolCallFragment:
|
|
tc, err := ev.AsToolCallFragment()
|
|
if err != nil {
|
|
return ReleaseEvent{}, err
|
|
}
|
|
return NewReleaseToolCallFragmentEvent(ev.Channel(), tc.ID, tc.Name, tc.Arguments, ev.Timestamp())
|
|
default:
|
|
return ReleaseEvent{}, fmt.Errorf("streamgate: cannot release event kind %s", ev.Kind())
|
|
}
|
|
}
|
|
|
|
// CommittedCursor returns the total number of events confirmed released
|
|
// downstream for the given channel. Returns 0 if the channel has no state.
|
|
func (t *EvidenceTail) CommittedCursor(channel string) int {
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return cs.committedCursor
|
|
}
|
|
|
|
// EffectiveLookBehind returns a defensive deep copy of the bounded look-behind
|
|
// events for the given channel. Returns nil if the channel has no state.
|
|
func (t *EvidenceTail) EffectiveLookBehind(channel string) []NormalizedEvent {
|
|
cs, ok := t.channelState[channel]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if len(cs.committedLookBehind) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]NormalizedEvent, len(cs.committedLookBehind))
|
|
for i, ev := range cs.committedLookBehind {
|
|
out[i] = cloneNormalizedEvent(ev)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sliceEventAtRuneBoundary returns a new event with content sliced to at most
|
|
// maxRunes runes from the trailing edge. Returns an empty NormalizedEvent if
|
|
// the kind cannot be sliced (terminal, response_start, etc.).
|
|
func sliceEventAtRuneBoundary(ev NormalizedEvent, maxRunes int) (NormalizedEvent, error) {
|
|
var text string
|
|
switch ev.Kind() {
|
|
case EventKindTextDelta:
|
|
text, _ = ev.AsTextDelta()
|
|
case EventKindReasoningDelta:
|
|
text, _ = ev.AsReasoningDelta()
|
|
case EventKindToolCallFragment:
|
|
tc, _ := ev.AsToolCallFragment()
|
|
text = tc.Arguments
|
|
default:
|
|
return NormalizedEvent{}, nil
|
|
}
|
|
|
|
runes := []rune(text)
|
|
if len(runes) <= maxRunes {
|
|
return ev, nil
|
|
}
|
|
sliced := string(runes[len(runes)-maxRunes:])
|
|
|
|
switch ev.Kind() {
|
|
case EventKindTextDelta:
|
|
return NewTextDeltaEvent(ev.Channel(), sliced, ev.Timestamp())
|
|
case EventKindReasoningDelta:
|
|
return NewReasoningDeltaEvent(ev.Channel(), sliced, ev.Timestamp())
|
|
case EventKindToolCallFragment:
|
|
tc, _ := ev.AsToolCallFragment()
|
|
return NewToolCallFragmentEvent(ev.Channel(), tc.ID, tc.Name, sliced, ev.Timestamp())
|
|
}
|
|
return NormalizedEvent{}, nil
|
|
}
|
|
|
|
// trimLookBehind trims the look-behind to at most maxRunes worth of events,
|
|
// keeping the most recent events. Single oversized events are sliced at
|
|
// Unicode rune boundaries to preserve partial content as a defensive snapshot.
|
|
func trimLookBehind(events []NormalizedEvent, maxRunes int) []NormalizedEvent {
|
|
if len(events) == 0 || maxRunes <= 0 {
|
|
return events
|
|
}
|
|
|
|
// Calculate rune counts for each event.
|
|
runeCounts := make([]int, len(events))
|
|
totalRunes := 0
|
|
for i, ev := range events {
|
|
runeCounts[i] = runeCountForEvent(ev)
|
|
totalRunes += runeCounts[i]
|
|
}
|
|
|
|
if totalRunes <= maxRunes {
|
|
return events
|
|
}
|
|
|
|
// Keep from the end until we're within the limit.
|
|
var result []NormalizedEvent
|
|
remainingRunes := maxRunes
|
|
for i := len(events) - 1; i >= 0; i-- {
|
|
if remainingRunes >= runeCounts[i] {
|
|
result = append([]NormalizedEvent{events[i]}, result...)
|
|
remainingRunes -= runeCounts[i]
|
|
} else {
|
|
// Slice this event at rune boundary to preserve partial content.
|
|
sliced, err := sliceEventAtRuneBoundary(events[i], remainingRunes)
|
|
if err == nil && sliced.Kind() != "" {
|
|
result = append([]NormalizedEvent{sliced}, result...)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|