Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9e4bceb
Event feed: bind the connector to the generated operations
jeremy Sep 16, 2026
b661194
Event feed: the refused hop never reaches a rendering, and NewLive re…
jeremy Sep 16, 2026
3e0d04d
Event feed: the adapters classify what a fresh call would answer the …
jeremy Sep 16, 2026
f8cbdc0
Event feed: gate refusals stay transient, an inbox item's envelope is…
jeremy Sep 16, 2026
9657902
Event feed: the adapters refuse what the connector could never recove…
jeremy Sep 16, 2026
1c5d9a0
Event feed: the adapters answer every redirect at the wire, and nothi…
jeremy Sep 16, 2026
5fbda6c
Event feed: NewLive refuses a base URL carrying userinfo, and the mig…
jeremy Sep 16, 2026
ef75deb
Event feed: a row's details reach the connector as the bytes the serv…
jeremy Sep 16, 2026
b2f799a
Event feed: the redirect guard answers only the seams' calls, the lan…
jeremy Sep 16, 2026
4033655
Event feed: the guard recognizes a seam call by its route, and a trun…
jeremy Sep 16, 2026
7df5b67
Event feed: a 410 of the other lane's shape is malformed, and the inv…
jeremy Sep 16, 2026
b7ecaaf
Event feed: the guard finds its routes beneath a base path, a continu…
jeremy Sep 16, 2026
858b083
Event feed: the guard is anchored at the base path and the account, m…
jeremy Sep 16, 2026
310de91
Event feed: a canonical base path, one poll source per connector, and…
jeremy Sep 16, 2026
53f9fea
Event feed: the adapters map the two 410 shapes, the reason-keyed 400…
jeremy Sep 16, 2026
2d90ba3
Event feed: a next continues at a position, a resume re-enters under …
jeremy Sep 16, 2026
5f89967
Event feed: a continuation's filters compare to the lane's as sets, r…
jeremy Sep 16, 2026
56851cd
Event feed: a resume starts a new walk, a relative Location resolves …
jeremy Sep 16, 2026
393a818
Event feed: the inbox's 410 is refused by its fence, and the test now…
jeremy Sep 16, 2026
2532361
Event feed: a refused redirect the seam could not attribute says unre…
jeremy Sep 16, 2026
ebce4df
Event feed: a base URL whose path is percent-encoded is refused, not …
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 `265` 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, 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) |
| 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, the tier-2 conformance driver, and the Layer-1 seam adapters over the generated operations, `NewLive`; 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
32 changes: 21 additions & 11 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -4241,10 +4241,16 @@ END
-- refresh and retry budget) → unauthorized; a 3xx whose Location fails the per-hop
-- same-origin/no-downgrade validation (auto-follow is disabled — Continuation and
-- Resume URL Validation) → redirect_refused, carrying the refused Location redacted to
-- its origin → Terminal(`invalid_continuation`), NEVER unrecoverable; anything else
-- its origin — or, in its place, the fixed token `unparsable` when the Location yielded
-- no complete origin (§9) and `unrecorded` when the hop was refused but the adapter
-- could not attribute the origin to the call; the refusal and its zero egress never
-- depend on that attribution, and the two tokens are never interchanged, since one
-- reports what the server sent and the other what the adapter failed to keep
-- → Terminal(`invalid_continuation`), NEVER unrecoverable; anything else
-- non-retryable (404, 405, unexpected shapes) → unrecoverable, carrying the generated
-- error verbatim. A same-origin Location may be followed inside the seam under the same
-- per-hop rule (no error surfaces).
-- error verbatim. No Location is followed inside the seam, same-origin included: the API
-- never redirects a feed call, and a continuation is followed by re-issuing the
-- operation, never by a hop.

INTERFACE CableTransport
dial(ws_url, cancellation, max_frame_bytes) → CableConn
Expand Down Expand Up @@ -4352,11 +4358,11 @@ not an operable feed state.
stacks auto-follow redirects (Go strips `Authorization` on a cross-origin hop but still
egresses), which would falsify the zero-foreign-egress guarantee the moment a validated
same-origin URL answers 3xx with a foreign `Location`. The Layer-1 adapter therefore
**disables automatic redirect-following for `PollEvents`** (or per-hop validates every
resolved `Location` under §8's hop-anchored rule): a 3xx from a validated URL yields its
`Location` to the same same-origin + no-downgrade validation — cross-origin or downgraded
→ Terminal(`invalid_continuation`) with zero egress to the foreign origin; same-origin →
it may be followed, each hop under the same rule.
**answers every 3xx to a feed operation itself and follows none**: a 3xx from a validated
URL yields its `Location` reduced to its origin, with the hop refused — foreign,
downgraded and same-origin alike, since the API never redirects a feed call and a
continuation is followed by re-issuing the operation — → Terminal(`invalid_continuation`)
with zero egress to the foreign origin.

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
Expand All @@ -4383,9 +4389,13 @@ decides whether to follow one, which makes the foreign origin unreachable by
construction of the harness — a harness that asserted no request reached it would be
asserting something about itself. That obligation belongs to the Layer-1 seam
adapter's own 302 test, where a real generated `PollEvents` call meets a real redirect
against an adapter with automatic redirect-following disabled.
`conformance/event-feed/README.md`'s row-15 note records it as a pending obligation
rather than a proof the repository contains; the adapters are tracked in #819.
against an adapter whose client refuses the hop: the Go adapters (`eventfeed.NewLive`)
compose a guard over their client's transport that answers every 3xx at the wire —
the `Location` reduced to its origin for the seam and stripped, with the body, before
the HTTP stack, the operation hooks or any log sees it; no hop is followed, same-origin
included, since the API never redirects a feed call — and their 302 test proves zero
Comment thread
jeremy marked this conversation as resolved.
egress against a sentinel listener behind the foreign `Location`
(`conformance/event-feed/README.md`, row 15).

### Clock, Timers, and Virtual Time `[conformance]`

Expand Down
21 changes: 12 additions & 9 deletions conformance/event-feed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ revoked-mint threshold).
| 27 | `27-hostile-resume-cross-origin.json` | accepted 410 with a cross-origin `resume` → Terminal(`invalid_continuation`), zero foreign requests |
| 28 | `28-checkpoint-load-failure.json` | store load Failed → Terminal(`checkpoint_load`) with ZERO wire attempts; distinct from Missing (which proceeds to a present entry) |
| 29 | `29-checkpoint-save-failure-continues.json` | save Failed → feed continues and a SUBSEQUENT save is attempted (exact store-call script: no save circuit breaker) |
| 30 | `30-continuation-redirect-cross-origin.json` | validated same-origin `next` answering 302 + cross-origin Location → Terminal(`invalid_continuation`); zero foreign egress holds by construction of the seam here, and proving it against a real redirect is ASSIGNED to Layer 1, whose adapters are still pending, tracked in #819 — see the row-15 note |
| 30 | `30-continuation-redirect-cross-origin.json` | validated same-origin `next` answering 302 + cross-origin Location → Terminal(`invalid_continuation`); zero foreign egress holds by construction of the seam here, and proving it against a real redirect is ASSIGNED to Layer 1, whose Go adapters' 302 test proves it against a sentinel listener — see the row-15 note |
| 31 | `31-post-snapshot-straggler-below-served-id.json` | post-snapshot straggler with an id BELOW the entry page's served id delivered live; the re-push of that served id still suppressed |
| 32 | `32-inbox-lane-dedupes-by-addressing-id.json` | the inbox lane: `inbox: true` + `reasons` on the subscription; `{items, position, next}` pages; two items over one event both deliver; an addressing id re-served by the repair poll after its live delivery is suppressed; delivered ids are addressing ids |
| 33 | `33-inbox-gap-410-retention-resume.json` | the inbox's 410 is the retention window: accepted, the resume URL (`since=0`, the earliest retained item) is followed as a position-resume entry and its page saves on acceptance |
Expand Down Expand Up @@ -415,8 +415,8 @@ when every line is done:

Fifteen of the sixteen mutations are shown red against at least one fixture in
the reference implementation PR's body before they count. Row 15 is the
recorded exception — not killed at tier 2, pending the Layer-1 adapters #819
tracks — and the note below is its account.
recorded exception — not killed at tier 2; killed by the Layer-1 adapters' own 302
test — and the note below is its account.

**One row is an exception, and it is the reason this heading is worth reading
twice.** Row 15's mutation is **not killed at tier 2 at all** — it lives below
Expand All @@ -442,7 +442,7 @@ exactly the class of claim this family exists to check.
| 12 | `bypass-configured-handler` (handler registered but skipped; default-terminal applied) | 24, 25 (via `handlerInvocations` exact-set) |
| 13 | `follow-cross-origin-continuation` (skips §8 validation, polls the hostile URL) | 26, 27 |
| 14 | `collapse-load-error-to-missing` | 28 |
| 15 | `follow-cross-origin-redirect` (follows a 302 to a foreign Location) | **not killed at tier 2** — below the poll seam; assigned to Layer 1, whose adapters are still pending, tracked in #819. Fixture 30 pins a different fault class above the seam. See the note under this table. |
| 15 | `follow-cross-origin-redirect` (follows a 302 to a foreign Location) | **not killed at tier 2** — below the poll seam; killed at Layer 1 by the Go adapters' `TestLivePolls_RefusesACrossOriginRedirectWithZeroEgress` (a real generated `PollEvents` call meets a real 302, the hop is refused before any request, and a sentinel listener behind the foreign Location records zero hits). Fixture 30 pins a different fault class above the seam. See the note under this table. |
| 16 | `discard-live-id-at-or-below-served-id` (streaming lane orders live ids against the highest poll-served id) | 31 — and 31 alone: verified to pass all of 01–30, because every other straggler either arrives with nothing yet served (20) or is buffered pre-cut (01, 12, 19) |

**Row 15 is not killed at tier 2, and the reason is structural.** In tier 2 the
Expand Down Expand Up @@ -476,11 +476,14 @@ That is withdrawn. No implementation met it, and meeting it would prove
nothing: the foreign origin is unreachable **by construction of the harness**,
because the harness is the seam, so a silent sentinel is a statement about the
driver rather than about the connector. Zero egress to a foreign redirect
target is a Layer-1 property, and proving it is ASSIGNED to the Layer-1 seam
adapter's own 302 test, where a real generated `PollEvents` call will meet a
real redirect. Those adapters have not landed — `go/pkg/basecamp/eventfeed/doc.go`
lists them among the pieces still to come — so this is a recorded obligation,
not a proof the repository contains today. Tracked in #819.
target is a Layer-1 property, and proving it belongs to the Layer-1 seam
adapter's own 302 test, where a real generated `PollEvents` call meets a real
redirect. The Go adapters (`eventfeed.NewLive`, `go/pkg/basecamp/eventfeed/live.go`)
carry that test: a guard composed over their client's transport answers every 3xx
at the wire — no hop is followed, the `Location` is reduced to its origin and
stripped before anything else sees it — and a sentinel listener behind the
foreign `Location` records zero hits. The other SDKs' adapters owe the same
test when they land.

Auto-continue-past-unhandled-gap needs no separate mutation — fixture 23's
exact-set `finally` is its direct test. Fixture 29's exact store-call script is the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "30-continuation-redirect-cross-origin",
"description": "A VALIDATED same-origin `next` answers 302 with a cross-origin Location: the poll seam suppresses automatic redirect-following, and the foreign Location is Terminal(invalid_continuation). {{NEXT:1}} substitutes same-origin, so the pre-poll validation PASSES and the second poll seam call is made (contrast fixture 26, where no request reaches the URL at all) \u2014 the redirect answer is where the per-hop rule bites. What tier 2 verifies is the per-hop refusal DECISION \u2014 the driver classifies the scripted Location through the SHIPPED predicate (checkContinuation, via a test export), never an ad-hoc re-implementation \u2014 and the loop's response: the continuation terminal, the socket explicitly closed, and no further seam calls. That the adapter's HTTP client makes zero requests to the refused URL is below the poll seam and owned by Layer-1 adapter conformance \u2014 a recorded obligation, not an existing proof: those adapters are still pending (tracked in #819), so follow-cross-origin-redirect itself remains NOT killed at tier 2 (row 15). The Location host is literal, never substituted, and never served. Redaction is deliberately not claimed here: the driver reduces the Location to its origin best-effort (an unreducible Location carries none) before the connector runs, so no path or query text exists for a connector to over-echo; TestRedirectRefusalRendersNoServerValue owns that proof. See the row-15 note in README.md.", "config": {
"description": "A VALIDATED same-origin `next` answers 302 with a cross-origin Location: the poll seam suppresses automatic redirect-following, and the foreign Location is Terminal(invalid_continuation). {{NEXT:1}} substitutes same-origin, so the pre-poll validation PASSES and the second poll seam call is made (contrast fixture 26, where no request reaches the URL at all) \u2014 the redirect answer is where the per-hop rule bites. What tier 2 verifies is the per-hop refusal DECISION \u2014 the driver classifies the scripted Location through the SHIPPED predicate (checkContinuation, via a test export), never an ad-hoc re-implementation \u2014 and the loop's response: the continuation terminal, the socket explicitly closed, and no further seam calls. That the adapter's HTTP client makes zero requests to the refused URL is below the poll seam and owned by Layer-1 adapter conformance \u2014 the Go adapters' 302 test proves it against a sentinel listener \u2014 so follow-cross-origin-redirect itself remains NOT killed at tier 2 (row 15). The Location host is literal, never substituted, and never served. Redaction is deliberately not claimed here: the driver reduces the Location to its origin best-effort (an unreducible Location carries none) before the connector runs, so no path or query text exists for a connector to over-echo; TestRedirectRefusalRendersNoServerValue owns that proof. See the row-15 note in README.md.", "config": {
"position": "{{POS:0}}"
},
"steps": [
Expand Down
2 changes: 1 addition & 1 deletion conformance/event-feed/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1411,7 +1411,7 @@
"status",
"headers"
],
"description": "Redirect on a continuation (fixture 30): the seam suppresses automatic following; a cross-origin/downgraded Location is Terminal(invalid_continuation). At tier 2 the poll lane is a SEAM, so the driver forms the redirect-refused verdict and the connector never sees a Location header: this pins the fault class above the seam (mishandling the verdict — retrying it or misclassifying it), not redirect-following itself, which lives below the seam, is unreachable at tier 2, and is ASSIGNED to the Layer-1 adapter's 302 test — a recorded obligation, not an existing proof: those adapters are still pending (tracked in #819). Redaction is not pinned here: the driver reduces the Location to its origin before the connector runs, so no path or query text reaches it. No sentinel-listener obligation is imposed on the harness: the foreign origin is unreachable by construction of the harness, so a silent sentinel would be a statement about the driver rather than about the connector. See the row-15 note in README.md.",
"description": "Redirect on a continuation (fixture 30): the seam suppresses automatic following; a cross-origin/downgraded Location is Terminal(invalid_continuation). At tier 2 the poll lane is a SEAM, so the driver forms the redirect-refused verdict and the connector never sees a Location header: this pins the fault class above the seam (mishandling the verdict — retrying it or misclassifying it), not redirect-following itself, which lives below the seam, is unreachable at tier 2, and is the Layer-1 adapter's 302 test (the Go adapters prove zero egress against a sentinel listener; the other SDKs' adapters owe the same). Redaction is not pinned here: the driver reduces the Location to its origin before the connector runs, so no path or query text reaches it. No sentinel-listener obligation is imposed on the harness: the foreign origin is unreachable by construction of the harness, so a silent sentinel would be a statement about the driver rather than about the connector. See the row-15 note in README.md.",
"properties": {
"status": {
"const": 302
Expand Down
Loading
Loading