Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
430f4fc
Event feed connector: the run loop, catch-up, recovery, and the tier-…
jeremy Aug 18, 2026
9a8c96b
Event feed: a poll page carrying no position is malformed
jeremy Aug 18, 2026
ad1b3c8
Event feed: staleness arms before Connected, and observers see origin…
jeremy Aug 18, 2026
37e5598
Event feed: order the one durable effect against Close, and narrow th…
jeremy Aug 18, 2026
f3a0425
Event feed: an occupied deferral slot no longer blinds the drain's fa…
jeremy Aug 18, 2026
2a257d9
Event feed: close four semantic gaps daybreak found in B2-B5
jeremy Aug 18, 2026
ba82047
Event feed: three docs that outran the code they describe
jeremy Aug 19, 2026
ec5f2ea
Event feed: a wrapped sentinel is not a sentinel to log
jeremy Aug 19, 2026
929860c
Event feed: Dial's error does not reach the callback its contract named
jeremy Aug 19, 2026
ab0e33c
Event feed: the tier-2 driver could not fail the terminal contract it…
jeremy Aug 19, 2026
7380f3f
Event feed: two of the "connector's own" errors are not the connector…
jeremy Aug 19, 2026
b705ac1
Event feed: the fatal verdict was only probed for where the drain looks
jeremy Aug 19, 2026
f925a4b
Event feed: the gate paid a real cost for a partial version of a free…
jeremy Aug 19, 2026
484ed58
Event feed: the consumer surface gained a method, so §23 gained its c…
jeremy Aug 19, 2026
187b58c
Conformance: the driver contract claims a strictness the Go driver do…
jeremy Aug 19, 2026
9e24666
Event feed: three leaks and a race the last pass reasoned past
jeremy Aug 19, 2026
ea4672e
Event feed: the save's context is contract, not an implementation detail
jeremy Aug 19, 2026
d3a30f9
Event feed: a sampled flag cannot make a happens-before, so the bound…
jeremy Aug 19, 2026
82c6e3e
Event feed: the driver was order-strict where the family rule says ar…
jeremy Aug 20, 2026
274b40f
Event feed: a socket with nothing to say was the one thing the poll w…
jeremy Aug 20, 2026
bc26ba9
Event feed: two of Codex's four, and an honest account of a third
jeremy Aug 20, 2026
dab67a8
Event feed: cancellation could not reach the one exit, and the origin…
jeremy Aug 22, 2026
383fc3d
Event feed: the oversize verdict survived the seam but not the sanitizer
jeremy Aug 22, 2026
281eea9
Event feed: wakes are not verdicts, and three more places the order w…
jeremy Aug 22, 2026
50aa346
Event feed: verdicts that lost coin flips, and a driver blind to eras
jeremy Aug 22, 2026
c86a042
Event feed: the parser cannot say zero, and my throttled fix forgot t…
jeremy Aug 22, 2026
222beed
Event feed: the teardown's courtesy call, and three more places Close…
jeremy Aug 22, 2026
cdb7966
Event feed: a panic strands nothing, and Close wins the last both-rea…
jeremy Aug 22, 2026
4c2f7d9
Event feed: every deferral gets the latch, not just the one that fired
jeremy Aug 24, 2026
97c6005
Event feed: the ring the buffer needed, the parser parity it claimed,…
jeremy Aug 24, 2026
cf47163
Event feed: the wedge's hand-off signal aliased the fatal's
jeremy Aug 25, 2026
4e643ad
Event feed: the phase timer a panic left behind, and three more verdi…
jeremy Aug 25, 2026
1d87d28
Invert the two continuation-terminal pins to the value-free contract
jeremy Aug 25, 2026
2f7c260
Event feed: the redirect terminal renders a phrase, never the peer's …
jeremy Aug 25, 2026
40a7d4f
Annotate the attempt's cancel for gosec G118
jeremy Sep 10, 2026
f3e64f4
State the Clock seam's promptness obligation on NewTimer
jeremy Sep 10, 2026
300b974
Event feed: the ring grows only while it is contiguous
jeremy Sep 10, 2026
1987fc4
Event feed: Close outranks the admission, wherever the frame was rece…
jeremy Sep 10, 2026
c26f490
Event feed: a verdict already in outranks the act that would hide it
jeremy Sep 10, 2026
be6e18e
Event feed: a misbehaving seam fails closed instead of crashing or le…
jeremy Sep 10, 2026
7f5c1de
Event feed: three renderings that said more, or other, than the contract
jeremy Sep 10, 2026
c93c620
Event feed: two docs narrowed to what the code holds
jeremy Sep 10, 2026
b51c788
Event feed: the tier-2 driver models a conforming adapter, and its lo…
jeremy Sep 10, 2026
03f5d70
Event feed: a state is announced with its timer already armed
jeremy Sep 10, 2026
7d803f9
Event feed: a seam panic on a worker goroutine reaches the consumer, …
jeremy Sep 10, 2026
0377000
Event feed: a cancelled dial's panic is re-raised, not dropped with i…
jeremy Sep 10, 2026
0df591a
Event feed: the staleness probe trusts the latch before the channel
jeremy Sep 10, 2026
996a59f
Event feed: reconcile the run loop with the shipped feed contract
jeremy Sep 16, 2026
baa295b
Event feed: pin the FeedGap resume URL and the close code at tier 2, …
jeremy Sep 16, 2026
e32dcfa
Event feed: Close outranks the re-entry after FilterConflict, Wait pu…
jeremy Sep 16, 2026
de9171e
Event feed: tier 2 records Observer.filterConflict, and fixture 34 pi…
jeremy Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ All `262` operations across the ~50-service per-SDK layer are generated. Hand-wr
|---------|----------|
| HTTP helpers, pagination, hooks | `typescript/src/services/base.ts`, `ruby/lib/basecamp/generated/services/base_service.rb`, `swift/Sources/Basecamp/Services/BaseService.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/BaseService.kt`, `python/src/basecamp/generated/services/_base.py`, `rust/basecamp-sdk/src/http/` |
| OAuth flows (not in OpenAPI spec) | `typescript/src/services/authorization.ts`, `ruby/lib/basecamp/services/authorization_service.rb`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/oauth/`, `python/src/basecamp/services/authorization.py`, `rust/basecamp-sdk/src/oauth/` (no Swift equivalent) |
| Event Feed connector: the long-lived push+poll transport, hand-written behind its TicketMinter/PollSource seams (SPEC.md §23) | `go/pkg/basecamp/eventfeed/` (Go reference, foundations only so far — the run loop, the Layer-1 seam adapters over the generated operations, and the other SDKs are all pending) |
| Event Feed connector: the long-lived push+poll transport, hand-written behind its TicketMinter/PollSource seams (SPEC.md §23) | `go/pkg/basecamp/eventfeed/` (Go reference — foundations, the run loop, and the tier-2 conformance driver; the Layer-1 seam adapters over the generated operations and the other SDKs are still pending) |
| Merge-safe Todos composites (update/edit over generated get+replace; SPEC.md §18) | `typescript/src/services/todos-extensions.ts`, `ruby/lib/basecamp/services/todos_extensions.rb`, `swift/Sources/Basecamp/TodosServiceExtensions.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodosService.kt`, `python/src/basecamp/services/todos.py`, `rust/basecamp-sdk/src/services/todos.rs` |
| Merge-safe Cards composite (update over generated get+updateVerbatim; SPEC.md §18) | `typescript/src/services/cards-extensions.ts`, `ruby/lib/basecamp/services/cards_extensions.rb`, `swift/Sources/Basecamp/CardsServiceExtensions.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/CardsService.kt`, `python/src/basecamp/services/cards.py`, `rust/basecamp-sdk/src/services/cards.rs` |
| Merge-safe Todolists composites (update/edit over generated get+replace; SPEC.md §18) | `typescript/src/services/todolists-extensions.ts`, `ruby/lib/basecamp/services/todolists_extensions.rb`, `swift/Sources/Basecamp/TodolistsServiceExtensions.swift`, `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodolistsService.kt`, `python/src/basecamp/services/todolists.py`, `rust/basecamp-sdk/src/services/todolists.rs` |
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ All SDKs are generated from a single Smithy specification. When adding support f
- **Python:** `make py-generate`
- **Rust:** `make rs-generate`

`go/pkg/basecamp/eventfeed/` is outside that drift check by design: it is
hand-written §23 infrastructure rather than a wrapper over generated
operations, so nothing about it is derivable from the spec. It is verified
instead by the tier-2 conformance driver in the package, which replays every
fixture under `conformance/event-feed/fixtures/`.

4. **Add tests** for each SDK

5. **Add conformance tests** (`conformance/tests/`) covering the new operations
Expand Down
128 changes: 113 additions & 15 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -3224,7 +3224,26 @@ is shared:
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).
checkpoint (see the exclusion under Entry Boundary). Cancellation is visible before it
returns; it does **not** wait for the run to unwind, because every consumer callback runs
on the run's own execution context and waiting there would deadlock on the caller.
- **`close()` does not order a second connector over the same checkpoint store, and cannot.**
A save decided just before the close is still written after it. That is intended: the
position was accepted and its events delivered before the close, so dropping the write
would silently re-deliver them. **The save therefore runs under a context detached from
the run's cancellation** — carrying the run's context values, but not its cancellation.
Passing the live run context instead makes the guarantee conditional on the store: one
that ignores its context writes anyway, and one that honors it — which this contract
permits, and says nothing against — sees a cancelled context and drops the position. The
trade is explicit: a store that blocks indefinitely delays the run's exit, and therefore
`wait()`, rather than being released by `close()`. That is bounded by the store's own
behavior, where a dropped position is unbounded re-delivery with nothing recording it. **`wait()`** is the quiescence point — it blocks
until the run has exited, after which no save can be in flight. A consumer that owns the
iteration needs nothing extra, since the iteration terminating is the same guarantee.
Await termination — or `wait()` — before opening a second connector over the same store.
`wait()` is not callable from anything the run is waiting on — a consumer callback, or a
seam, store, or clock implementation the run is synchronously awaiting — for the reason
`close()` does not wait.
- 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
Expand Down Expand Up @@ -3443,6 +3462,59 @@ 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.

**A socket outcome observed while a poll seam call is in flight is deferred to the page
boundary, and the wait for that call is bounded by DETECTION WINDOW + GRACE PHASE.** This
is transition 21 in CatchingUp, the only state that holds a wire call open across a socket
event, and it is normative for every SDK.

The deferral itself is the finish-the-page ordering the rest of §23 already states: the
in-flight page is accepted, delivered and saved, and only then is the socket's outcome
dispatched. It applies to **every** socket outcome, and that expressly includes a staleness
expiry — a socket that goes silently half-open produces no frame and no read error, so its
expiry is the only evidence there will ever be, and an implementation whose in-flight-poll
wait does not observe `staleness` cannot detect that socket at all. Disposing the attempt
where the expiry is observed is NOT conformant: it strands the deliveries and the save of a
page the server had already served.

The two intervals bound the whole sequence, and their sum is the worst case:

- **Detection window** — `EVENT_FEED_STALE_AFTER`, the staleness window, measured from the
last inbound frame. It is what decides the socket is dead, under the ordinary evaluation
rule: a firing superseded by a frame the reader took first, or one whose window
overlapped a blocked hand-off, is not evidence and must not be deferred.
- **Grace phase** — one further `EVENT_FEED_STALE_AFTER`, measured from the deferral, after
which the seam call is abandoned to the deferred outcome's teardown (the teardown
cancels it — Seam-Call Semantics). It is a **deadline read from the clock at the instant
of deferral**, not a window, and it carries two immunities that are the point of naming
it:
- **Immune to frame resets.** A frame arriving inside the phase re-arms `staleness` in
the ordinary way and MUST NOT move the deadline. The phase bounds how long the
connector waits for an abandoned call, which is unrelated to whether the peer is still
talking.
- **Immune to suspension.** The full-queue suspension rule below rests on a full queue
proving the peer is outrunning a connector that is still consuming. This is the one
wait that deliberately stops consuming — an outcome already occupies the single
deferral slot — so the premise is false here by construction, and a suspended
evaluation MUST NOT extend the phase.

An implementation may wake as often as it likes and from whatever source it has; wakes may
be early or late, and the deadline is what decides. In particular this introduces **no new
timer kind**: the six kinds and the per-state exact timer sets are both unchanged, and an
implementation that re-arms `staleness` purely to obtain a wake keeps CatchingUp's set at
{`staleness`}.

**"Observed" means handed to the state machine, and the boundary is normative.** A frame
the transport reader has taken off the socket but not yet handed over is not observed, and
no implementation is required to find it. That is not a tolerance granted for convenience:
making it observable requires the read to complete inside a critical section the scan can
enter, and the read blocks indefinitely on a quiet socket, so the lock deadlocks the drain
against a peer that simply stopped talking. Sampling a flag the reader sets after its read
does not close it either — the flag is published after the read returns, and the scan reads
it after its own check of the queue, so a frame can arrive and the flag clear between the
two. What every implementation MUST cover is everything handed over, **plus the one frame a
blocked hand-off is holding** — the reader is a single goroutine, so there is exactly one,
and it is why the scan's budget is pump depth + 1 rather than pump depth.

### Disconnect Dispatch `[conformance]`

Action Cable's `disconnect` is a **text frame**, not a WebSocket close frame, and stock
Expand Down Expand Up @@ -3494,7 +3566,10 @@ Two dispatch clarifications, pinned:
map-marshaled so any retransmit is byte-identical. Subscription parameters mirror the
poll filters with the same caps; `self` is resolved by the caller (Consumer Surface). The server absorbs identical
retransmits and rejects different ones.
- Subscribe is sent on each `welcome` received. Confirm/reject correlation is exact string
- Subscribe is sent on the `welcome` that opens each connection — Action Cable sends exactly
one, before any other frame; a later frame of that type is liveness only, never a second
handshake (a mid-connection resubscribe would race the one already confirmed). 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":<epoch>}`.
Expand Down Expand Up @@ -3563,13 +3638,16 @@ 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
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=<id>`, `since=0` — are **position-resume class** and keep the unamended per-page
save discipline.
this section and the state table: the zero Cursor (bare present entry); `since="now"`;
and a 400-position/409 re-entry that falls back to the present because no 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=<id>`, `since=0`,
and a 410 reset's `resume` URL (the server documents it as re-entering **at the epoch**
with the canonical filter set preserved, so the servable history above the fence is not
skipped; the inbox lane's re-enters at `since=0`, the earliest retained item) — are
**position-resume class** and keep the unamended per-page save discipline: every event
between such an entry and the present is poll-served, so nothing behind it depends on
the live buffer.

**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
Expand Down Expand Up @@ -3647,7 +3725,7 @@ SignalHandler : (Signal) → Accept | Terminate
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
filter set and re-enters at the epoch — a position-resume entry, Entry Boundary above). **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
Expand Down Expand Up @@ -3758,25 +3836,31 @@ INTERFACE PollSource
-- 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.
-- The connector's own side of that is bounded rather than trusting: an outcome observed
-- while the call is in flight is awaited for the grace phase and then abandoned to the
-- teardown (detection window + grace phase, under the state machine above).
END

RECORD Cursor -- exactly one field set; the zero Cursor is the bare present entry
position : String? -- resume/repair token (in-memory authoritative within a run;
-- durable via write-through when saves succeed)
since : String? -- "now", "0", or a decimal event id
since : String? -- "now", "0", or a decimal event id (a signed 64-bit integer on
-- the wire; out of range draws the position 400, not an empty page)
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
-- (X-Feed-Position and Link rel="next" merely echo position/next)
events : List<Event>
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) |
-- filter_invalid(server message) | filter_changed(position_digest, filters_digest) |
-- gone(epoch_after_id, resume_url) |
-- unauthorized | redirect_refused(location_origin) | 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 → throttled(retry_after)
Expand Down Expand Up @@ -4093,12 +4177,23 @@ ships off.
| Signal handler (none ⇒ default-terminal) | `WithSignalHandler` | `signalHandler?` | `signal_handler` | `signalHandler` | `signal_handler` |
| Observer (none) | `WithObserver` | `observer?` | `observer` | `observer` | `observer` |

The two lifecycle methods take each language's native spelling of the same two acts: Go
`Close()` / `Wait()`, TypeScript `close()` / `wait()`, Python and Ruby `close` / `wait`,
Kotlin `close()` / `join()`, Swift `close()` / `wait()`. Where the language's streaming
idiom already exposes the run's completion — a Kotlin `Job`, a Swift `Task` — that handle
IS `wait()` and no second method is added; what must exist is a way to observe the run's
exit that is not `close()`.

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)`,
resume_url)`, `position_rejected(kind)`, `filter_conflict(position_digest, filters_digest)`
(a 409's two bare srv2 digests — the one the refused position was minted for and the one
the request's filters hashed to; a `filters_digest` that differs from the SDK's own
`Filters.digest()` for the same set means the local canonicalization has drifted from the
server's), `stale_connection(since_last_frame)`,
`buffer_overflow(dropped_count)`. All are observability-only; none carries a disposition.

### Security Invariants `[static]`
Expand All @@ -4108,8 +4203,11 @@ resume_url)`, `position_rejected(kind)`, `stale_connection(since_last_frame)`,
Values Are Never Rendered" names. A dial failure names the policy class it violated
from a closed vocabulary, never any component of the URL, and never chains the
transport's own error where a caller or runtime would render it. Poll and resume URLs
are not credentials — polls authenticate with the bearer header — so `gap(resume_url)`
and `catch_up_started(cursor)` carry them whole.
are not credentials — polls authenticate with the bearer header — but they are
server-chosen text arriving on a logging surface, so `gap(resume_url)` and
`catch_up_started(cursor)` carry them reduced to their origin (a fixed placeholder for a
cross-origin or unparsable one); the `FeedGap` signal hands the handler the resume URL
whole, since its disposition is a decision about which URL to follow.
- **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).
Expand Down
Loading
Loading