From 4f0cc49ab2898f53408228ad52bdcf3d828186dd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 04:47:13 -0700 Subject: [PATCH 01/13] =?UTF-8?q?SPEC=20=C2=A723:=20the=20Event=20Feed=20C?= =?UTF-8?q?onnector=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blessed connector for BC3's account-wide event feed (BC3 #9646/#9659), specified ahead of the generated layer: the 11-state/26-transition machine with reason-level disconnect dispatch and a four-row matrix; present-class entry sequencing against a defined ownership cut, with the conjunctive save-ordering invariant and its first-usable-checkpoint exclusion; the semantic-signal handler contract (BufferOverflow/FeedGap as distinct types, Accept|Terminate dispositions, no handler means a typed terminal, a registered handler is invoked exactly once per signal); delivered-id dedupe; five seam contracts with seam-call-count semantics; continuation and 410 resume URLs validated with §8's same-origin algorithm before any authenticated follow (terminal invalid_continuation on failure); one pinned poll-retry algorithm (Retry-After exact and cap-exempt per §7, else full-jitter on a separate consecutive-poll-failure index); six timer kinds and the normative virtual-advance algorithm; srv1 checkpoint identity with the published vectors; per-language option naming; security invariants. §22 narrows its WebSocket and synchronous-first bullets to carve out this one sanctioned lane. Appendix A gains the EVENT_FEED_* block with a preamble amendment sanctioning contract-first rows (and correcting the stale 13-row tally to 14); Appendix F records the per-SDK scenario-lane divergences. No §19/§21/Appendix-D rows for the parallel fixture families, matching the oauth-family precedent. Everything bc3-derived is verified at bc3 branch head 8be5c67de5 (lineage ee19670c02) and classified in the Provenance block — wire literals frozen on regenerated transcripts, semantic behavior re-verified row-by-row at bc3's merge-time gate — with the SDK-owned contract ungated. The wire operations stay tracked in spec/api-gaps/event-feed.md until the BC3 contract merges. --- SPEC.md | 837 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 834 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index ce5a777348..a1904f6cb6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2041,23 +2041,823 @@ Regenerate-and-diff freshness gates now exist for all six SDKs' generated output The following are explicitly NOT part of this specification: -- GraphQL, WebSocket, or SSE transport +- GraphQL or SSE transport. WebSocket as a general API transport remains out of scope; the §23 Event Feed connector’s Action Cable lane is in scope - CLI UI or interactive prompts - Circuit breaker, bulkhead, or client-side rate limiter (rubric T2D criteria exist but are optional extras, not core contracts) - Prometheus or OpenTelemetry hook implementations (the hook protocol is in scope; specific integrations are not) - Package publishing or release automation -- Language-specific async/concurrency model (spec is synchronous-first; async is a language adaptation) +- Language-specific async/concurrency model (spec is synchronous-first; async is a language adaptation). The §23 Event Feed connector is the carve-out: its surface is inherently asynchronous — a long-lived, serially back-pressured event stream — and each language realizes it with its native streaming idiom (§23 "Consumer Surface"); the concurrency primitive is the adaptation, the behavioral contract is not - Smithy model authoring - File upload multipart encoding details - Webhook receiver HTTP server implementation (the verification algorithm is in scope; how to run an HTTP server is not) --- +## §23. Event Feed Connector + +### What This Is + +BC3 exposes an account-wide **event feed** — a resumable notification feed, not an audit +log — over two lanes. The **push lane** is a native Action Cable WebSocket (`EventsChannel`), +authenticated by short-lived stream tickets minted at `POST /events/stream_ticket.json`; +its payloads are wake-up signals that may be delayed, duplicated, or dropped. The **poll +lane** is `GET /events.json`: stateless, client-held cursor, strict event-id order, behind a +best-effort ~30-second safety horizon. The poll lane repairs what push missed **for ids above +the held position whose transactions commit within the horizon** — the bound is +position-relative, never wall-clock, and it is best-effort rather than guaranteed delivery. +Completeness-critical workflows corroborate against canonical resource APIs. + +The SDK ships **one blessed connector** in every language so that six integrations never +become six divergent reinventions of a subtle stateful protocol: mint → dial the mint's URL +verbatim → subscribe → await confirmation → catch-up poll → drain the live buffer → stream, +with the reconnect, backoff, staleness, dedupe, and checkpoint discipline specified below. +Connecting the live lane **before** entering the poll lane is load-bearing, not an +optimization: entry at the present places every already-visible event — and any in-flight +event that drew a lower id and commits after entry — permanently behind the entry position, +so the live buffer is the only carrier of an in-flight-at-entry straggler (see Entry +Boundary below). + +The wire operations beneath the connector — `PollEvents` and `CreateStreamTicket` — are +ordinary generated operations, tracked in `spec/api-gaps/event-feed.md` until the BC3 +contract merges. The connector performs **no wire I/O of its own** except dialing the mint's +URL verbatim through the transport seam; every HTTP exchange reaches the wire through a seam +backed by a generated operation. That is what lets this section fix the connector contract +ahead of the generated layer landing. + +### Provenance `[manual]` + +Everything bc3-derived in this section is verified at bc3 `8be5c67de5` (pre-merge; lineage +`ee19670c02`); re-verified at bc3's merge-time gate. Until that gate clears, the +bc3-derived material is **PROVISIONAL** — normative for SDK drafting, frozen only at the +gate — and it comes in two classes with different re-verification mechanics: + +**Class 1 — wire literals**, frozen when bc3 regenerates transcripts against the rebased +head: + +- disconnect reason strings `unauthorized`, `remote`, `invalid_event_stream_command`; +- the poll body envelope keys `events` / `position` / `next`; +- the 409 body's digest keys `position_digest` / `filters_digest`; +- the 410 body's keys `epoch_after_id` / `resume`; +- the 400-position and 400-filter error-body shapes (the discriminating matrix, as + captured in the transcripts); +- the published srv1 digest vectors (Checkpoint Identity below); +- the mint response body `{ticket, expires_in, url}`; +- the subscribe identifier literals — channel `EventsChannel`, filter param spellings + `types` / `buckets` / `creators`, comma-joined values. + +One literal in that list carries a different verification basis: `remote` has **no +transcript capture** (none exists in the provisional delivery, and the connection-outcome +scenarios don't produce it) — it is source-verified against the pinned Rails and the +branch. Its freeze at the merge-time gate rides bc3's re-verification of the disconnect +matrix (plus the single requested capture frame), not transcript regeneration. + +**Class 2 — semantic behavior**, equally provisional; transcript diffs cannot prove these, +so each is re-verified at the gate as its own row (against the rebased source and docs): + +- present-entry semantics — `since=now` and the bare entry are equivalent, mint the cursor + at the newest visible event id, and permanently exclude an in-flight lower id that + commits after entry (the Entry Boundary section's premise); +- the best-effort, position-relative ~30-second safety-horizon bound; +- the frozen-head `next` predicate (when a walk continues vs. terminates); +- 409/410 re-entry semantics, including that a 410 `resume` URL re-enters at `since=now` + with the canonical filter set preserved; +- the srv1 canonicalization algorithm itself (not just its vectors); +- ticket statelessness and replayability, and the ~120-second TTL; +- the 3-second server heartbeat cadence (the input to the SDK's staleness policy); +- the 400 split's recovery semantics — a malformed-position 400 is recoverable by `since=` + re-entry, a malformed-filter 400 is not; +- the subscribe retransmit contract — identical resubscribes silently absorbed, different + ones rejected; +- the push payload's shape, including the `visible_to_clients` presence asymmetry (push + rows carry it, poll rows omit it); +- the disconnect matrix's completeness at the verified head, including that `unauthorized` + arrives only pre-welcome. + +**SDK-owned contract** — normative as written, not gated on bc3: the state inventory and +transitions, the ownership cut and the conjunctive save-ordering invariant (they *respond +to* class-2 semantics — the premises re-verify at the gate; the discipline stands), the +semantic-handler contract, the dedupe rule, timer kinds and the virtual-advance algorithm, +the checkpoint-identity structure with origin canonicalization and the `srv1-` namespace +prefix, continuation/resume-URL validation, the staleness detection policy (7500ms), the +options surface, and the security invariants. + +### Classification: Infrastructure, Not a Composite `[manual]` + +The connector is hand-written, spec-normative infrastructure in the same class as §14 +(Download), §15 (Webhooks), and §16 (OAuth Utilities). It is **not** a §18 composite: +composites are stateless multi-call orchestrations over generated operations; this is a +long-lived stateful transport with its own liveness contract. §18's composite rules +(read-overlay-write, `METHOD_NAME_OVERRIDES`, raw-path reachability) do not apply here; +what does carry over is the spirit of §18 rule 1 — no hand-written wire I/O. Every mint and +poll flows through a generated operation behind a seam; the one non-HTTP wire act the +connector owns is `CableTransport.dial(mint.url)`, verbatim, as sanctioned. The connector +never assembles cable topology (scheme, host, path, account prefix) client-side — the +mint's `url` is connected to exactly as returned. + +### Consumer Surface + +The connector is consumed as a serial stream of deduplicated events plus lifecycle +observability. The delivery idiom is language-native (Go `iter.Seq2[Event, error]` +range-over-func is the reference; TypeScript/Python async iterators, Kotlin `Flow`, Swift +`AsyncSequence`, Ruby a blocking enumerator are the expected adaptations), but the contract +is shared: + +- **Serial back-pressure is structural.** The consumer's processing *is* the loop body; the + connector does not advance, monitor, or reconnect while the consumer is processing. Timers + that fire mid-delivery are observed when control returns; a stale socket is then detected + before any further delivery **from the live socket**. (Draining's replay of + already-admitted buffered events is a bounded in-memory completion, not socket delivery — + it finishes first; see the deferred-consumption note under the state machine.) +- **Events only.** The iteration element is the event. Gap, caught-up, and position-rejected + notifications are Observer callbacks; semantic dispositions live exclusively in the signal + handler (below). Neither is ever an in-band union element. +- **Typed terminal errors end iteration** as exactly one final error element carrying a + `TerminalReason`. Cancellation, `close()`, and a consumer break end iteration with **no** + error element — a clean stop; the feed is resumable by design. +- **Single-shot.** A second consumption of the same connector is the `usage` terminal — + the one `usage` condition that surfaces as an iteration error element. Construction does + no I/O (validation only); construction-time validation failures (a filter violation, a + configured store with an empty consumer namespace) surface as **language-native + construction errors** carrying the `usage` code, before any iteration exists — never as + iteration elements, and with zero wire attempts. First I/O happens on first iteration, + which keeps hosts and tests deterministic. +- **`close()`** is idempotent and callable from any context: it abandons, never drains. + Undelivered buffered events are abandoned — the next run re-serves from the last **usable** + checkpoint (see the exclusion under Entry Boundary). +- A consumer break takes the identical teardown path; the in-flight page's checkpoint is + **not** saved. +- All Observer callbacks fire on the consumer's execution context, never concurrently with a + delivery. + +Feed rows are wake-up signals — enough to route, not enough to act. Consumers refetch the +referenced recording through canonical resource APIs before acting; feed payloads are never +current resource state. + +``` +RECORD Event + id : Integer + kind : String + event_type : String + action : String + created_at : String -- ISO 8601 + bucket_id : Integer + creator_id : Integer + recording_id : Integer + visible_to_clients : Boolean? -- presence-bearing: push payloads carry it, poll rows + -- omit it; absent ≠ false. Never a defaulted boolean. +END + +RECORD Filters + types : List? -- cataloged event types only + buckets : List? -- ≤ 100 ids + creators : List? -- ≤ 100 ids +END +``` + +All id fields carry §10's 64-bit integer contract; no new type spelling is introduced for +it. + +Filter validation is client-side and fail-closed `[conformance]`: ids must be positive; type +strings non-empty with no commas, whitespace, or quotes; each id list capped at 100. A +violation is a `usage`-coded construction error (Consumer Surface above) — zero wire +attempts. Positions are filter-bound; changing filters starts a new checkpoint lineage (the +server enforces this with 409). + +### State Machine `[conformance]` + +The connector is **11 states and 26 labeled transitions**, plus one universal edge to +`Closed` from each of the 9 non-absorbing states. This inventory is published as the +cross-language contract; each SDK's state code encodes this table. + +States: `Idle`, `Backoff`, `Minting`, `Connecting`, `AwaitingWelcome`, +`AwaitingConfirmation`, `CatchingUp`, `Draining`, `Streaming`, `Terminal` (absorbing; the +typed error element is emitted), `Closed` (absorbing; no error element). + +| # | From | To | Trigger / action | +|---|------|----|------------------| +| 1 | Idle | Minting | first iteration (initial connect is immediate; no backoff) | +| 2 | Backoff | Minting | `backoff` timer fired (a fresh ticket is ALWAYS minted next) | +| 3 | Minting | Connecting | ticket minted; dial the mint's `url` verbatim | +| 4 | Minting | Backoff | mint transient/throttled, or an unauthorized mint (401/403) below the shared-counter threshold (Retry-After honored as the floor of the next delay) | +| 5 | Minting | Terminal(`authorization_failed`) | 3rd consecutive connection-level authorization failure (shared counter across unauthorized mints and `unauthorized` disconnects; resets on `confirm_subscription`) | +| 6 | Connecting | AwaitingWelcome | dial ok; frame pump started; `handshake-deadline` armed (10s) | +| 7 | Connecting | Backoff | dial failed | +| 8 | AwaitingWelcome | AwaitingConfirmation | `welcome` → send subscribe; re-arm as `confirmation-deadline` (10s) | +| 9 | AwaitingWelcome | Backoff | socket error/close, staleness expiry, or handshake deadline lapsed (full teardown first) | +| 10 | AwaitingWelcome | Terminal(`authorization_failed`) | 3rd consecutive connection-level authorization failure (reason-string dispatch on the literal `unauthorized`; shared counter with unauthorized mints) | +| 11 | AwaitingConfirmation | CatchingUp | `confirm_subscription` → cancel deadline; reset attempt + auth counters; select entry cursor | +| 12 | AwaitingConfirmation | Terminal(`subscription_rejected`) | `reject_subscription` → cancel deadline, **close the socket**, ZERO reconnects | +| 13 | AwaitingConfirmation | Terminal(`protocol_fatal`) | raw disconnect frame `reason=invalid_event_stream_command, reconnect=false` | +| 14 | AwaitingConfirmation | Backoff | confirmation deadline lapsed → dispose conn + pump + ALL of the attempt's timers, then jittered fresh-ticket retry | +| 15 | AwaitingConfirmation | Backoff | socket dropped, staleness expiry, or disconnect with `reconnect ≠ false` (includes `remote`) | +| 16 | CatchingUp | CatchingUp | page fetched → deliver events → save + announce checkpoint → follow `next` | +| 17 | CatchingUp | CatchingUp **or** Terminal(`feed_gap`) | 410 → dispatch `FeedGap` signal (Semantic Signals below): a registered handler returning Accept re-enters via the provided resume URL; Terminate — or no handler — is Terminal(`feed_gap`). `Observer.gap` fires as observability either way. A 410 never silently auto-continues. | +| 18 | CatchingUp | CatchingUp | 400-position → re-enter `since=` (or, with no accepted id, `since="now"` — a present-class entry, Entry Boundary below); `Observer.position_rejected(position_invalid)` | +| 19 | CatchingUp | CatchingUp | 409 → discard the held position → re-enter `since=` (or, with no accepted id, `since="now"` — a present-class entry); `Observer.position_rejected(filter_changed)` | +| 20 | CatchingUp | Terminal(`filter_invalid`) | 400-filter: configuration error; a position reset won't help | +| 21 | CatchingUp | Backoff | socket died mid-walk, or staleness expiry (per-page checkpoints already saved are kept) | +| 22 | CatchingUp | Draining | `next` absent — the walk reached its frozen head → deliver the final page's events → save + announce its checkpoint (position-resume entries) → enter Draining. **Present-class amendment (present-class entries only — Entry Boundary below; position-resume entries unchanged):** the entry poll's returned position is HELD, not saved; the ownership cut — defined under Entry Boundary — is taken after entry into Draining. | +| 23 | Draining | Streaming | buffer replayed through dedupe → `Observer.caught_up` → arm jittered `repair-poll`. **Present-class amendment:** the held entry position is saved only once the conjunctive save-ordering invariant holds; a `BufferOverflow` disposition of Terminate — or no handler — lands in Terminal(`buffer_overflow`) instead, with no Save. (A `FeedGap` cannot arise here: 410s arise from polls, which Draining never performs — a 410 on the entry walk was already dispatched at transition 17.) | +| 24 | Streaming | CatchingUp | `repair-poll` fired → one walk from the durable position (60s ± 20%) | +| 25 | Streaming | Backoff | `staleness` fired (7.5s without a frame) / socket error / disconnect `reconnect ≠ false` and not protocol-fatal (includes `remote`) | +| 26 | Streaming | Terminal(`protocol_fatal`) | raw disconnect `invalid_event_stream_command` | +| — | any non-absorbing | Closed | `close()` / cancellation / consumer break (universal edge) | + +Semantic-signal dispositions are an **overlay** on this table, not additional numbered +rows: a `FeedGap` signal arises at transition 17; a `BufferOverflow` signal arises whenever +the live buffer drops events (any buffer-holding state) and is dispatched synchronously +before the next Save; a Terminate disposition — or an absent handler — lands in the +corresponding Terminal state from wherever the signal was dispatched. Four further edges +sit outside the 26 numbered rows for the same reason: the Terminal(`checkpoint_load`) edge +(fires on the first iteration, before the first mint — zero wire attempts); the +Terminal(`usage`) edge (a re-consumed iterator); the Terminal(`invalid_continuation`) edge +(fires between URL validation and the poll call, wherever a `next` continuation or 410 +`resume` URL is about to be followed — row 16's follow and row 17's Accept re-entry both +pass through it; zero requests to the failing URL); and the poll transient/throttle +**self-loop** inside CatchingUp (the `poll-retry` timer — a wait, not a state change). +The published inventory stays 11/26. + +Interpretation, pinned: + +- **`reject_subscription` is always terminal** — first attempt or reconnect, zero reconnect + attempts, and the connector must explicitly close the still-open socket (Action Cable + leaves a rejected socket open; an unhandled one stays registered server-side, receiving + heartbeats forever while delivering nothing). +- **Connection-level authorization failures retry, then surface — on ONE shared counter.** + Unauthorized mints (401/403) and `unauthorized`-reason disconnects at connect + (pre-welcome — rows 9/10; Disconnect Dispatch pins the arrival point) increment the same + consecutive-failure counter; at `EVENT_FEED_AUTH_FAILURE_THRESHOLD = 3` the connector + surfaces `authorization_failed` (a mixed sequence — mint 401, `unauthorized` disconnect, + mint 401 — is terminal). A single blip must not kill a long-lived agent: a ticket that + expired in the mint→dial race is indistinguishable from revocation on one sample. The + counter resets on `confirm_subscription`, and only these two failure shapes ever + increment it. +- **Poll transients and throttles are never terminal.** They retry inside CatchingUp on the + `poll-retry` timer, with one algorithm: a server-directed `Retry-After` is waited + exactly, exempt from local caps per §7's rule (implementations may still bound it + against host limits, as §7 allows); otherwise the wait is full-jitter + `uniform(0, min(60s, 1s × 2^(k−1)))` over the **consecutive-poll-failure index k** — a + counter separate from the reconnect-cycle count, starting at 1 on the first consecutive + failed poll, incremented per consecutive transient/throttled failure, and reset by any + successful poll page and by socket teardown (after a teardown, the Backoff cycle's own + counter governs; the poll index starts fresh in the next walk). +- **Checkpoint discipline:** only poll-page acceptance ever calls `save` (transitions 16/22–23 + per their amendments; re-entry walks save through 16 like any other pages). Streaming never + saves — live event ids never advance the durable position. `save` failure is + `Observer.checkpoint_save_failed` and the feed continues. `load` happens exactly once, + on the first iteration **before the first mint**; its failure is + Terminal(`checkpoint_load`) with zero wire attempts, because silently starting at the + present would skip history. +- **Reconnect discipline:** one path through Backoff → Minting → Connecting; at most one + in-flight reconnect attempt at any time; the attempt counter increments per failed cycle + and resets on confirmation. A fresh ticket is minted on **every** pass — the connector + never stores a mint URL across attempts. + +Per-state invariants (asserted by the tier-2 harness as exact sets): + +| State | Socket | Allowed timers (exact set) | Delivery? | Save? | +|---|---|---|---|---| +| Idle | none | {} | no | no | +| Backoff | closed | {`backoff`} | no | no | +| Minting | none | {} | no | no | +| Connecting | opening | {} | no | no | +| AwaitingWelcome | open | {`handshake-deadline`, `staleness`} | no | no | +| AwaitingConfirmation | open | {`confirmation-deadline`, `staleness`} | **no** | no | +| CatchingUp | open | {`staleness`} (+ `poll-retry` while a poll wait is pending) | poll pages only | after each page's delivery (held on present-class entry) | +| Draining | open | {`staleness`} | buffered live, deduped | only per the save-ordering invariant | +| Streaming | open | {`staleness`, `repair-poll`} | live (deduped) + repair pages | poll positions only — never live ids | +| Terminal / Closed | closed | **{}** | no | no | + +Draining is a bounded, in-memory replay — it performs no polls and takes no wire waits, so +it has no failure edge of its own: a staleness expiry or socket failure observed while +draining is consumed at the Streaming boundary (transition 23 completes the drain, then +transition 25 handles the failure). This is the one deliberate deferred-consumption case; +everywhere else a socket-open state's failure edge (9/15/21/25) fires directly, and +staleness expiry is among each of those edges' triggers. + +### Disconnect Dispatch `[conformance]` + +Action Cable's `disconnect` is a **text frame**, not a WebSocket close frame, and stock +Action Cable discards the reason before subscription callbacks fire. The transport seam's +verbatim raw frames exist so the connector can read it. Dispatch is on the **reason +string** — never on the `reconnect:false` flag alone, since `unauthorized` and +`invalid_event_stream_command` share `reconnect:false` and demand opposite responses. The +matrix — complete over every reason the server emits at the verified head: + +| Failure | Wire signal | Connector response | +|---|---|---| +| Handshake failure (bad `Origin`, missing ticket) | HTTP upgrade failure — **no frame** | Backoff → fresh mint (transitions 7/9) | +| Invalid/expired ticket at connect | `{"type":"disconnect","reason":"unauthorized","reconnect":false}`, then close | retriable **with a fresh ticket** (9 → Backoff); Terminal(`authorization_failed`) after 3 consecutive on the shared counter (10) | +| Mid-connection revocation / server-initiated disconnect | `{"type":"disconnect","reason":"remote","reconnect":true}` | maps into the existing `reconnect ≠ false` → Backoff transitions (15/25) — no new state, no new transition: re-mint and reconnect. A genuinely revoked user's next mint fails, and that mint-failure path into `authorization_failed` (5) **is** the designed revocation detection — revocation is not wire-distinguishable at disconnect time | +| Protocol violation (malformed/oversized/uncorrelatable command) | `{"type":"disconnect","reason":"invalid_event_stream_command","reconnect":false}` | Terminal(`protocol_fatal`) (13/26) — surface, never retry into it | + +Reason strings are provisional wire literals (Provenance above). The reason-level dispatch +rule itself — and the threshold-3 `authorization_failed` — are SDK-owned and final. + +Two dispatch clarifications, pinned: + +- **`unauthorized` arrives only pre-welcome on the wire** — the server rejects the ticket + at connect, before any `welcome` (the TTL+ε capture shows the disconnect with no + preceding welcome), which is why the inventory routes it through transitions 9/10 and no + other row admits it. An `unauthorized`-reason disconnect observed in any later + socket-open state (wire-impossible at the verified head) is treated as a **socket drop** + — the current state's "socket error/close" failure edge (15/21/25) — with **no** counter + increment. Nothing is lost by that: the reconnect cycle re-mints, and a genuinely + revoked user's mint then fails 401/403 and increments the shared counter — the same + detection path the `remote` row relies on. No out-of-inventory edge exists. +- **An unrecognized reason string is treated as a socket drop** — the current state's + "socket error/close" failure edge (9/15/21/25), whatever its `reconnect` flag says — and + never increments the authorization counter; only the literal `unauthorized` does. + Unknown reasons must not be guessed into either terminal class. + +### Cable Protocol Details `[conformance]` + +- The subscribe command is built once per connection as an exact byte string: + `{"command":"subscribe","identifier":""}`, where the identifier + is the JSON-encoded string of an **ordered** object + `{"channel":"EventsChannel"[,"types":"a,b"][,"buckets":"1,2"][,"creators":"3"]}` — + comma-joined values, fixed key order, absent filters omitted, hand-built rather than + map-marshaled so any retransmit is byte-identical. The server absorbs identical + retransmits and rejects different ones. +- Subscribe is sent on each `welcome` received. Confirm/reject correlation is exact string + equality against the connector's identifier; frames carrying other identifiers are + ignored. +- Ping parsing accepts both `{"type":"ping"}` and `{"type":"ping","message":}`. + Unknown frame types update liveness and are otherwise ignored. +- **Every** inbound frame, of any kind, resets the `staleness` timer + (`EVENT_FEED_STALE_AFTER = 7500ms`: two missed 3-second server heartbeats plus 25% grace — + the server contract leaves detection policy to the SDK; the SDK pins and publishes this + one). +- The transport negotiates subprotocol `actioncable-v1-json`, sends no `Origin` header + (non-browser clients), and passes the mint URL through untouched, query string included. + +### Entry Boundary and Save Ordering `[conformance]` + +`since=now` — and the bare present entry, which the server treats identically — mints the +cursor at the newest **visible** event id. An in-flight transaction that drew a lower id N +before entry and commits after it falls permanently behind that cursor: the poll lane never +serves N, because the repair bound covers ids **above** the position, position-relative, +never wall-clock. The live buffer is therefore the only carrier of an in-flight-at-entry +straggler, which creates a client-side ordering obligation and a durability boundary this +section states honestly rather than papers over. + +**Present-class entries, defined.** The amendment below applies to every entry whose +cursor resolves at the server's present head — the class, used by this name throughout +this section and the state table: the zero Cursor (bare present entry); `since="now"`; a +410 reset's resume URL (the server documents it as `since=now` with the canonical filter +set preserved); and a 400-position/409 re-entry that falls back to the present because no +accepted event id exists. Entries positioned in served history — `position=`, +`since=`, `since=0` — are **position-resume class** and keep the unamended per-page +save discipline. + +**Entry sequencing (present-class entries only):** hold the entry poll's returned +position → take the **ownership cut** → fix the snapshot → drain-and-accept → only then +`save`. + +**The ownership cut, defined:** the cut is the state machine's first empty receive from the +frame pump's queue after accepting the entry-poll response — deliberately **not** a +drain-until-empty barrier, which races a concurrent sender and promises an unclosable +window. "Observed" means **admitted into the state-machine-owned buffer at or before the +cut**; a frame the transport had read but the state machine had not yet received at the cut +does not count. **The snapshot** is the pre-cut contents of the state-machine-owned buffer, +fixed at the cut; "pre-cut events" and "post-snapshot stragglers" below are defined against +it. + +**The published delivery promise is the CONJUNCTIVE save-ordering invariant, and nothing +stronger:** + +> `save(P)` only after ALL retained pre-cut events have been accepted AND every pre-cut +> loss condition has been explicitly accepted. Terminate — or no handler — means no `save`. + +A disjunctive form ("accepted, or its signal handled") would let an accepted overflow +bypass delivery of the other retained events; the conjunction is the invariant. + +**Explicitly excluded: crash or cancellation before the first usable checkpoint.** On a +first present entry there is no older durable cursor, and on a 410 reset the old cursor is +unusable — so an event admitted pre-cut and lost to a crash before delivery and `save` is +unrecoverable, with no signal. Client-side ordering cannot manufacture durability the +server does not offer. No blanket loss-prevention or global delivery-completeness claim is +published anywhere in this section — such a claim would contradict this exclusion. What is +published is the save-ordering invariant above, scoped to the defined observation point. + +**Overflow invalidates completeness before checkpoint.** A dropped lower-ID event behind +the present position is **not** poll-repairable — the repair bound covers ids above the +position only. Overflow during the entry window therefore invalidates completeness, and its +signal must be handled (or be terminal) before any `save`. This supersedes any reading of +overflow as safe-because-the-poll-lane-repairs. + +**Post-snapshot lower-id stragglers** (N arrives on the live lane after the snapshot) are +the server's documented best-effort case: delivered live, deduplicated, never a position +regression. Completeness-critical work corroborates against canonical resource APIs. + +### Semantic Signals `[conformance]` + +Conditions that change what the feed can promise get a **separate synchronous handler with +an explicit disposition** — never a fire-and-forget callback, and never an overloaded +epoch-shaped callback: + +``` +Signal = BufferOverflow | FeedGap -- two DISTINCT types, a closed union + +RECORD BufferOverflow + dropped_ids : List -- exact ids dropped: "dropped" is unambiguous + dropped_count : Integer +END + +RECORD FeedGap + epoch_after_id : Integer + resume_url : String +END + +SignalHandler : (Signal) → Accept | Terminate +``` + +- **No handler registered ⇒ typed terminal:** `buffer_overflow` for an overflow, `feed_gap` + for a 410 gap. An unhandled semantic signal cannot disappear, and **a 410 never silently + auto-continues**. +- **Accept on `FeedGap`** resumes via the provided resume URL (it preserves the canonical + filter set). **Accept on `BufferOverflow`** means the consumer owns the acknowledged + incompleteness — and acceptance is not license to skip retained deliveries (the + conjunctive invariant above still gates the `save`). +- **A registered handler is invoked exactly once per semantic signal, synchronously, on the + consumer's execution context, before its disposition takes effect.** Skipping a + registered handler and applying the default-terminal path is a conformance violation: + the fixtures assert exact handler-invocation records `{kind, disposition}`, and the + default-terminal fixtures assert **zero** invocations. (The visible outcome of + handler-Terminate is otherwise identical to no-handler-terminal; the invocation record is + what distinguishes them.) +- `Observer.gap` and `Observer.buffer_overflow` remain observability-only notifications. + The semantic disposition lives exclusively in the handler. + +**Only event-bearing frames are admitted to the live buffer** — pings, control frames, and +unknown frame types update liveness and are discarded, never buffered — so the buffer is +denominated in events and every dropped entry has an id. On overflow the connector drops +the oldest buffered events; the signal carries their exact ids and count. The live +buffer's capacity is its own option (`EVENT_FEED_LIVE_BUFFER_CAPACITY`, default 10,000 +events), deliberately decoupled from the dedupe capacity. + +### Dedupe `[conformance]` + +The connector keeps a bounded LRU (default 10,000 entries) of **actually-delivered event +ids** — never position ordering. The rule is symmetric across lanes: **every delivery — +poll page, drain, or streaming — checks the LRU before delivering and records the +delivered id.** Poll-vs-push duplication is expected in both directions (pushes arrive +instantly and polls re-serve the same events once they clear the safety horizon; a repair +poll can equally re-serve what streaming already delivered), so duplicates are suppressed +by id regardless of which lane delivered first. + +Two sharp edges, pinned: + +- **A buffered live event with an id ≤ the current position is still delivered** — it was + never served by poll. Discarding live ids at or below the position is the named mutant + this rule exists to kill. +- A duplicate suppressed during poll delivery still counts toward the page's checkpoint: + the position advances regardless — it is a poll position, not an event acknowledgment. + +### Terminal and Continuable Outcomes `[conformance]` + +Terminal reasons end iteration with exactly one typed error element: + +| TerminalReason | Trigger | +|---|---| +| `subscription_rejected` | `reject_subscription` — always terminal, first attempt or reconnect; zero reconnect attempts | +| `protocol_fatal` | raw disconnect `reason=invalid_event_stream_command` | +| `filter_invalid` | 400-filter from a poll; the server's message naming the offending list is preserved | +| `authorization_failed` | 3rd consecutive connection-level authorization failure (unauthorized mint or `unauthorized` disconnect) | +| `checkpoint_load` | `CheckpointStore.load` failed at start | +| `usage` | re-consumed iterator (the only `usage` condition that surfaces as an iteration element — construction-time validation failures raise language-native construction errors carrying the same code) | +| `buffer_overflow` | live-buffer overflow with no registered handler, or a handler returning Terminate | +| `feed_gap` | 410 with no registered handler, or a handler returning Terminate | +| `invalid_continuation` | a `next` or 410 `resume` URL failed same-origin/downgrade validation (Continuation and Resume URL Validation below); no request is issued to it | + +`auth_revoked` is deliberately **reserved, not used**: the wire carries no distinct +revocation signal — revocation is one possible cause of repeated unauthorized failures, and +the error name must not claim certainty the wire cannot substantiate. It comes into +existence only if bc3 ships an observable revocation contract. + +Continuable — none of these end iteration: mint transients and throttles (Retry-After +floors the next reconnect delay), poll transients and throttles (Retry-After waited +exactly) — server-directed waits exempt from local caps per §7 in both lanes — dial +failures, socket drops, `remote` disconnects, +`unauthorized` failures below the threshold, staleness, 400-position (re-enter `since=`), +409 (discard the held position, re-enter `since=`), and a 410 whose registered handler +returns Accept (resume via the provided URL). + +### Seam Contracts + +Five seams isolate the connector from wire I/O, time, and persistence. `CableTransport` and +`Clock` are **product surface, not test hooks** — they are the documented extension points +for custom WebSocket stacks and embedded runtimes, and the reason the conformance harness +is deterministic. + +``` +INTERFACE TicketMinter + mint_stream_ticket() → StreamTicket -- one fully-governed generated CreateStreamTicket call +END + +RECORD StreamTicket + ticket : String -- opaque bearer credential; never logged + expires_in : Integer -- seconds (~120); server-owned, NEVER used for client scheduling + url : String -- connect verbatim; never assemble cable topology client-side +END +-- Mint errors carry a kind: transient | throttled(retry_after) | unauthorized. + +INTERFACE PollSource + poll(cursor: Cursor, filters: Filters) → PollPage -- one fully-governed generated PollEvents call +END + +RECORD Cursor -- exactly one field set; the zero Cursor is the bare present entry + position : String? -- durable resume/repair token + since : String? -- "now", "0", or a decimal event id + page_url : String? -- absolute URL: a `next` continuation OR a 410 resume URL. + -- Same-origin + no-downgrade validated BEFORE any poll call + -- (Continuation and Resume URL Validation) +END + +RECORD PollPage -- the body envelope IS the contract; never bind to response headers + events : List + position : String -- the ONLY thing that ever advances the checkpoint + next : String? -- continuation URL; absent = the walk reached its frozen head. + -- Bound to that walk; NEVER persisted. +END +-- Poll errors carry a kind: transient | throttled(retry_after) | position_invalid | +-- filter_invalid(server message) | filter_changed | gone(epoch_after_id, resume_url). + +INTERFACE CableTransport + dial(ws_url) → CableConn + -- Dials exactly one connection per call. MUST NOT auto-reconnect. MUST NOT interpret, + -- filter, or swallow application text frames. Negotiates subprotocol + -- "actioncable-v1-json". Refuses redirects (§23 Security Invariants). +END + +INTERFACE CableConn + read_frame() → String -- the next raw text frame VERBATIM — including + -- {"type":"disconnect",...}: the terminal/non-terminal + -- distinction lives only in this raw frame. Byte-level + -- representation is language-native (Go []byte); verbatim-ness + -- is the contract. Peer close surfaces as CloseError{code, reason}. + write_frame(String) + close(code, reason) -- idempotent, safe from any context, unblocks read_frame +END + +INTERFACE Clock + now() → monotonic reading -- used ONLY for deltas, never persisted + new_timer(duration, kind) → Timer -- kind-labelled, cancellable, enumerable + outstanding() → List -- kinds of live (unfired, unstopped) timers +END +-- Product seam, registry included: the system clock keeps the same enumerable registry +-- the test clocks rely on, which is what makes "no timer survives teardown" an exact-set +-- assertion (`outstanding()`) rather than a test-only artifact. + +INTERFACE CheckpointStore + load(key: CheckpointKey) → (position: String, ok: Boolean) + save(key: CheckpointKey, position: String) +END + +RECORD CheckpointKey + origin : String -- canonicalized (Checkpoint Identity below) + account_id : String + consumer_namespace : String -- required whenever a store is configured + filter_key : String -- "srv1-" + bare server digest +END +``` + +Frame-parsing ownership: **the connector parses every frame; the transport moves bytes.** +Application-level `{"type":"ping"}` frames are connector business (staleness); +WebSocket-level ping/pong control frames stay inside the transport implementation. This +split is what makes the protocol-fatal disconnect interceptable by contract rather than by +library-specific hacks. + +### Seam-Call Semantics `[conformance]` + +**One seam call (`mint_stream_ticket`, `poll`) is one fully-governed generated call.** The +generated operation keeps its full §7 contract *inside* the seam — internal retry budget, +declared `retry_on` gate, backoff, Retry-After. `CreateStreamTicket` is safe-to-retry (a +stateless mint — deliberately not a claim of identical responses), so §7 retry applies +inside a mint seam call. The connector **never adds a second per-request retry layer**: its +backoff and Retry-After logic governs **reconnect cycles and poll-walk resumption only**, +and treats seam errors as post-retry outcomes. Conformance mint/connect/poll counts count +seam calls, never wire attempts. + +### Continuation and Resume URL Validation `[conformance]` + +The two absolute URLs the poll lane follows — the envelope's `next` continuation and a 410 +body's `resume` URL — carry the caller's `Authorization` bearer when followed. Before a +`poll(Cursor{page_url})` call is made, the connector validates the URL against the +configured base origin with §8's Same-Origin Validation Algorithm, and rejects a protocol +downgrade (HTTPS → HTTP) — the same rule, for the same reason, as §8's pagination `Link` +rejection: a cross-origin or downgraded URL in a response body must never redirect an +authenticated request (SSRF and token leakage). A URL that fails validation is +Terminal(`invalid_continuation`) — no request is issued to the failing URL, and the +rejected URL is carried redacted (origin only) in the error. There is no retry and no +handler for this condition: a hostile continuation is not an operable feed state. + +The mint's cable `url` is deliberately **not** under this rule: it is server-directed +cable topology, cross-host by design, dialed verbatim with its own credential (the +short-lived ticket rides in the URL itself; no `Authorization` header is attached), and +governed by its own invariants — `wss://` outside localhost, redirects refused, never +logged (Security Invariants below). + +Required tier-2 coverage: a hostile cross-origin `next` mid-walk and a hostile 410 +`resume` URL each terminate with `invalid_continuation` and zero requests to the foreign +origin. + +### Clock, Timers, and Virtual Time `[conformance]` + +**Every delay the connector takes flows through the injected Clock** — no native timer or +sleep may bypass it. There are exactly six timer kinds, kebab-case: + +| Kind | Armed | Duration | +|---|---|---| +| `handshake-deadline` | on dial success (transition 6) | `EVENT_FEED_HANDSHAKE_DEADLINE` (10s) | +| `confirmation-deadline` | on `welcome` → subscribe (transition 8) | `EVENT_FEED_CONFIRMATION_DEADLINE` (10s default, configurable) | +| `backoff` | on entry to Backoff | full-jitter: `uniform(0, min(60s, 1s × 2^(n−1)))` for failed-cycle count n; Retry-After floors it, and wins outright when it exceeds the cap (server-directed waits are exempt, §7) | +| `staleness` | at socket open; re-armed per inbound frame of any kind | 7500ms | +| `repair-poll` | on entry to Streaming; re-armed per cycle | 60s ± 20% jitter per cycle | +| `poll-retry` | on a transient/throttled poll inside CatchingUp | Retry-After when present (exact, cap-exempt per §7); else full-jitter `uniform(0, min(60s, 1s × 2^(k−1)))` on the consecutive-poll-failure index k (reset by a successful poll or socket teardown) | + +The connector's reconnect backoff is deliberately **not** the §7 per-request formula: it is +full-jitter delay *selection* over the whole range (the §7 formula saturates its backoff +term at `MAX_BACKOFF_DELAY_MS` = 30s and adds jitter on top; this one draws uniformly from +`[0, min(60s, 1s × 2^(n−1)))` with no added term). The two govern different things — §7 +governs attempts inside a seam call; this governs cycles between them. The same 60s cap +bounds `poll-retry`'s locally computed jitter draw; a server-directed `Retry-After` is +exempt from both caps, per §7. + +**Virtual-advance algorithm (normative).** Every language's test clock must honor the same +semantics, so a tier-2 script means the same thing everywhere: *advancing virtual time +fires due timers in deadline order, re-evaluating after each fire; timers scheduled during +the advance whose deadlines land inside the window also fire; ties break by creation +order.* A harness may additionally fire a named timer without advancing the clock, +asserting its scheduled delay against a `{min, max}` envelope — that is how jitter is +asserted without a cross-language RNG seam. Each language's test clock passes a shared +semantics checklist (deadline order, reentrant scheduling within an advance, creation-order +tie-break) before its tier-2 results count. + +Teardown discipline: disposing a connection attempt — deadline lapse, staleness, socket +death, terminal — cancels the frame pump, closes the connection, and stops **all** of that +attempt's timers before the next state is entered. After a confirmation-deadline teardown, +the exact outstanding-timer set is `{backoff}`; after a terminal, it is `{}`. Exact-set +timer assertions are what make a leaked deadline timer, ghost watchdog, or duplicated +backoff a hard failure rather than a heuristic. + +### Checkpoint Identity `[conformance]` + +Checkpoint identity is `{origin, account_id, consumer_namespace, filter_key}` — all four, +always: + +- Server positions are bound to `{account, filter set}` but carry **no consumer identity**; + two independent consumers in one account would otherwise share a lineage and silently + skip each other's work. `consumer_namespace` is therefore a required input whenever a + store is configured (a configured store with an empty namespace fails construction with + a `usage`-coded language-native error — Consumer Surface above). +- `origin` is included because the SDK supports configurable base URLs; a server-side + cursor-domain key is not a safe client persistence key on its own. **Origin + canonicalization:** lowercase scheme and host; omit the default port (`:443` for https, + `:80` for http); no path, query, fragment, or trailing slash — canonical form exactly + `scheme "://" host [":" nondefault-port]`. Hosts are used as configured after lowercasing + (no IDN/punycode transformation). +- `filter_key = "srv1-" + `. The **server wire format + is the bare hex** — exactly what the 409 body's `position_digest`/`filters_digest` carry; + the server never emits the `srv1-` prefix. It is the SDK-side checkpoint-lineage + namespace only. + +**srv1 canonicalization (the published server contract; the SDK implements it as +published, not as a mirror of server internals):** + +``` +digest = lowercase_hex(SHA-256(UTF-8(canonical_json)))[0:16] -- 16 hex chars = first 8 bytes +canonical_json = "[" T "," B "," C "]" -- compact: no whitespace anywhere + T = null if no types, else a JSON array of the type strings, deduped, + sorted bytewise-ascending over their UTF-8 encodings + B = null if no buckets, else a JSON array of integers: base-10 coerced, deduped AFTER + coercion ("1" and "01" are one id), numerically ascending, canonical integer + rendering (no sign for positives, no leading zeros, no fraction, no exponent) + C = same as B, for creators + absent list ⇒ null; empty filter set ⇒ the input is exactly [null,null,null] + string escaping: RFC 8259 minimal — only ", \, and control characters U+0000–U+001F; + NO HTML escaping; no \uXXXX for non-control characters +``` + +The canonical bytes are hand-built (string builder plus a minimal escape helper) — no +language's default JSON emitter is load-bearing, because several HTML-escape by default. +srv1's domain is the cataloged ASCII type strings and integer ids; unknown types draw the +filter 400 before any digest is computed, so no quoted-string or non-ASCII vector exists. + +Published srv1 vectors (provisional until the merge-time gate; the conformance vector +source): + +| Input | Canonical JSON | Digest | +|---|---|---| +| no filters | `[null,null,null]` | `fe44a8cccd89edae` | +| `types=message.created` | `[["message.created"],null,null]` | `9eae6bbae1414746` | +| `types=todo.completed,message.created&buckets=2,1` (unsorted multi-list) | `[["message.created","todo.completed"],[1,2],null]` | `00ed03e6196e77a2` | +| `buckets=01` (also `buckets=1,01` — post-coercion dedup) | `[null,[1],null]` | `fb19e601cd033cad` | +| `buckets=1,...,100` (the cap boundary) | `[null,[1,2,...,100],null]` | `832adbc56aa7c8f2` | + +The one built-in store is a file store: a single JSON file keyed by the compact RFC 8259 +JSON array of the four identity strings — e.g. +`["https://3.basecampapi.com","5951425","openclaw","srv1-9f2ab04e5c11d3a7"]` — written +atomically (temp + rename, 0600), documented as single-process (a server-side advisory +checkpoint API is deliberately deferred until a multi-host connector needs a shared +cursor). No `delete` method exists: after a 409 the connector re-enters via `since=` and +the next page's `save` overwrites under the same key, and after a filter change the key +itself changed, so the old lineage simply goes cold. + +Checkpoints only move forward; an access grant does not replay history behind them. +Inspecting a newly granted bucket's past activity is an explicit caller choice: rewind to +an older stored position, reset via `since=`, or (preferred for agents) fetch canonical +resources. + +### Options and Per-Language Naming `[static]` + +Following the §16 device-flow precedent (injectable clock, per-language option idiom), the +connector's options map per language as follows. Go uses functional options; TypeScript an +options object with optional fields; Python and Ruby keyword arguments (Ruby with +`DEFAULT_…` constants); Kotlin constructor parameters; Swift initializer parameters. + +| Concern (default) | Go option | TS field | Python / Ruby kwarg | Kotlin / Swift parameter | +|---|---|---|---|---| +| Filters (none) | `WithFilters` | `filters?` | `filters` | `filters` | +| Entry mode (resume: stored position if any, else present; also present / beginning / after(id) / at-position(token)) | `WithStart` | `start?` | `start` | `start` | +| Cable transport (default WebSocket impl) | `WithTransport` | `transport?` | `transport` | `transport` | +| Clock (system monotonic) | `WithClock` | `clock?` | `clock` | `clock` | +| Checkpoint store (none) | `WithCheckpointStore` | `checkpointStore?` | `checkpoint_store` | `checkpointStore` | +| Consumer namespace (required with a store) | `WithConsumerNamespace` | `consumerNamespace?` | `consumer_namespace` | `consumerNamespace` | +| Confirmation deadline (10s) | `WithConfirmationDeadline` | `confirmationDeadlineMs?` | `confirmation_deadline` | `confirmationDeadline` | +| Repair interval (60s ± 20%) | `WithRepairInterval` | `repairIntervalMs?` | `repair_interval` | `repairInterval` | +| Dedupe capacity (10,000) | `WithDedupeCapacity` | `dedupeCapacity?` | `dedupe_capacity` | `dedupeCapacity` | +| Live buffer capacity (10,000) | `WithLiveBufferCapacity` | `liveBufferCapacity?` | `live_buffer_capacity` | `liveBufferCapacity` | +| Signal handler (none ⇒ default-terminal) | `WithSignalHandler` | `signalHandler?` | `signal_handler` | `signalHandler` | +| Observer (none) | `WithObserver` | `observer?` | `observer` | `observer` | + +The Observer is a struct of optional callbacks in the `httptrace.ClientTrace` style — +extensible without breaking implementers: `connecting(attempt, delay)`, `connected()`, +`confirmed()`, `disconnected(reason, error)`, `catch_up_started(cursor)`, +`page_delivered(count, position)`, `checkpoint(position)` (after that page's events were +accepted), `checkpoint_save_failed(error)`, `caught_up()`, `gap(epoch_after_id, +resume_url)`, `position_rejected(kind)`, `stale_connection(since_last_frame)`, +`buffer_overflow(dropped_count)`. All are observability-only; none carries a disposition. + +### Security Invariants `[static]` + +- **Never log the ticket or the mint URL's query string** — the ticket rides in it. + Observer callbacks and error renderings carry redacted URLs. +- **Bound the inbound frame size** (`EVENT_FEED_MAX_FRAME_BYTES`, 1 MiB default) and + bound/truncate any error rendering of frame contents (§9's `MAX_ERROR_MESSAGE_LENGTH` + applies). +- **Require `wss://`** for the cable URL, with the §9 localhost/loopback carve-out. +- **Refuse mint-URL redirects** — a redirect on dial is a hard error, never followed. +- **Validate every continuation and resume URL** before following it — §8's same-origin + algorithm plus downgrade rejection, terminal `invalid_continuation` on failure + (Continuation and Resume URL Validation above). Authenticated poll requests never + follow a cross-origin URL. + +### Constants + +The connector's constants live in Appendix A (the `EVENT_FEED_*` rows): handshake deadline +10s; confirmation deadline 10s; repair interval 60s ± 20% jitter per cycle; reconnect +backoff base 1s, ×2, cap 60s, full-jitter (the same cap bounds `poll-retry`'s jitter draw; +server-directed `Retry-After` is exempt from local caps per §7); server +heartbeat cadence 3s; staleness 7500ms; authorization-failure threshold 3; dedupe capacity +10,000 ids; live buffer capacity 10,000 events; ticket TTL ~120s (server-owned — +`expires_in` is never used for client-side scheduling; expiry is arbitrated by the server +and the connector always mints fresh); maximum inbound frame 1 MiB. + +### Verification + +Verification is three tiers with disjoint responsibilities: + +| Tier | What it proves | Where it lives | +|---|---|---| +| 1 — poll-lane wire behavior | request/response contract of the generated `PollEvents` as ordinary operation-dispatch cases | `conformance/tests/` — deferred to the generated layer's landing | +| 2 — connector protocol scenarios | the cross-lane state machine, frame-level cable behavior, mint/connect/poll interleave, virtual time | `conformance/event-feed/` fixture family + per-SDK native scenario drivers | +| 3 — per-language internals | what data fixtures cannot express: LRU bounds, jitter formula, single-flight under real concurrency, real transport adapter contract, test-clock semantics | per-SDK unit/integration tests against the seams | + +The tier-2 family has its own schema and README; scenarios are strictly-ordered interleaved +scripts (an unexpected mint, poll, connect, or outbound frame fails the scenario), with +time consumed through the Clock seam per the virtual-advance algorithm above. Four +acceptance behaviors are cross-language contracts: **(a)** fresh-ticket reconnect — after a +severed socket past the ticket TTL, the reconnect presents a newly minted ticket URL; +**(b)** confirmation gating — zero deliveries, zero polls, zero saves before +`confirm_subscription`; **(c)** deadline teardown — the disposed attempt leaves exactly +`{backoff}` outstanding; **(d)** terminal rejection — the connector closes the open socket, +zero reconnect attempts, no armed retry timer survives. Handler-invocation records and the +present-class-entry fixtures pin the Entry Boundary and Semantic Signals contracts; +mint/connect/poll counts in fixtures count seam calls per Seam-Call Semantics. The +fixture-by-fixture inventory lives in the family's own README — following the +oauth/oauth-token precedent, parallel fixture families are documented at their consuming +section and directory, not in §19's operation-dispatch category table or Appendix D. +Per-SDK scenario-lane divergences (Swift's fake-transport lane, Kotlin's jvmTest-only +consumption) are recorded in Appendix F with their compensating tier-3 tests. + +--- + ## Appendix A: Constants Reference All magic numbers in one place, derived from shipping SDK code (not `rubric-audit.json`). -Only `API_VERSION` is gated (``, checked by `make doc-constants-check`). The other 13 rows are hand-maintained and were read against their cited sources on 2026-08-03 — all 13 matched. They are not gated because each is asserted of several SDKs at once in a different spelling per language (Go `1 * time.Second`, Python `1.0`, Ruby `1.0`, Kotlin `30.seconds`, Swift `1_000`), so a checker would need a per-row, per-language extraction rule rather than the one-value-one-source substitution the marker convention is built on. The name in the table is the concept, not a symbol to grep: `MAX_ERROR_MESSAGE_LENGTH` is `MaxErrorMessageBytes` in Go and `MAX_ERROR_MESSAGE_BYTES` in Ruby, and `TOKEN_REFRESH_BUFFER` is the literal `300` in `creds.ExpiresAt-300` (`go/pkg/basecamp/auth.go`) rather than a named constant at all. If one of these starts moving, gate that row rather than the appendix. +Only `API_VERSION` is gated (``, checked by `make doc-constants-check`). The other 14 pre-§23 rows are hand-maintained: 13 were read against their cited sources on 2026-08-03 — all 13 matched — and `MAX_BACKOFF_DELAY` joined with #592 under that PR’s own six-SDK verification (the sentence previously said 13 rows while the table carried 14). The `EVENT_FEED_*` block below them is different in kind and marked so: those rows are contract-first — their source is §23’s normative text, connector code ships in later PRs, and the two server-owned values are provisional until bc3’s merge-time gate; when the connector lands, they join the read-against-source discipline. They are not gated because each is asserted of several SDKs at once in a different spelling per language (Go `1 * time.Second`, Python `1.0`, Ruby `1.0`, Kotlin `30.seconds`, Swift `1_000`), so a checker would need a per-row, per-language extraction rule rather than the one-value-one-source substitution the marker convention is built on. The name in the table is the concept, not a symbol to grep: `MAX_ERROR_MESSAGE_LENGTH` is `MaxErrorMessageBytes` in Go and `MAX_ERROR_MESSAGE_BYTES` in Ruby, and `TOKEN_REFRESH_BUFFER` is the literal `300` in `creds.ExpiresAt-300` (`go/pkg/basecamp/auth.go`) rather than a named constant at all. If one of these starts moving, gate that row rather than the appendix. | Constant | Value | Unit | Source | |----------|-------|------|--------| @@ -2076,6 +2876,18 @@ Only `API_VERSION` is gated (``, checked by `make doc-const | `MAX_TOKEN_HASH_ENTRIES` | 100 | entries | `typescript/src/client.ts` | | `API_VERSION` | `2026-08-02` | — | `openapi.json` `info.version` | | `TOKEN_REFRESH_BUFFER` | 300 | seconds | Go OAuth token refresh threshold (5-minute buffer); Ruby refreshes only on expiry (no buffer); TS/Kotlin/Swift delegate expiry to caller | +| `EVENT_FEED_HANDSHAKE_DEADLINE` | 10 | seconds | §23 timers — dial-to-`welcome` deadline | +| `EVENT_FEED_CONFIRMATION_DEADLINE` | 10 | seconds | §23 (configurable; default) | +| `EVENT_FEED_REPAIR_INTERVAL` | 60, ± 20% jitter per cycle | seconds | §23 (configurable; default) | +| `EVENT_FEED_BACKOFF_BASE` | 1 | seconds | §23 — full-jitter base for both the reconnect-cycle and `poll-retry` formulas | +| `EVENT_FEED_BACKOFF_CAP` | 60 | seconds | §23 — caps the locally computed reconnect-cycle and `poll-retry` delays (server-directed `Retry-After` is exempt, §7); deliberately distinct from `MAX_BACKOFF_DELAY` above (§7's `MAX_BACKOFF_DELAY_MS`, 30s), which governs attempts inside a seam call | +| `EVENT_FEED_PING_INTERVAL` | 3 | seconds | server heartbeat cadence (bc3; provisional until the merge-time gate); input to `EVENT_FEED_STALE_AFTER` | +| `EVENT_FEED_STALE_AFTER` | 7500 | milliseconds | §23 — two missed 3s heartbeats + 25% grace; SDK-pinned detection policy | +| `EVENT_FEED_AUTH_FAILURE_THRESHOLD` | 3 | consecutive failures | §23 disconnect dispatch (one shared counter) | +| `EVENT_FEED_DEDUPE_CAPACITY` | 10,000 | event ids | §23 (configurable; default) | +| `EVENT_FEED_LIVE_BUFFER_CAPACITY` | 10,000 | events | §23 (configurable; default; decoupled from the dedupe capacity; only event-bearing frames are buffered) | +| `EVENT_FEED_TICKET_TTL` | ~120 | seconds | server-owned (`expires_in`; provisional until the merge-time gate); never used for client scheduling | +| `EVENT_FEED_MAX_FRAME_BYTES` | 1,048,576 (1 MiB) | bytes | §23 security invariants | --- @@ -2333,3 +3145,22 @@ For ASCII text (all conformance test fixtures today), these are equivalent. | Ruby | 46 (full canonical set) | | Go | 44 as standalone accessors (folds `automation`; `clientVisibility` ops exist on `RecordingsService` rather than as a separate service). Hand-written service wrappers around generated OpenAPI client — not fully generated. | | Python | 46 (full canonical set; sync + async) | + +### Event Feed Connector Scenario Lane (§23) + +Tier-2 scenarios drive the real transport adapter over an in-process loopback cable server +wherever a lightweight one exists, with only the Clock faked; divergences are compensated +by named tier-3 tests. + +| SDK | Tier-2 scenario lane | Compensation / note | +|-----|----------------------|---------------------| +| Go | Real default transport against an in-process loopback ws server; fixtures consumed as data | Full-jitter formula additionally pinned exactly via an injected deterministic rand source (tier 3) | +| TypeScript | Real transport under MSW `ws` interception | Default transport must use the global `WebSocket` (Node ≥ 22), never the `ws` package | +| Python | Real transport over an in-process `websockets` loopback | Connector transport ships under an optional `stream` extra | +| Ruby | Real transport over a `websocket-driver` loopback | `websocket-driver` becomes a runtime dependency | +| Kotlin | jvmTest-only: real ktor ws client against a test-scoped server (`MockEngine` cannot mock WebSockets) | State machine mirrored in commonTest tier 3 for the four acceptance scenarios | +| Swift | Fake-transport-driven scenarios (no in-process ws server without adding SwiftNIO) | macOS-gated tier-3 `URLSessionWebSocketTask` adapter contract test proves the real adapter honors the transport contract (verbatim frames, close mapping) | + +Outside Go, jitter is asserted only as a `{min, max}` envelope — a degenerate RNG +(always-0 is legal full-jitter output) is caught only by Go's formula pin. Documented +divergence, accepted. From 04479b50cb2746ff6a737943276346863cf94e71 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 10:50:55 -0700 Subject: [PATCH 02/13] Address the #614 review: poll-error passthrough, poll-only reset cursor, bounded pump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex found two P1 holes and a P2. Ordinary generated-operation poll errors outside the feed's 400/409/410 matrix now have defined outcomes: 401/403 (post-refresh) is a new unauthorized poll-error kind riding the reconnect cycle on the shared authorization counter, and anything else non-retryable is a new unrecoverable kind passing through as Terminal(poll_failed) with the generated error attached. The 400/409 reset cursor is pinned poll-lane-only (last poll-served id, never a live-delivered id — a live id above the durable position would skip the un-polled gap behind it; empty pages don't advance it). The frame pump's hand-off queue is specified as bounded and blocking (back-pressure to TCP, never a drop), so the live buffer remains the only drop point and connector memory is bounded under a slow consumer. Copilot's two curly apostrophes are straightened. --- SPEC.md | 61 ++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/SPEC.md b/SPEC.md index a1904f6cb6..d9958f0997 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2041,7 +2041,7 @@ Regenerate-and-diff freshness gates now exist for all six SDKs' generated output The following are explicitly NOT part of this specification: -- GraphQL or SSE transport. WebSocket as a general API transport remains out of scope; the §23 Event Feed connector’s Action Cable lane is in scope +- GraphQL or SSE transport. WebSocket as a general API transport remains out of scope; the §23 Event Feed connector's Action Cable lane is in scope - CLI UI or interactive prompts - Circuit breaker, bulkhead, or client-side rate limiter (rubric T2D criteria exist but are optional extras, not core contracts) - Prometheus or OpenTelemetry hook implementations (the hook protocol is in scope; specific integrations are not) @@ -2252,8 +2252,8 @@ typed error element is emitted), `Closed` (absorbing; no error element). | 15 | AwaitingConfirmation | Backoff | socket dropped, staleness expiry, or disconnect with `reconnect ≠ false` (includes `remote`) | | 16 | CatchingUp | CatchingUp | page fetched → deliver events → save + announce checkpoint → follow `next` | | 17 | CatchingUp | CatchingUp **or** Terminal(`feed_gap`) | 410 → dispatch `FeedGap` signal (Semantic Signals below): a registered handler returning Accept re-enters via the provided resume URL; Terminate — or no handler — is Terminal(`feed_gap`). `Observer.gap` fires as observability either way. A 410 never silently auto-continues. | -| 18 | CatchingUp | CatchingUp | 400-position → re-enter `since=` (or, with no accepted id, `since="now"` — a present-class entry, Entry Boundary below); `Observer.position_rejected(position_invalid)` | -| 19 | CatchingUp | CatchingUp | 409 → discard the held position → re-enter `since=` (or, with no accepted id, `since="now"` — a present-class entry); `Observer.position_rejected(filter_changed)` | +| 18 | CatchingUp | CatchingUp | 400-position → re-enter `since=` (or, with none, `since="now"` — a present-class entry, Entry Boundary below); `Observer.position_rejected(position_invalid)` | +| 19 | CatchingUp | CatchingUp | 409 → discard the held position → re-enter `since=` (or, with none, `since="now"` — a present-class entry); `Observer.position_rejected(filter_changed)` | | 20 | CatchingUp | Terminal(`filter_invalid`) | 400-filter: configuration error; a position reset won't help | | 21 | CatchingUp | Backoff | socket died mid-walk, or staleness expiry (per-page checkpoints already saved are kept) | | 22 | CatchingUp | Draining | `next` absent — the walk reached its frozen head → deliver the final page's events → save + announce its checkpoint (position-resume entries) → enter Draining. **Present-class amendment (present-class entries only — Entry Boundary below; position-resume entries unchanged):** the entry poll's returned position is HELD, not saved; the ownership cut — defined under Entry Boundary — is taken after entry into Draining. | @@ -2267,14 +2267,19 @@ Semantic-signal dispositions are an **overlay** on this table, not additional nu rows: a `FeedGap` signal arises at transition 17; a `BufferOverflow` signal arises whenever the live buffer drops events (any buffer-holding state) and is dispatched synchronously before the next Save; a Terminate disposition — or an absent handler — lands in the -corresponding Terminal state from wherever the signal was dispatched. Four further edges +corresponding Terminal state from wherever the signal was dispatched. Five further edges sit outside the 26 numbered rows for the same reason: the Terminal(`checkpoint_load`) edge (fires on the first iteration, before the first mint — zero wire attempts); the Terminal(`usage`) edge (a re-consumed iterator); the Terminal(`invalid_continuation`) edge (fires between URL validation and the poll call, wherever a `next` continuation or 410 `resume` URL is about to be followed — row 16's follow and row 17's Accept re-entry both -pass through it; zero requests to the failing URL); and the poll transient/throttle +pass through it; zero requests to the failing URL); the Terminal(`poll_failed`) edge (an +`unrecoverable`-kind poll error — Seam Contracts below — from any polling state, carrying +the generated error); and the poll transient/throttle **self-loop** inside CatchingUp (the `poll-retry` timer — a wait, not a state change). +An `unauthorized`-kind poll error rides the reconnect cycle — full teardown → Backoff with +a shared-counter increment (the fresh mint/token cycle is its recovery path) — and the +counter's threshold-3 terminal fires from wherever the third consecutive failure lands. The published inventory stays 11/26. Interpretation, pinned: @@ -2284,14 +2289,16 @@ Interpretation, pinned: leaves a rejected socket open; an unhandled one stays registered server-side, receiving heartbeats forever while delivering nothing). - **Connection-level authorization failures retry, then surface — on ONE shared counter.** - Unauthorized mints (401/403) and `unauthorized`-reason disconnects at connect - (pre-welcome — rows 9/10; Disconnect Dispatch pins the arrival point) increment the same - consecutive-failure counter; at `EVENT_FEED_AUTH_FAILURE_THRESHOLD = 3` the connector - surfaces `authorization_failed` (a mixed sequence — mint 401, `unauthorized` disconnect, - mint 401 — is terminal). A single blip must not kill a long-lived agent: a ticket that - expired in the mint→dial race is indistinguishable from revocation on one sample. The - counter resets on `confirm_subscription`, and only these two failure shapes ever - increment it. + Unauthorized mints (401/403), `unauthorized`-reason disconnects at connect + (pre-welcome — rows 9/10; Disconnect Dispatch pins the arrival point), and + `unauthorized`-kind poll errors (401/403 after the seam's own refresh/retry budget) + increment the same consecutive-failure counter; at + `EVENT_FEED_AUTH_FAILURE_THRESHOLD = 3` the connector surfaces `authorization_failed` + (a mixed sequence — mint 401, `unauthorized` disconnect, poll 401 — is terminal). A + single blip must not kill a long-lived agent: a ticket that expired in the mint→dial + race is indistinguishable from revocation on one sample. The counter resets on + `confirm_subscription` **and on any successful poll page**, and only these three + failure shapes ever increment it. - **Poll transients and throttles are never terminal.** They retry inside CatchingUp on the `poll-retry` timer, with one algorithm: a server-directed `Retry-After` is waited exactly, exempt from local caps per §7's rule (implementations may still bound it @@ -2308,6 +2315,12 @@ Interpretation, pinned: on the first iteration **before the first mint**; its failure is Terminal(`checkpoint_load`) with zero wire attempts, because silently starting at the present would skip history. +- **The reset cursor is poll-lane-only.** Rows 18/19's `since=` is + the highest event id ever **served by the poll lane** on this lineage — a value tracked + independently of delivery, dedupe, and the live lane. A live-delivered id is NEVER a + reset cursor: a live id above the durable position would re-enter past the un-polled + gap behind it and permanently skip everything in between. Empty pages serve no ids and + do not advance this value; with no poll-served id at all, the re-entry is present-class. - **Reconnect discipline:** one path through Backoff → Minting → Connecting; at most one in-flight reconnect attempt at any time; the attempt counter increments per failed cycle and resets on confirmation. A fresh ticket is minted on **every** pass — the connector @@ -2388,6 +2401,14 @@ Two dispatch clarifications, pinned: (`EVENT_FEED_STALE_AFTER = 7500ms`: two missed 3-second server heartbeats plus 25% grace — the server contract leaves detection policy to the SDK; the SDK pins and publishes this one). +- **The frame pump's hand-off queue is bounded and never drops.** The pump reads frames + from the transport and hands them to the state machine over a queue of small fixed depth + (implementation-chosen; the Go reference uses 256). At capacity the pump **blocks** — + back-pressure propagates to the socket and TCP — rather than dropping: the + state-machine-owned live buffer is the only place a frame can ever be dropped, and its + overflow signal is the only drop signal. Connector memory is therefore bounded by pump + depth + `EVENT_FEED_LIVE_BUFFER_CAPACITY` + `EVENT_FEED_MAX_FRAME_BYTES`, even under a + slow consumer. - The transport negotiates subprotocol `actioncable-v1-json`, sends no `Origin` header (non-browser clients), and passes the mint URL through untouched, query string included. @@ -2529,6 +2550,7 @@ Terminal reasons end iteration with exactly one typed error element: | `buffer_overflow` | live-buffer overflow with no registered handler, or a handler returning Terminate | | `feed_gap` | 410 with no registered handler, or a handler returning Terminate | | `invalid_continuation` | a `next` or 410 `resume` URL failed same-origin/downgrade validation (Continuation and Resume URL Validation below); no request is issued to it | +| `poll_failed` | an `unrecoverable`-kind poll error — a generated-operation outcome outside the feed's 400/409/410 matrix and the retryable classes (e.g. 404, 405, an unexpected shape) — passed through with the generated error attached | `auth_revoked` is deliberately **reserved, not used**: the wire carries no distinct revocation signal — revocation is one possible cause of repeated unauthorized failures, and @@ -2538,7 +2560,8 @@ existence only if bc3 ships an observable revocation contract. Continuable — none of these end iteration: mint transients and throttles (Retry-After floors the next reconnect delay), poll transients and throttles (Retry-After waited exactly) — server-directed waits exempt from local caps per §7 in both lanes — dial -failures, socket drops, `remote` disconnects, +failures, socket drops, `remote` disconnects, `unauthorized`-kind poll errors below the +shared-counter threshold (recovery rides the reconnect cycle), `unauthorized` failures below the threshold, staleness, 400-position (re-enter `since=`), 409 (discard the held position, re-enter `since=`), and a 410 whose registered handler returns Accept (resume via the provided URL). @@ -2581,7 +2604,13 @@ RECORD PollPage -- the body envelope IS the contract; never bind to resp -- Bound to that walk; NEVER persisted. END -- Poll errors carry a kind: transient | throttled(retry_after) | position_invalid | --- filter_invalid(server message) | filter_changed | gone(epoch_after_id, resume_url). +-- filter_invalid(server message) | filter_changed | gone(epoch_after_id, resume_url) | +-- unauthorized | unrecoverable(error). +-- The adapter maps every §6/§7 outcome of the generated call onto exactly one kind: +-- 429/503 and §7-retryable outcomes exhausted inside the seam → transient/throttled; +-- the feed's 400/409/410 matrix → its four kinds; 401/403 (after the seam's own token +-- refresh and retry budget) → unauthorized; anything else non-retryable (404, 405, +-- unexpected shapes) → unrecoverable, carrying the generated error verbatim. INTERFACE CableTransport dial(ws_url) → CableConn @@ -2857,7 +2886,7 @@ consumption) are recorded in Appendix F with their compensating tier-3 tests. All magic numbers in one place, derived from shipping SDK code (not `rubric-audit.json`). -Only `API_VERSION` is gated (``, checked by `make doc-constants-check`). The other 14 pre-§23 rows are hand-maintained: 13 were read against their cited sources on 2026-08-03 — all 13 matched — and `MAX_BACKOFF_DELAY` joined with #592 under that PR’s own six-SDK verification (the sentence previously said 13 rows while the table carried 14). The `EVENT_FEED_*` block below them is different in kind and marked so: those rows are contract-first — their source is §23’s normative text, connector code ships in later PRs, and the two server-owned values are provisional until bc3’s merge-time gate; when the connector lands, they join the read-against-source discipline. They are not gated because each is asserted of several SDKs at once in a different spelling per language (Go `1 * time.Second`, Python `1.0`, Ruby `1.0`, Kotlin `30.seconds`, Swift `1_000`), so a checker would need a per-row, per-language extraction rule rather than the one-value-one-source substitution the marker convention is built on. The name in the table is the concept, not a symbol to grep: `MAX_ERROR_MESSAGE_LENGTH` is `MaxErrorMessageBytes` in Go and `MAX_ERROR_MESSAGE_BYTES` in Ruby, and `TOKEN_REFRESH_BUFFER` is the literal `300` in `creds.ExpiresAt-300` (`go/pkg/basecamp/auth.go`) rather than a named constant at all. If one of these starts moving, gate that row rather than the appendix. +Only `API_VERSION` is gated (``, checked by `make doc-constants-check`). The other 14 pre-§23 rows are hand-maintained: 13 were read against their cited sources on 2026-08-03 — all 13 matched — and `MAX_BACKOFF_DELAY` joined with #592 under that PR's own six-SDK verification (the sentence previously said 13 rows while the table carried 14). The `EVENT_FEED_*` block below them is different in kind and marked so: those rows are contract-first — their source is §23's normative text, connector code ships in later PRs, and the two server-owned values are provisional until bc3's merge-time gate; when the connector lands, they join the read-against-source discipline. They are not gated because each is asserted of several SDKs at once in a different spelling per language (Go `1 * time.Second`, Python `1.0`, Ruby `1.0`, Kotlin `30.seconds`, Swift `1_000`), so a checker would need a per-row, per-language extraction rule rather than the one-value-one-source substitution the marker convention is built on. The name in the table is the concept, not a symbol to grep: `MAX_ERROR_MESSAGE_LENGTH` is `MaxErrorMessageBytes` in Go and `MAX_ERROR_MESSAGE_BYTES` in Ruby, and `TOKEN_REFRESH_BUFFER` is the literal `300` in `creds.ExpiresAt-300` (`go/pkg/basecamp/auth.go`) rather than a named constant at all. If one of these starts moving, gate that row rather than the appendix. | Constant | Value | Unit | Source | |----------|-------|------|--------| From 34948f351b024291ed23257dd825c66cee472b03 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 11:03:17 -0700 Subject: [PATCH 03/13] Address the #614 re-review: counter reset, present fallback, mint passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-on findings from the re-review. The shared authorization counter now resets only on a successful poll page — never on confirm_subscription, which proves the ticket worked but not the bearer, and would let alternating poll-401/reconnect/confirm cycles hold the counter below threshold forever (a connect blip still clears promptly via the post-confirmation catch-up poll). The present-class fallback for 400/409 re-entries is keyed to the absence of a poll-served id, matching the poll-lane-only reset cursor. And CreateStreamTicket gains the same unrecoverable(error) passthrough polls got: non-retryable non-auth mint outcomes are Terminal(mint_failed) with the generated error attached. --- SPEC.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index d9958f0997..a2c35a4d54 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2239,13 +2239,13 @@ typed error element is emitted), `Closed` (absorbing; no error element). | 2 | Backoff | Minting | `backoff` timer fired (a fresh ticket is ALWAYS minted next) | | 3 | Minting | Connecting | ticket minted; dial the mint's `url` verbatim | | 4 | Minting | Backoff | mint transient/throttled, or an unauthorized mint (401/403) below the shared-counter threshold (Retry-After honored as the floor of the next delay) | -| 5 | Minting | Terminal(`authorization_failed`) | 3rd consecutive connection-level authorization failure (shared counter across unauthorized mints and `unauthorized` disconnects; resets on `confirm_subscription`) | +| 5 | Minting | Terminal(`authorization_failed`) | 3rd consecutive connection-level authorization failure (shared counter across unauthorized mints, `unauthorized` disconnects, and unauthorized polls; resets only on a successful poll page) | | 6 | Connecting | AwaitingWelcome | dial ok; frame pump started; `handshake-deadline` armed (10s) | | 7 | Connecting | Backoff | dial failed | | 8 | AwaitingWelcome | AwaitingConfirmation | `welcome` → send subscribe; re-arm as `confirmation-deadline` (10s) | | 9 | AwaitingWelcome | Backoff | socket error/close, staleness expiry, or handshake deadline lapsed (full teardown first) | | 10 | AwaitingWelcome | Terminal(`authorization_failed`) | 3rd consecutive connection-level authorization failure (reason-string dispatch on the literal `unauthorized`; shared counter with unauthorized mints) | -| 11 | AwaitingConfirmation | CatchingUp | `confirm_subscription` → cancel deadline; reset attempt + auth counters; select entry cursor | +| 11 | AwaitingConfirmation | CatchingUp | `confirm_subscription` → cancel deadline; reset the attempt counter (the authorization counter resets only on a successful poll page); select entry cursor | | 12 | AwaitingConfirmation | Terminal(`subscription_rejected`) | `reject_subscription` → cancel deadline, **close the socket**, ZERO reconnects | | 13 | AwaitingConfirmation | Terminal(`protocol_fatal`) | raw disconnect frame `reason=invalid_event_stream_command, reconnect=false` | | 14 | AwaitingConfirmation | Backoff | confirmation deadline lapsed → dispose conn + pump + ALL of the attempt's timers, then jittered fresh-ticket retry | @@ -2267,7 +2267,7 @@ Semantic-signal dispositions are an **overlay** on this table, not additional nu rows: a `FeedGap` signal arises at transition 17; a `BufferOverflow` signal arises whenever the live buffer drops events (any buffer-holding state) and is dispatched synchronously before the next Save; a Terminate disposition — or an absent handler — lands in the -corresponding Terminal state from wherever the signal was dispatched. Five further edges +corresponding Terminal state from wherever the signal was dispatched. Six further edges sit outside the 26 numbered rows for the same reason: the Terminal(`checkpoint_load`) edge (fires on the first iteration, before the first mint — zero wire attempts); the Terminal(`usage`) edge (a re-consumed iterator); the Terminal(`invalid_continuation`) edge @@ -2275,7 +2275,8 @@ Terminal(`usage`) edge (a re-consumed iterator); the Terminal(`invalid_continuat `resume` URL is about to be followed — row 16's follow and row 17's Accept re-entry both pass through it; zero requests to the failing URL); the Terminal(`poll_failed`) edge (an `unrecoverable`-kind poll error — Seam Contracts below — from any polling state, carrying -the generated error); and the poll transient/throttle +the generated error); the Terminal(`mint_failed`) edge (an `unrecoverable`-kind mint +error, from Minting, carrying the generated error); and the poll transient/throttle **self-loop** inside CatchingUp (the `poll-retry` timer — a wait, not a state change). An `unauthorized`-kind poll error rides the reconnect cycle — full teardown → Backoff with a shared-counter increment (the fresh mint/token cycle is its recovery path) — and the @@ -2296,8 +2297,12 @@ Interpretation, pinned: `EVENT_FEED_AUTH_FAILURE_THRESHOLD = 3` the connector surfaces `authorization_failed` (a mixed sequence — mint 401, `unauthorized` disconnect, poll 401 — is terminal). A single blip must not kill a long-lived agent: a ticket that expired in the mint→dial - race is indistinguishable from revocation on one sample. The counter resets on - `confirm_subscription` **and on any successful poll page**, and only these three + race is indistinguishable from revocation on one sample. **The counter resets only on + a successful poll page** — never on `confirm_subscription`: a confirmed subscription + proves the ticket worked, not the bearer, and resetting there would let alternating + poll-401 → reconnect → confirm cycles hold the counter below threshold forever. A + connect-level blip still clears promptly — every confirmation is followed by the + catch-up poll, whose first successful page resets the counter. Only these three failure shapes ever increment it. - **Poll transients and throttles are never terminal.** They retry inside CatchingUp on the `poll-retry` timer, with one algorithm: a server-directed `Retry-After` is waited @@ -2427,7 +2432,8 @@ cursor resolves at the server's present head — the class, used by this name th this section and the state table: the zero Cursor (bare present entry); `since="now"`; a 410 reset's resume URL (the server documents it as `since=now` with the canonical filter set preserved); and a 400-position/409 re-entry that falls back to the present because no -accepted event id exists. Entries positioned in served history — `position=`, +poll-served id exists (the reset cursor is poll-lane-only — a live-delivered id never +positions a re-entry). Entries positioned in served history — `position=`, `since=`, `since=0` — are **position-resume class** and keep the unamended per-page save discipline. @@ -2551,6 +2557,7 @@ Terminal reasons end iteration with exactly one typed error element: | `feed_gap` | 410 with no registered handler, or a handler returning Terminate | | `invalid_continuation` | a `next` or 410 `resume` URL failed same-origin/downgrade validation (Continuation and Resume URL Validation below); no request is issued to it | | `poll_failed` | an `unrecoverable`-kind poll error — a generated-operation outcome outside the feed's 400/409/410 matrix and the retryable classes (e.g. 404, 405, an unexpected shape) — passed through with the generated error attached | +| `mint_failed` | an `unrecoverable`-kind mint error — a non-retryable `CreateStreamTicket` outcome other than 401/403 (e.g. 404, 422, a malformed success) — passed through with the generated error attached | `auth_revoked` is deliberately **reserved, not used**: the wire carries no distinct revocation signal — revocation is one possible cause of repeated unauthorized failures, and @@ -2583,7 +2590,11 @@ RECORD StreamTicket expires_in : Integer -- seconds (~120); server-owned, NEVER used for client scheduling url : String -- connect verbatim; never assemble cable topology client-side END --- Mint errors carry a kind: transient | throttled(retry_after) | unauthorized. +-- Mint errors carry a kind: transient | throttled(retry_after) | unauthorized | +-- unrecoverable(error). The adapter maps every §6/§7 outcome onto exactly one kind: +-- retryable outcomes exhausted inside the seam → transient/throttled; 401/403 → +-- unauthorized (shared counter); anything else non-retryable (404, 422, a malformed +-- success) → unrecoverable → Terminal(mint_failed), generated error attached. INTERFACE PollSource poll(cursor: Cursor, filters: Filters) → PollPage -- one fully-governed generated PollEvents call From 708f31276ca815da6bd5b876ea27e76041f5292f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 11:20:58 -0700 Subject: [PATCH 04/13] Address the #614 third round: dial lifecycle, state-generic terminals, governance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings. The handshake deadline arms on entry to Connecting, before dial, and CableTransport.dial must be cancellable — a stalled TCP connect or HTTP upgrade can no longer hang the connector or block close(). A dial refused by cable-URL policy (non-wss, redirect, unparseable) is the new Terminal(invalid_cable_url) rather than a Backoff loop that re-mints into the same unusable URL forever. The protocol-fatal disconnect is pinned terminal from every socket-open state (the pump runs through CatchingUp and Draining), not just the two numbered rows. Both jitter formulas inherit §7's saturate-before-exponentiating rule. The authorization_failed terminal row lists all three counter shapes. And AGENTS.md Hard Rule 2 now names the §23 cable dial as the second sanctioned wire exception, so the connector PRs don't land in conflict with repository governance. --- AGENTS.md | 2 +- SPEC.md | 32 +++++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a5e38903b..053c833258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ When verifying API response shapes, check Go generated code in `go/pkg/generated ### Never Do These 1. **NEVER edit files under `*/generated/`** — they get overwritten by generators. Four files under `generated/` are not generator-emitted and are the ONLY exceptions: three hand-written base files, edited like any other infrastructure file — `python/src/basecamp/generated/services/_base.py`, `python/src/basecamp/generated/services/_async_base.py`, `ruby/lib/basecamp/generated/services/base_service.rb` — plus the empty package marker `python/src/basecamp/generated/__init__.py`. The `generated/services/__init__.py` next to the Python base files IS generated. -2. **NEVER add hand-written service methods that touch the wire** — all wire operations come from generators. The sole exception is a conformance-tested composite that only calls generated wire methods and satisfies SPEC.md §18 "Hand-Written Composite Methods" +2. **NEVER add hand-written service methods that touch the wire** — all wire operations come from generators. Two sanctioned exceptions: a conformance-tested composite that only calls generated wire methods and satisfies SPEC.md §18 "Hand-Written Composite Methods"; and the SPEC.md §23 Event Feed connector's cable dial — `CableTransport.dial(mint.url)`, connecting verbatim to the URL a generated `CreateStreamTicket` call returned. That dial is the connector's one non-HTTP wire act; every HTTP exchange it makes still flows through generated operations behind its `TicketMinter`/`PollSource` seams (§23 "Classification: Infrastructure, Not a Composite") 3. **NEVER skip running `make smithy-build` after Smithy changes** — keeps OpenAPI in sync 4. **NEVER construct API paths manually** — use the generated client methods 5. **NEVER bypass the SDK** — no raw `client.Get()`, string-concatenated URLs, or internal method calls diff --git a/SPEC.md b/SPEC.md index a2c35a4d54..53f5e63f05 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2240,8 +2240,8 @@ typed error element is emitted), `Closed` (absorbing; no error element). | 3 | Minting | Connecting | ticket minted; dial the mint's `url` verbatim | | 4 | Minting | Backoff | mint transient/throttled, or an unauthorized mint (401/403) below the shared-counter threshold (Retry-After honored as the floor of the next delay) | | 5 | Minting | Terminal(`authorization_failed`) | 3rd consecutive connection-level authorization failure (shared counter across unauthorized mints, `unauthorized` disconnects, and unauthorized polls; resets only on a successful poll page) | -| 6 | Connecting | AwaitingWelcome | dial ok; frame pump started; `handshake-deadline` armed (10s) | -| 7 | Connecting | Backoff | dial failed | +| 6 | Connecting | AwaitingWelcome | dial ok; frame pump started (`handshake-deadline` was armed on entry to Connecting, before `dial` — a stalled dial expires it) | +| 7 | Connecting | Backoff | dial failed, or `handshake-deadline` expired mid-dial (the pending dial is cancelled). A dial refused by cable-URL policy is NOT this edge — it is Terminal(`invalid_cable_url`), below the table | | 8 | AwaitingWelcome | AwaitingConfirmation | `welcome` → send subscribe; re-arm as `confirmation-deadline` (10s) | | 9 | AwaitingWelcome | Backoff | socket error/close, staleness expiry, or handshake deadline lapsed (full teardown first) | | 10 | AwaitingWelcome | Terminal(`authorization_failed`) | 3rd consecutive connection-level authorization failure (reason-string dispatch on the literal `unauthorized`; shared counter with unauthorized mints) | @@ -2267,7 +2267,7 @@ Semantic-signal dispositions are an **overlay** on this table, not additional nu rows: a `FeedGap` signal arises at transition 17; a `BufferOverflow` signal arises whenever the live buffer drops events (any buffer-holding state) and is dispatched synchronously before the next Save; a Terminate disposition — or an absent handler — lands in the -corresponding Terminal state from wherever the signal was dispatched. Six further edges +corresponding Terminal state from wherever the signal was dispatched. Seven further edges sit outside the 26 numbered rows for the same reason: the Terminal(`checkpoint_load`) edge (fires on the first iteration, before the first mint — zero wire attempts); the Terminal(`usage`) edge (a re-consumed iterator); the Terminal(`invalid_continuation`) edge @@ -2276,7 +2276,9 @@ Terminal(`usage`) edge (a re-consumed iterator); the Terminal(`invalid_continuat pass through it; zero requests to the failing URL); the Terminal(`poll_failed`) edge (an `unrecoverable`-kind poll error — Seam Contracts below — from any polling state, carrying the generated error); the Terminal(`mint_failed`) edge (an `unrecoverable`-kind mint -error, from Minting, carrying the generated error); and the poll transient/throttle +error, from Minting, carrying the generated error); the Terminal(`invalid_cable_url`) +edge (a policy-refused dial, from Connecting — recurring by construction, so never the +Backoff path); and the poll transient/throttle **self-loop** inside CatchingUp (the `poll-retry` timer — a wait, not a state change). An `unauthorized`-kind poll error rides the reconnect cycle — full teardown → Backoff with a shared-counter increment (the fresh mint/token cycle is its recovery path) — and the @@ -2338,7 +2340,7 @@ Per-state invariants (asserted by the tier-2 harness as exact sets): | Idle | none | {} | no | no | | Backoff | closed | {`backoff`} | no | no | | Minting | none | {} | no | no | -| Connecting | opening | {} | no | no | +| Connecting | opening | {`handshake-deadline`} | no | no | | AwaitingWelcome | open | {`handshake-deadline`, `staleness`} | no | no | | AwaitingConfirmation | open | {`confirmation-deadline`, `staleness`} | **no** | no | | CatchingUp | open | {`staleness`} (+ `poll-retry` while a poll wait is pending) | poll pages only | after each page's delivery (held on present-class entry) | @@ -2387,6 +2389,12 @@ Two dispatch clarifications, pinned: "socket error/close" failure edge (9/15/21/25), whatever its `reconnect` flag says — and never increments the authorization counter; only the literal `unauthorized` does. Unknown reasons must not be guessed into either terminal class. +- **The protocol-fatal disconnect is terminal from EVERY socket-open state.** The + inventory numbers it where the connector is actively commanding (rows 13 and 26); a + raw `invalid_event_stream_command` frame read in AwaitingWelcome, CatchingUp, or + Draining — the pump runs throughout — is the same reason-level dispatch applied + state-generically: Terminal(`protocol_fatal`), never Backoff. An explicitly + non-retryable protocol rejection must not reconnect from any state. ### Cable Protocol Details `[conformance]` @@ -2550,7 +2558,7 @@ Terminal reasons end iteration with exactly one typed error element: | `subscription_rejected` | `reject_subscription` — always terminal, first attempt or reconnect; zero reconnect attempts | | `protocol_fatal` | raw disconnect `reason=invalid_event_stream_command` | | `filter_invalid` | 400-filter from a poll; the server's message naming the offending list is preserved | -| `authorization_failed` | 3rd consecutive connection-level authorization failure (unauthorized mint or `unauthorized` disconnect) | +| `authorization_failed` | 3rd consecutive connection-level authorization failure on the shared counter (unauthorized mint, `unauthorized` disconnect, or unauthorized poll) | | `checkpoint_load` | `CheckpointStore.load` failed at start | | `usage` | re-consumed iterator (the only `usage` condition that surfaces as an iteration element — construction-time validation failures raise language-native construction errors carrying the same code) | | `buffer_overflow` | live-buffer overflow with no registered handler, or a handler returning Terminate | @@ -2558,6 +2566,7 @@ Terminal reasons end iteration with exactly one typed error element: | `invalid_continuation` | a `next` or 410 `resume` URL failed same-origin/downgrade validation (Continuation and Resume URL Validation below); no request is issued to it | | `poll_failed` | an `unrecoverable`-kind poll error — a generated-operation outcome outside the feed's 400/409/410 matrix and the retryable classes (e.g. 404, 405, an unexpected shape) — passed through with the generated error attached | | `mint_failed` | an `unrecoverable`-kind mint error — a non-retryable `CreateStreamTicket` outcome other than 401/403 (e.g. 404, 422, a malformed success) — passed through with the generated error attached | +| `invalid_cable_url` | the mint's `url` violates cable-URL policy (non-`wss://` outside localhost, a redirect on dial, unparseable) — recurring by construction on every re-mint, so it is surfaced, never retried into | `auth_revoked` is deliberately **reserved, not used**: the wire carries no distinct revocation signal — revocation is one possible cause of repeated unauthorized failures, and @@ -2628,6 +2637,8 @@ INTERFACE CableTransport -- Dials exactly one connection per call. MUST NOT auto-reconnect. MUST NOT interpret, -- filter, or swallow application text frames. Negotiates subprotocol -- "actioncable-v1-json". Refuses redirects (§23 Security Invariants). + -- MUST be cancellable: the connector cancels a pending dial on handshake-deadline + -- expiry and on close() — a dial that cannot be interrupted violates this contract. END INTERFACE CableConn @@ -2709,7 +2720,7 @@ sleep may bypass it. There are exactly six timer kinds, kebab-case: | Kind | Armed | Duration | |---|---|---| -| `handshake-deadline` | on dial success (transition 6) | `EVENT_FEED_HANDSHAKE_DEADLINE` (10s) | +| `handshake-deadline` | on entry to Connecting, BEFORE `dial` is invoked — it spans dial-to-`welcome`, so a stalled TCP connect or HTTP upgrade cannot hang the connector | `EVENT_FEED_HANDSHAKE_DEADLINE` (10s) | | `confirmation-deadline` | on `welcome` → subscribe (transition 8) | `EVENT_FEED_CONFIRMATION_DEADLINE` (10s default, configurable) | | `backoff` | on entry to Backoff | full-jitter: `uniform(0, min(60s, 1s × 2^(n−1)))` for failed-cycle count n; Retry-After floors it, and wins outright when it exceeds the cap (server-directed waits are exempt, §7) | | `staleness` | at socket open; re-armed per inbound frame of any kind | 7500ms | @@ -2724,6 +2735,13 @@ governs attempts inside a seam call; this governs cycles between them. The same bounds `poll-retry`'s locally computed jitter draw; a server-directed `Retry-After` is exempt from both caps, per §7. +**Saturate before exponentiating** — §7's overflow rule applies to both formulas: an +implementation MUST compare the failure index (n or k) against the cap-crossing exponent +before evaluating `2^(n−1)`/`2^(k−1)` (for base 1s and cap 60s, any index above 7 already +saturates the range at 60s). Evaluating the power first overflows fixed-width integers +after a long genuine outage (~64 consecutive failures), producing exactly the tight-loop +or crash failure §7's saturation rules exist to prevent. + **Virtual-advance algorithm (normative).** Every language's test clock must honor the same semantics, so a tier-2 script means the same thing everywhere: *advancing virtual time fires due timers in deadline order, re-evaluating after each fire; timers scheduled during From 38f115041b8d8a8bf8cd3ae5182a347905f327ba Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 11:29:33 -0700 Subject: [PATCH 05/13] Address the #614 fourth round: bounded cut, cancellable typed dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownership cut becomes one bounded admission pass (non-blocking receives until momentarily-empty or liveBufferCapacity admissions, whichever first): the previous first-empty-receive definition never completes under sustained arrival at or above the admission rate, so the entry position could never save — and the capacity bound is finite without weakening the retained set, since further admissions would evict retained events anyway. The dial seam gains an explicit cancellation channel (language-native; triggered on handshake-deadline expiry and close(), with prompt return required) and a typed error taxonomy — transient versus policy(reason) — so a redirecting or otherwise policy-refused mint URL is dispatchable to Terminal(invalid_cable_url) instead of being indistinguishable from a transient dial failure and looping through Backoff forever. --- SPEC.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/SPEC.md b/SPEC.md index 53f5e63f05..faaf68522d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2449,12 +2449,18 @@ save discipline. position → take the **ownership cut** → fix the snapshot → drain-and-accept → only then `save`. -**The ownership cut, defined:** the cut is the state machine's first empty receive from the -frame pump's queue after accepting the entry-poll response — deliberately **not** a -drain-until-empty barrier, which races a concurrent sender and promises an unclosable -window. "Observed" means **admitted into the state-machine-owned buffer at or before the -cut**; a frame the transport had read but the state machine had not yet received at the cut -does not count. **The snapshot** is the pre-cut contents of the state-machine-owned buffer, +**The ownership cut, defined:** after accepting the entry-poll response, the state machine +performs **one bounded admission pass** — receiving from the frame pump's queue without +blocking until the queue is momentarily empty OR the pass has admitted +`EVENT_FEED_LIVE_BUFFER_CAPACITY` events, whichever comes first. The cut is the completion +of that pass. The pass is deliberately **not** a drain-until-empty barrier: unbounded +draining races a concurrent sender, and under sustained arrival at or above the admission +rate it never completes — the cut must be reachable in bounded time or the entry position +never saves. The capacity bound keeps the pass finite without weakening the retained set: +any admission beyond capacity would evict retained pre-acceptance events anyway. +"Observed" means **admitted into the state-machine-owned buffer at or before the cut**; a +frame the transport had read but the state machine had not yet admitted at the cut does +not count. **The snapshot** is the pre-cut contents of the state-machine-owned buffer, fixed at the cut; "pre-cut events" and "post-snapshot stragglers" below are defined against it. @@ -2633,12 +2639,21 @@ END -- unexpected shapes) → unrecoverable, carrying the generated error verbatim. INTERFACE CableTransport - dial(ws_url) → CableConn + dial(ws_url, cancellation) → CableConn -- Dials exactly one connection per call. MUST NOT auto-reconnect. MUST NOT interpret, -- filter, or swallow application text frames. Negotiates subprotocol -- "actioncable-v1-json". Refuses redirects (§23 Security Invariants). - -- MUST be cancellable: the connector cancels a pending dial on handshake-deadline - -- expiry and on close() — a dial that cannot be interrupted violates this contract. + -- `cancellation` is the language-native cancellation channel (Go context, TS + -- AbortSignal, Kotlin coroutine cancellation, Swift task cancellation, a Python/Ruby + -- cancel token): the connector triggers it on handshake-deadline expiry and on + -- close(), and a triggered dial MUST return promptly — a dial that cannot be + -- interrupted violates this contract. + -- Dial errors carry a kind: transient | policy(reason). `policy` is a PERMANENT + -- refusal the transport detected (a redirect encountered; a scheme the invariants + -- forbid; an unparseable URL) → Terminal(`invalid_cable_url`), never Backoff — a + -- fresh mint returns the same unusable URL. Everything else is `transient` → + -- transition 7. The connector performs the scheme/parse checks it can before dialing; + -- only the transport can see a redirect. END INTERFACE CableConn From 41fedf6ce5a942b0937861786afac17ea9ff4d5c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 11:36:18 -0700 Subject: [PATCH 06/13] Address the #614 fifth round: dequeued-frames bound, seam cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admission pass's bound now counts dequeued frames of any kind rather than admitted events — pings, control frames, and unknown types dequeue without admitting, so an event-counting bound lets a heartbeat-replenished queue spin the pass forever; admitted events never exceed dequeued frames, so the retained-set argument is unchanged. And both HTTP seams gain the same explicit cancellation channel the dial seam carries: close() and caller cancellation trigger it, a triggered mint or poll returns promptly, and the universal edge to Closed no longer waits out a stalled request. --- SPEC.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index faaf68522d..cfb762545a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2451,13 +2451,17 @@ position → take the **ownership cut** → fix the snapshot → drain-and-accep **The ownership cut, defined:** after accepting the entry-poll response, the state machine performs **one bounded admission pass** — receiving from the frame pump's queue without -blocking until the queue is momentarily empty OR the pass has admitted -`EVENT_FEED_LIVE_BUFFER_CAPACITY` events, whichever comes first. The cut is the completion -of that pass. The pass is deliberately **not** a drain-until-empty barrier: unbounded -draining races a concurrent sender, and under sustained arrival at or above the admission +blocking until the queue is momentarily empty OR the pass has **dequeued** +`EVENT_FEED_LIVE_BUFFER_CAPACITY` frames of any kind, whichever comes first. The cut is +the completion of that pass. The bound counts dequeued frames, not admitted events: pings, +control frames, and unknown types are dequeued without being admitted, and an +event-counting bound would let a heartbeat-replenished queue spin the pass forever. The +pass is deliberately **not** a drain-until-empty barrier: unbounded +draining races a concurrent sender, and under sustained arrival at or above the dequeue rate it never completes — the cut must be reachable in bounded time or the entry position -never saves. The capacity bound keeps the pass finite without weakening the retained set: -any admission beyond capacity would evict retained pre-acceptance events anyway. +never saves. The frame bound keeps the pass finite without weakening the retained set: +admitted events never exceed dequeued frames, and any admission beyond capacity would +evict retained pre-acceptance events anyway. "Observed" means **admitted into the state-machine-owned buffer at or before the cut**; a frame the transport had read but the state machine had not yet admitted at the cut does not count. **The snapshot** is the pre-cut contents of the state-machine-owned buffer, @@ -2597,7 +2601,11 @@ is deterministic. ``` INTERFACE TicketMinter - mint_stream_ticket() → StreamTicket -- one fully-governed generated CreateStreamTicket call + mint_stream_ticket(cancellation) → StreamTicket + -- One fully-governed generated CreateStreamTicket call. `cancellation` is the same + -- language-native channel the dial seam takes (Go context, TS AbortSignal, …): the + -- connector triggers it on close() and caller cancellation, and a triggered call MUST + -- return promptly — the universal edge to Closed cannot wait out a stalled request. END RECORD StreamTicket @@ -2612,7 +2620,9 @@ END -- success) → unrecoverable → Terminal(mint_failed), generated error attached. INTERFACE PollSource - poll(cursor: Cursor, filters: Filters) → PollPage -- one fully-governed generated PollEvents call + poll(cursor: Cursor, filters: Filters, cancellation) → PollPage + -- One fully-governed generated PollEvents call; `cancellation` as on TicketMinter — + -- triggered on close()/caller cancellation, prompt return required. END RECORD Cursor -- exactly one field set; the zero Cursor is the bare present entry From d74306190500bc8e78129e9798a92b0308d79119 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 11:45:18 -0700 Subject: [PATCH 07/13] Address the #614 sixth round: frame-violation disposition, byte-correct bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unparseable or oversized inbound frame is now a defined peer protocol violation dispatched as a socket failure — teardown through the current state's socket-failure edge into the reconnect cycle, observably, never terminal (a garbled frame is transport corruption, not the server's own protocol verdict) and never silently ignored. And the memory bound is stated multiplicatively — (pump depth + live-buffer capacity) x max frame bytes — instead of adding counts to bytes; an optional total byte cap must evict through the overflow signal. --- SPEC.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index cfb762545a..5c5ffd6778 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2409,7 +2409,16 @@ Two dispatch clarifications, pinned: equality against the connector's identifier; frames carrying other identifiers are ignored. - Ping parsing accepts both `{"type":"ping"}` and `{"type":"ping","message":}`. - Unknown frame types update liveness and are otherwise ignored. + Unknown frame types — parseable JSON whose `type` the connector doesn't recognize — + update liveness and are otherwise ignored. +- **An unparseable or oversized inbound frame is a peer protocol violation, dispatched as + a socket failure** `[conformance]`: a frame that fails to parse as JSON, or exceeds + `EVENT_FEED_MAX_FRAME_BYTES`, triggers full teardown through the current state's + socket-failure edge (9/15/21/25 → Backoff; the reconnect cycle recovers), with + `Observer.disconnected` carrying an unparseable-frame indication. Never terminal — a + garbled frame is transport-level corruption, unlike the server's own + `invalid_event_stream_command` verdict — and never silently ignored: continuing to read + a stream that has stopped parsing invites silent divergence. - **Every** inbound frame, of any kind, resets the `staleness` timer (`EVENT_FEED_STALE_AFTER = 7500ms`: two missed 3-second server heartbeats plus 25% grace — the server contract leaves detection policy to the SDK; the SDK pins and publishes this @@ -2419,9 +2428,14 @@ Two dispatch clarifications, pinned: (implementation-chosen; the Go reference uses 256). At capacity the pump **blocks** — back-pressure propagates to the socket and TCP — rather than dropping: the state-machine-owned live buffer is the only place a frame can ever be dropped, and its - overflow signal is the only drop signal. Connector memory is therefore bounded by pump - depth + `EVENT_FEED_LIVE_BUFFER_CAPACITY` + `EVENT_FEED_MAX_FRAME_BYTES`, even under a - slow consumer. + overflow signal is the only drop signal. Worst-case connector memory is therefore + bounded multiplicatively — every queued or buffered item is itself bounded by + `EVENT_FEED_MAX_FRAME_BYTES`, so the ceiling is + (pump depth + `EVENT_FEED_LIVE_BUFFER_CAPACITY`) × `EVENT_FEED_MAX_FRAME_BYTES` + (≈ 10 GiB at the defaults' extreme, reached only if every slot holds a maximum-size + frame) — even under a slow consumer. Implementations MAY additionally impose a total + byte cap on the live buffer; if they do, eviction routes through the same overflow + signal, never a silent drop. - The transport negotiates subprotocol `actioncable-v1-json`, sends no `Origin` header (non-browser clients), and passes the mint URL through untouched, query string included. From ba7dee5fb2d47075a33507d29fbcf80a75e8d25e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 11:58:03 -0700 Subject: [PATCH 08/13] Address the #614 seventh round: total srv1, in-read frame cap, architecture line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit srv1 is total over client-validated inputs: catalog membership is server-owned and deliberately not client-validated, so an uncataloged type forms a well-defined checkpoint key, load() runs, and the first poll's filter 400 terminates the lineage — the key must exist before the server can answer. The frame-size cap now binds inside the transport read (a max_frame_bytes dial parameter; over-limit messages are rejected without materialization, surfacing as the frame-violation socket failure), so the security cap actually bounds the WebSocket stack's allocation. And AGENTS.md's opening architecture statement names the cable-dial exception alongside Hard Rule 2, ending the internal contradiction. --- AGENTS.md | 4 +++- SPEC.md | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 053c833258..813146471c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,9 @@ All six SDKs (Go, TypeScript, Ruby, Swift, Kotlin, Python) share one architecture: **Smithy spec -> OpenAPI -> generated services.** Every wire operation is generated. The only hand-written runtime API methods are sanctioned composites calling generated wire -methods exclusively -- see SPEC.md §18 "Hand-Written Composite Methods". +methods exclusively -- see SPEC.md §18 "Hand-Written Composite Methods" -- plus one +sanctioned non-HTTP wire act: the SPEC.md §23 Event Feed connector's cable dial of the +URL a generated `CreateStreamTicket` call returned (Hard Rule 2 below). --- diff --git a/SPEC.md b/SPEC.md index 5c5ffd6778..10f496596b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2415,7 +2415,9 @@ Two dispatch clarifications, pinned: a socket failure** `[conformance]`: a frame that fails to parse as JSON, or exceeds `EVENT_FEED_MAX_FRAME_BYTES`, triggers full teardown through the current state's socket-failure edge (9/15/21/25 → Backoff; the reconnect cycle recovers), with - `Observer.disconnected` carrying an unparseable-frame indication. Never terminal — a + `Observer.disconnected` carrying an unparseable-frame indication. The size check binds + inside the transport (the `max_frame_bytes` dial parameter — an over-limit message is + rejected during the read, never materialized); the parse check is the connector's. Never terminal — a garbled frame is transport-level corruption, unlike the server's own `invalid_event_stream_command` verdict — and never silently ignored: continuing to read a stream that has stopped parsing invites silent divergence. @@ -2663,7 +2665,7 @@ END -- unexpected shapes) → unrecoverable, carrying the generated error verbatim. INTERFACE CableTransport - dial(ws_url, cancellation) → CableConn + dial(ws_url, cancellation, max_frame_bytes) → CableConn -- Dials exactly one connection per call. MUST NOT auto-reconnect. MUST NOT interpret, -- filter, or swallow application text frames. Negotiates subprotocol -- "actioncable-v1-json". Refuses redirects (§23 Security Invariants). @@ -2672,6 +2674,12 @@ INTERFACE CableTransport -- cancel token): the connector triggers it on handshake-deadline expiry and on -- close(), and a triggered dial MUST return promptly — a dial that cannot be -- interrupted violates this contract. + -- `max_frame_bytes`: the transport MUST enforce this limit WHILE reading (e.g. a + -- read-limit on the socket), rejecting an over-limit message without materializing it + -- — the security cap has to bind inside the WebSocket stack, or the allocation happens + -- before the connector can measure anything. The rejection surfaces from read_frame as + -- an error and takes the frame-violation socket-failure dispatch (Cable Protocol + -- Details). -- Dial errors carry a kind: transient | policy(reason). `policy` is a PERMANENT -- refusal the transport detected (a redirect encountered; a scheme the invariants -- forbid; an unparseable URL) → Terminal(`invalid_cable_url`), never Backoff — a @@ -2838,8 +2846,15 @@ canonical_json = "[" T "," B "," C "]" -- compact: no whitespace anywhere The canonical bytes are hand-built (string builder plus a minimal escape helper) — no language's default JSON emitter is load-bearing, because several HTML-escape by default. -srv1's domain is the cataloged ASCII type strings and integer ids; unknown types draw the -filter 400 before any digest is computed, so no quoted-string or non-ASCII vector exists. +**The algorithm is total over every client-validated input, and the SDK computes it for +any filter set that passes construction validation** — catalog membership is deliberately +NOT client-validated (the catalog is server-owned and grows), and the checkpoint key must +form before the first poll can answer. A syntactically valid but uncataloged type +therefore gets a well-defined `filter_key` and its `load` runs normally; the first poll +then draws the server's filter 400 (Terminal(`filter_invalid`)) and that lineage simply +never advances — harmless. The *server-side* srv1 domain is the cataloged ASCII type +strings and integer ids: the server rejects unknown types with the filter 400 before +computing any digest, which is why bc3 publishes no quoted-string or non-ASCII vectors. Published srv1 vectors (provisional until the merge-time gate; the conformance vector source): From eb55717c9818d1bfc2e8a8b0f446878d17024c57 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 12:10:04 -0700 Subject: [PATCH 09/13] Address the #614 eighth round: pump-side reset, Draining carve-out, 1A.6 rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staleness reset moves pump-side, to frame receipt, so frame-vs-deadline ordering is defined at the transport boundary — a fired deadline observed after a slow consumer delivery is authoritative, and queued frames already reset the timer when they arrived. The protocol-fatal disconnect is carved out of Draining's deferred-consumption rule: immediate terminal, drain abandoned, held entry position not saved. And the cable-dial carve-out reaches the two remaining compliance summaries (§20 row 9, Appendix C 1A.6) plus §18's opening sentence, so no layer of the spec still says composites are the only hand-written exception. --- SPEC.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 10f496596b..ba09719dd3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1784,7 +1784,7 @@ The generated service method must pass its operation name to the HTTP transport ### Hand-Written Composite Methods `[manual]` -All wire operations are generated (rubric 1A.6). One narrow exception is sanctioned: a hand-written **composite convenience method** may be added on top of a generated service when the generator cannot express a safety-critical surface. A composite is permitted only when all of the following hold: +All wire operations are generated (rubric 1A.6). One narrow exception is sanctioned here: a hand-written **composite convenience method** may be added on top of a generated service when the generator cannot express a safety-critical surface. (A second, non-HTTP carve-out lives in §23: the Event Feed connector's cable dial of the URL a generated `CreateStreamTicket` call returned — see §23 "Classification: Infrastructure, Not a Composite".) A composite is permitted only when all of the following hold: 1. **No hand-written wire I/O.** Every request flows through public generated wire methods (Go: through the shared generated-client transport). No manual path construction or verb selection. Bodies use the generated request types, with one Go-specific carve-out: where zero-value + `omitempty` request structs cannot express always-send-empty semantics, the composite's private transport MAY marshal an explicit body map and call the operation's generated `*WithBody` variant — the generated wrapper still owns path, verb, content type, and response decoding, and the operation identity still reaches hooks and retry. This is the only sanctioned use of hand-marshaled bodies; sparse public methods keep using the generated request types. 2. **Composition, not substitution.** It composes existing generated operations (e.g. GET → overlay → full PUT); it never introduces a wire operation the spec lacks — fix the spec and regenerate instead. @@ -1997,7 +1997,7 @@ The following are must-pass criteria from the rubric. Each maps to a spec sectio | 6 | 2C.5 | Cross-origin pagination Link header rejected | §8 | `[conformance]` | | 7 | 3C.1 | HTTPS enforcement for non-localhost | §9 | `[conformance]` | | 8 | 1C.3 | No manual path construction | §3, §18 | `[manual]` | -| 9 | 1A.6 | No hand-written wire methods (multi-language only; Go uses hand-written service wrappers around generated client — see Appendix F). Conformance-tested composites over generated operations are permitted per §18 "Hand-Written Composite Methods" | §18 | `[manual]` | +| 9 | 1A.6 | No hand-written wire methods (multi-language only; Go uses hand-written service wrappers around generated client — see Appendix F). Conformance-tested composites over generated operations are permitted per §18 "Hand-Written Composite Methods", and the §23 Event Feed connector's cable dial (verbatim, of a generated mint's URL) is the one sanctioned non-HTTP wire act | §18, §23 | `[manual]` | | 10 | 4A.1 | Smithy → OpenAPI freshness check | §21 | `[static]` | --- @@ -2353,7 +2353,11 @@ it has no failure edge of its own: a staleness expiry or socket failure observed draining is consumed at the Streaming boundary (transition 23 completes the drain, then transition 25 handles the failure). This is the one deliberate deferred-consumption case; everywhere else a socket-open state's failure edge (9/15/21/25) fires directly, and -staleness expiry is among each of those edges' triggers. +staleness expiry is among each of those edges' triggers. **The protocol-fatal disconnect +is carved out of this deferral**: a raw `invalid_event_stream_command` observed during +Draining is Terminal(`protocol_fatal`) immediately (the state-generic rule under +Disconnect Dispatch) — the drain is not completed, the held entry position is NOT saved, +and no `caught_up` is announced; only recoverable failures defer. ### Disconnect Dispatch `[conformance]` @@ -2424,7 +2428,11 @@ Two dispatch clarifications, pinned: - **Every** inbound frame, of any kind, resets the `staleness` timer (`EVENT_FEED_STALE_AFTER = 7500ms`: two missed 3-second server heartbeats plus 25% grace — the server contract leaves detection policy to the SDK; the SDK pins and publishes this - one). + one). **The reset happens pump-side, at frame receipt** — not at state-machine dequeue — + so frame-vs-deadline ordering is well-defined at the transport boundary regardless of + queue depth or consumer latency: a fired staleness deadline observed on return from a + slow delivery is authoritative (no frame arrived within the window), and frames still + queued at that moment were received before the firing and already reset the timer then. - **The frame pump's hand-off queue is bounded and never drops.** The pump reads frames from the transport and hands them to the state machine over a queue of small fixed depth (implementation-chosen; the Go reference uses 256). At capacity the pump **blocks** — @@ -3020,7 +3028,7 @@ account, attachments, automation, boosts, campfires, cardColumns, cardSteps, car |-----------|-------------|---------| | 1A.1 | §18, §21 | Smithy model validates | | 1A.2 | §18, §21 | OpenAPI derived from Smithy | -| 1A.6 | §18 | No hand-written wire methods; conformance-tested composites permitted | +| 1A.6 | §18, §23 | No hand-written wire methods; conformance-tested composites permitted; the §23 cable dial is the one sanctioned non-HTTP wire act | | 1B.2 | §18 | Types generated from OpenAPI schema | | 1B.4 | §10 | Optional fields use language optionals | | 1B.5 | §10 | Date fields use ISO 8601 / native types | From 8a298ee4cf033b0adf294a658ba8742b346cbc04 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 12:22:17 -0700 Subject: [PATCH 10/13] Address the #614 ninth round: staleness suspension, invalid-frame closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staleness is suspended while the pump is blocked on a full hand-off queue — a full queue proves the peer was outrunning the consumer, the opposite of a dead socket, and a pump that is not reading cannot observe resets, so the timer re-arms fresh when reading resumes and a healthy fast peer is never reconnected away under a slow consumer. The invalid-frame class closes over its third shape — a correlated message whose payload fails to decode as an Event — with the same socket-failure disposition (no escaping decoder errors, no silent skips), and gains an explicit Draining disposition: it defers like other recoverable socket failures, since the already-admitted retained set is unimplicated; only the server's own protocol-fatal verdict terminates mid-drain. --- SPEC.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index ba09719dd3..cff5a7507e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2415,13 +2415,22 @@ Two dispatch clarifications, pinned: - Ping parsing accepts both `{"type":"ping"}` and `{"type":"ping","message":}`. Unknown frame types — parseable JSON whose `type` the connector doesn't recognize — update liveness and are otherwise ignored. -- **An unparseable or oversized inbound frame is a peer protocol violation, dispatched as - a socket failure** `[conformance]`: a frame that fails to parse as JSON, or exceeds - `EVENT_FEED_MAX_FRAME_BYTES`, triggers full teardown through the current state's - socket-failure edge (9/15/21/25 → Backoff; the reconnect cycle recovers), with - `Observer.disconnected` carrying an unparseable-frame indication. The size check binds - inside the transport (the `max_frame_bytes` dial parameter — an over-limit message is - rejected during the read, never materialized); the parse check is the connector's. Never terminal — a +- **An invalid inbound frame is a peer protocol violation, dispatched as a socket + failure** `[conformance]` — three shapes, one disposition: a frame that fails to parse + as JSON; a frame exceeding `EVENT_FEED_MAX_FRAME_BYTES`; and a correlated `message` + frame whose payload fails to decode as an Event (a missing required key, a wrong-typed + id). Each triggers full teardown through the current state's socket-failure edge + (9/15/21/25 → Backoff; the reconnect cycle recovers — the fresh walk repairs anything + the discarded stream carried), with `Observer.disconnected` carrying an invalid-frame + indication; never an untyped decoder error escaping, never a silent skip. Never + terminal — a garbled frame is transport-level corruption, unlike the server's own + `invalid_event_stream_command` verdict. The size check binds inside the transport (the + `max_frame_bytes` dial parameter — an over-limit message is rejected during the read, + never materialized); the parse and decode checks are the connector's. **In Draining, + this class defers like any other recoverable socket failure** (the deferred-consumption + rule): the already-admitted retained set is intact and unimplicated, so the drain + completes and transition 25 handles the failure — only the protocol-fatal disconnect, + where the server itself declares the session dead, terminates mid-drain. Never terminal — a garbled frame is transport-level corruption, unlike the server's own `invalid_event_stream_command` verdict — and never silently ignored: continuing to read a stream that has stopped parsing invites silent divergence. @@ -2431,8 +2440,14 @@ Two dispatch clarifications, pinned: one). **The reset happens pump-side, at frame receipt** — not at state-machine dequeue — so frame-vs-deadline ordering is well-defined at the transport boundary regardless of queue depth or consumer latency: a fired staleness deadline observed on return from a - slow delivery is authoritative (no frame arrived within the window), and frames still + slow delivery is authoritative, and frames still queued at that moment were received before the firing and already reset the timer then. + **Staleness is suspended while the pump is blocked on a full hand-off queue**: a full + queue proves the peer was sending faster than the connector consumed — the opposite of + a dead peer — and a pump that isn't reading cannot observe resets, so absence of a + reset is not evidence. The timer re-arms fresh when the pump resumes reading; + "authoritative" above therefore holds exactly when the pump was reading throughout the + window. - **The frame pump's hand-off queue is bounded and never drops.** The pump reads frames from the transport and hands them to the state machine over a queue of small fixed depth (implementation-chosen; the Go reference uses 256). At capacity the pump **blocks** — From 654959ebcd3f8e932fe5a6ea5c2c4080b8c5dfe9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 12:32:46 -0700 Subject: [PATCH 11/13] Address the #614 tenth round: evaluation-rule suspension, TS read-limit note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staleness suspension is realized at evaluation, not arming: the timer stays armed in every socket-open state (the exact-set invariants are unchanged), and a firing whose window overlapped a pump-blocked interval is disregarded and re-armed rather than dispatched. Appendix F records the TypeScript lane's honest divergence: the global WebSocket exposes no read limit, so the frame cap is enforced at message receipt — before any parse, decode, or queueing, though after materialization — with true bounded reads available via an injected transport. --- SPEC.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/SPEC.md b/SPEC.md index cff5a7507e..c434c15746 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2442,12 +2442,14 @@ Two dispatch clarifications, pinned: queue depth or consumer latency: a fired staleness deadline observed on return from a slow delivery is authoritative, and frames still queued at that moment were received before the firing and already reset the timer then. - **Staleness is suspended while the pump is blocked on a full hand-off queue**: a full - queue proves the peer was sending faster than the connector consumed — the opposite of - a dead peer — and a pump that isn't reading cannot observe resets, so absence of a - reset is not evidence. The timer re-arms fresh when the pump resumes reading; - "authoritative" above therefore holds exactly when the pump was reading throughout the - window. + **Staleness is suspended while the pump is blocked on a full hand-off queue** — a full + queue proves the peer was sending faster than the connector consumed, the opposite of + a dead peer, and a pump that isn't reading cannot observe resets, so absence of a + reset is not evidence. Suspension is realized **at evaluation, not arming**: the timer + stays armed throughout (the per-state exact-set invariants are unchanged — `staleness` + remains in every socket-open state's set), and a firing whose window overlapped a + pump-blocked interval is disregarded and re-armed rather than dispatched. A firing + whose window the pump spent reading is authoritative. - **The frame pump's hand-off queue is bounded and never drops.** The pump reads frames from the transport and hands them to the state machine over a queue of small fixed depth (implementation-chosen; the Go reference uses 256). At capacity the pump **blocks** — @@ -3290,7 +3292,7 @@ by named tier-3 tests. | SDK | Tier-2 scenario lane | Compensation / note | |-----|----------------------|---------------------| | Go | Real default transport against an in-process loopback ws server; fixtures consumed as data | Full-jitter formula additionally pinned exactly via an injected deterministic rand source (tier 3) | -| TypeScript | Real transport under MSW `ws` interception | Default transport must use the global `WebSocket` (Node ≥ 22), never the `ws` package | +| TypeScript | Real transport under MSW `ws` interception | Default transport must use the global `WebSocket` (Node ≥ 22), never the `ws` package. The global API exposes no read limit, so the `max_frame_bytes` cap is enforced at message receipt — before any parse, decode, or queueing — rather than during the read: the allocation itself cannot be pre-bounded on this lane (accepted, documented divergence); an injected `transport` MAY provide true bounded reads | | Python | Real transport over an in-process `websockets` loopback | Connector transport ships under an optional `stream` extra | | Ruby | Real transport over a `websocket-driver` loopback | `websocket-driver` becomes a runtime dependency | | Kotlin | jvmTest-only: real ktor ws client against a test-scoped server (`MockEngine` cannot mock WebSockets) | State machine mirrored in commonTest tier 3 for the four acceptance scenarios | From db2ef02d548b8ec55d5f3d2d42aaeec5b91797a2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 12:42:06 -0700 Subject: [PATCH 12/13] Address the #614 eleventh round: teardown cancels in-flight seam calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam cancellation channel now also triggers on any teardown of the attempt a call belongs to — mid-walk socket failure, staleness, a terminal — not only close() and caller cancellation, and the teardown-discipline paragraph names the step: a stalled poll must not delay the reconnect cycle or return into a superseded attempt. --- SPEC.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index c434c15746..5693d0b636 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2645,7 +2645,8 @@ INTERFACE TicketMinter mint_stream_ticket(cancellation) → StreamTicket -- One fully-governed generated CreateStreamTicket call. `cancellation` is the same -- language-native channel the dial seam takes (Go context, TS AbortSignal, …): the - -- connector triggers it on close() and caller cancellation, and a triggered call MUST + -- connector triggers it on close(), caller cancellation, and any teardown of the + -- attempt the call belongs to, and a triggered call MUST -- return promptly — the universal edge to Closed cannot wait out a stalled request. END @@ -2663,7 +2664,9 @@ END INTERFACE PollSource poll(cursor: Cursor, filters: Filters, cancellation) → PollPage -- One fully-governed generated PollEvents call; `cancellation` as on TicketMinter — - -- triggered on close()/caller cancellation, prompt return required. + -- triggered on close(), caller cancellation, AND any teardown of the attempt the call + -- belongs to (mid-walk socket failure, staleness, a terminal): a superseded poll must + -- not stall reconnection or return into a disposed attempt. Prompt return required. END RECORD Cursor -- exactly one field set; the zero Cursor is the bare present entry @@ -2825,7 +2828,9 @@ semantics checklist (deadline order, reentrant scheduling within an advance, cre tie-break) before its tier-2 results count. Teardown discipline: disposing a connection attempt — deadline lapse, staleness, socket -death, terminal — cancels the frame pump, closes the connection, and stops **all** of that +death, terminal — cancels the frame pump, **cancels any in-flight seam call belonging to +the attempt** (a stalled poll must not delay the reconnect cycle or return into a +superseded attempt), closes the connection, and stops **all** of that attempt's timers before the next state is entered. After a confirmation-deadline teardown, the exact outstanding-timer set is `{backoff}`; after a terminal, it is `{}`. Exact-set timer assertions are what make a leaked deadline timer, ghost watchdog, or duplicated From 7fb45af7dac651b42d508b8cad7ec950b6582d9b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 3 Aug 2026 12:52:28 -0700 Subject: [PATCH 13/13] Address the #614 twelfth round: Clock scope, interruptible writes, capacities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The every-delay-through-Clock rule is scoped to the connector's own delays — a seam call's internal §7 retry backoff is the generated operation's native machinery, which is why conformance counts seam calls rather than wire attempts. close() and cancellation now unblock an in-progress write_frame, with write failures taking the current state's socket-failure path. And the capacity options are construction-validated positive: a zero dedupe capacity would silently break the deduplicated-surface promise, so there is no dedupe-disabled mode. --- SPEC.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5693d0b636..662086e309 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2218,7 +2218,10 @@ All id fields carry §10's 64-bit integer contract; no new type spelling is intr it. Filter validation is client-side and fail-closed `[conformance]`: ids must be positive; type -strings non-empty with no commas, whitespace, or quotes; each id list capped at 100. A +strings non-empty with no commas, whitespace, or quotes; each id list capped at 100. The +capacity options are validated the same way: `dedupeCapacity` and `liveBufferCapacity` +must be positive (there is no dedupe-disabled mode — a zero capacity would silently break +the deduplicated-surface promise). A violation is a `usage`-coded construction error (Consumer Surface above) — zero wire attempts. Positions are filter-bound; changing filters starts a new checkpoint lineage (the server enforces this with 409). @@ -2722,8 +2725,10 @@ INTERFACE CableConn -- distinction lives only in this raw frame. Byte-level -- representation is language-native (Go []byte); verbatim-ness -- is the contract. Peer close surfaces as CloseError{code, reason}. - write_frame(String) - close(code, reason) -- idempotent, safe from any context, unblocks read_frame + write_frame(String) -- close()/cancellation MUST unblock an in-progress write; a + -- write failure takes the current state's socket-failure path + close(code, reason) -- idempotent, safe from any context, unblocks read_frame AND + -- write_frame END INTERFACE Clock @@ -2790,8 +2795,11 @@ origin. ### Clock, Timers, and Virtual Time `[conformance]` -**Every delay the connector takes flows through the injected Clock** — no native timer or -sleep may bypass it. There are exactly six timer kinds, kebab-case: +**Every delay the connector itself takes flows through the injected Clock** — no native +timer or sleep may bypass it. Delays *inside* a seam call are outside this rule: a +generated operation's §7 retry backoff is the operation's own machinery (native, as +shipped), which is exactly why conformance counts seam calls rather than wire attempts — +scenarios never depend on advancing seam-internal time. There are exactly six timer kinds, kebab-case: | Kind | Armed | Duration | |---|---|---|