IBEX Harness
DocsBenchmarksBlogChangelogRoadmap
GitHub
IBEX Harness

Documentation

Architecture Decision RecordsADR-0002: Repository foundation bootstrapADR-0003: Branch protection and merge policyADR-0004: Protobuf and code generation policyADR-0005: Postgres migration strategyADR-0006: Auth protobuf contract (`ibex.auth.v1`)ADR-0007: Auth token validation implementationADR-0008: Security scanning and CI quality gatesADR-0009: Permission bitmap layoutADR-0010: Cryptography policyADR-0011: Proxy auth gRPC client and middlewareADR-0012: Proxy request normalization (OpenAI chat)ADR-0013: Proxy input validation and stable error envelopeADR-0014: Core domain migration sequencingADR-0015: Proxy rate limit skeleton (Phase 1)ADR-0016: Proxy agent identity verification (Phase 1)ADR-0017: Request ID and trace context strategy (Phase 1)ADR-0018: Graceful shutdown contract (Phase 1)ADR-0019: OpenTelemetry provider configuration (Phase 1)ADR-0020: Shared package boundaries — `packages/config` and `packages/apierror`ADR-0021: Prometheus Metric Catalog (Phase 1)ADR-0022: Health check contract (Phase 1)ADR-0023: Docs site architecture (Phase 1.5)ADR-0024: Benchmark data publishing modelADR-0025: LLM provider abstractionADR-0026: OpenAI client designADR-0027: Streaming dual-write strategyADR-0028: Auth cache designADR-0029: Token revocation propagation via Redis pub/subADR-0030: Directive versioning strategyADR-0031: System prompt injection strategyADR-0032: Session data model and retentionADR-0033: ClickHouse llm_traces schema and retentionADR-0034: Proxy overhead performance measurement methodologyADR-0035: Chat Idempotency-Key Redis dedupeADR-0038: Context assembly service design and gRPC contractADR-0039: Proxy Postgres ownership for session and directive storesADR-0040: Anthropic provider adapterADR-0041: Model capability registryADR-0042: Self-hosted OpenAI-compatible LLM adapterADR-0043: Tokenizer registry architectureADR-0044: Non-streaming response pipelineADR-0045: Streaming response transformationADR-0046: Embedder interface and profile registryADR-0047: Memory temporal validity foundationADR-0048: Memory multi-label categoriesADR-0049: Memory relationship graph readinessADR-0050: MCP server skeleton (transport, auth, audit)ADR-0051: Local LGTM observability stack (Phase 2.5 exit pull-forward)ADR-0052: Memory schema v2 expand (HNSW, quality columns)ADR-0053: Vector store abstraction and composite scoring v2
ADRs›ADR-0027: Streaming dual-write strategy
ADRs

ADR-0027: Streaming dual-write strategy

Architecture decision record 0027 — SSE verbatim forward, TeeReader dual-write, accumulation limits, and disconnect semantics for OpenAI streaming.

ADR-0027: Streaming dual-write strategy

  • Status: Accepted
  • Date: 2026-07-22
  • Authors: IBEX Harness team
  • Milestone: 2.1.3 OpenAI streaming forwarder

Context

Production AI clients expect Server-Sent Events (SSE) token streaming. The proxy must:

  1. Forward upstream bytes to the caller with low time-to-first-byte (flush after each SSE event).
  2. Accumulate content and usage for later session checkpoints and traces (milestones 2.4 / 2.5).

These two writes must not fight each other: forward failure loses the client stream; accumulation failure is a degraded telemetry state only.

ADR-0025 already returns streaming bodies as io.ReadCloser. ADR-0026 deferred stream=true until this decision.

Decision

1) Single-path dual-write (io.TeeReader), not goroutine fan-out

The proxy reads the upstream body once. Bytes are copied to:

  • Primary: http.ResponseWriter (client forward), flushed at each SSE event boundary (\n\n).
  • Secondary: openai.StreamAccumulator (io.Writer) for content/usage extraction.

io.TeeReader keeps ordering trivial and avoids a channel between goroutines. A fan-out design would add scheduling races and make backpressure harder to reason about.

2) Verbatim SSE — no re-frame

IBEX does not rewrite SSE framing, inject events, or transform provider-specific extensions (tool-call deltas, content filters). Clients parse OpenAI’s stream directly. Any transformation risks incompatibility.

3) Accumulation is best-effort

Parse errors in the accumulator must never fail Write or block the forward path. Incomplete accumulation is best-effort: JSON parse failures and soft-cap drops are silent in the accumulator (forward still receives full upstream bytes). The proxy logs a warning when the stream ends incomplete (Complete() == false). Prefer a missing or truncated trace over delayed TTFB.

Soft content cap: 1 MiB of accumulated completion text, truncated on a UTF-8 rune boundary. Beyond the cap, further content is dropped from the accumulator (forward still receives full upstream bytes).

4) Termination and completeness

  • Stream ends with data: [DONE]\n\n. Seeing this sentinel marks the accumulator complete.
  • Upstream EOF without [DONE], or mid-stream transport error after headers: Complete() == false for later emitters (is_complete=false).
  • After response headers and body have started toward the client, the proxy must not convert failures into a JSON error envelope (see also milestone 2.1.5).

5) No retry after stream start

The OpenAI client may retry transient failures before returning a successful streaming 200 body. Once a live SSE body is returned to the proxy, retries are forbidden — the client may already have received partial tokens.

6) Client disconnect

Request context cancellation (client gone) stops reading from upstream, closes the provider body, and increments ibex_proxy_stream_client_disconnects_total. No background goroutine may outlive the request without an explicit wait.

7) Response headers (proxy)

Before reading the body:

HeaderValue
Content-Typetext/event-stream
Cache-Controlno-cache
X-Accel-Bufferingno

http.Flusher is required. If unavailable, return 500 before any stream starts.

8) Layering

LayerResponsibility
packages/provider/openaiUpstream SSE request; content-type check; StreamAccumulator
services/proxy/internal/httpFlusher, client headers, TeeReader copy loop, disconnect metrics
packages/metricsStream duration and disconnect/backpressure counters

9) Metrics

MetricLabels
ibex_proxy_stream_duration_secondsprovider, status (ok, incomplete, client_disconnect, error)
ibex_proxy_stream_client_disconnects_total—
ibex_proxy_stream_upstream_disconnects_total—
ibex_proxy_stream_backpressure_events_total—

Backpressure: increment when a client write/flush for an event takes longer than 50ms (bounded signal; not per-token).

Consequences

Positive:

  • Real-time SSE compatible with OpenAI clients
  • Clean hook for 2.4/2.5 without blocking the hot path
  • Clear no-retry / no-JSON-after-headers contract for 2.1.5

Negative:

  • Accumulator may truncate under the soft cap
  • Mid-stream errors leave clients with a partial SSE stream (by design)

Follow-ups:

  • Milestone 2.1.5: centralized MapProviderError for pre-stream failures only (mid-stream remains incomplete SSE; no injected error events)
  • Milestones 2.4.3 / 2.5.3: consume accumulator after [DONE] for checkpoints and traces

References

  • ADR-0025
  • ADR-0026
  • Milestone 2.1.3

Was this page helpful?

Edit on GitHub

Last updated on

PreviousADR-0026: OpenAI client designNextADR-0028: Auth cache design

On this page

  • Context
  • Decision
  • 1) Single-path dual-write (io.TeeReader), not goroutine fan-out
  • 2) Verbatim SSE — no re-frame
  • 3) Accumulation is best-effort
  • 4) Termination and completeness
  • 5) No retry after stream start
  • 6) Client disconnect
  • 7) Response headers (proxy)
  • 8) Layering
  • 9) Metrics
  • Consequences
  • References
0%