Skip to content

The event feed's paging is declared as an absence - #914

Merged
jorgemanrubia merged 6 commits into
mainfrom
feed-ops-cursor-paging
Sep 16, 2026
Merged

jorgemanrubia merged 6 commits into
mainfrom
feed-ops-cursor-paging

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

The account event feed pages by cursor: each GET /events.json or GET /inbox.json answers one page of at most 100 rows carrying its own opaque position, and a next URL while the walk has more to serve. Nothing in the spec said so. Account event feed, inbox, and stream tickets: spec operations and generated clients had to model that paging as an absence — no @basecampPagination trait at all — because the only thing the trait could mean was the Link-header walk, and generating that walk would flatten the pages and swallow every intermediate position, which is the one thing a crashed consumer resumes from. Declaring nothing was the only way to avoid declaring the wrong thing.

The cost is that every catalogue downstream of the spec says these operations answer once. The Rust route table rendered them Pagination::None, which route.rs documents as "a single answer". behavior-model.json and the metadata catalogues said nothing at all. Anything reading the contract — including the MCP catalogues — learned that the feed does not page.

Make the pagination trait's cursor style mean something removed the reason for the silence: style: "cursor" is now implemented in all seven generators and means exactly "one call answers one page, never walked", and Close the malformed-trait pass in the two generators that still had it closed the two holes that left. So the feed can now say what it does, and this is it saying so.

Context: 11 Feed operations in both MCP servers.

What changes

Both poll lanes declare the cursor style. key names the envelope member holding the page (events, items) and maxPageSize is bc3's PAGE_SIZE — 100 on Event::Feed and Event::Inbox alike. totalCountHeader is omitted because the feed sends no X-Total-Count. CreateStreamTicket stays undeclared: it mints one ticket per call and pages in no sense.

No generated method changes anywhere. All six service generators read the cursor style as "no walk", which is exactly what the absent trait produced, so the only SDK diff is the doc comment. What does change is the contract: openapi.json, behavior-model.json, the Ruby and TypeScript metadata catalogues, and the Rust route table, which now says Cursor with the page's key.

SPEC §23 and the Smithy section note both stated the absence as the design. They now state the declaration, and say why it is still not the Link walk.

On the tests

The route table is where the declaration is pinned, because it is the artefact a consumer reads. guarantees.rs asserts the two lanes are the entire cursor set, with their keys, and that CreateStreamTicket pages not at all. Reverting the trait and regenerating turns it red: left: [] right: ["PollEvents", "PollInbox"].

The runtime guard is conformance. The feed's envelope case already served a continuation and asserted one request; the inbox had no such case, so it now has one. Both serve a relative Link: rel="next" (as pagination.json does) beside the absolute body next, so a walker that follows the header makes a second request in every runner instead of depending on each walker's cross-origin refusal. Flipping both lanes to style: "link" and regenerating turns both cases red in the TypeScript and Python runners with Expected 1 requests, got 2.

How a flip goes red differs by SDK, and SPEC §23 now says exactly how: on the request count in TypeScript, Python and a Link-following Go wrapper; on the resulting error in Ruby; at the runner's compile in Kotlin and Swift. Rust's conformance runner stays green on a flip — its link-style method fetches one page and leaves the walk to the caller — so in Rust the route-table test is the guard.

An earlier revision added a TypeScript-only test for this; it duplicated the feed's conformance case and is gone.

Rebased on the error-shapes change

Rebased onto Event feed: split the poll lanes' 410s and give their 400 a reason, which touched the same table rows, doc comments and fixture file. No semantic overlap: that PR is error shapes, this one is paging. Everything regenerated on top of it, and SPEC §23's fixture count now reads thirteen.

Locally, the full make check target list passes except two things this machine cannot run: rs-deny (cargo-deny not installed; passes in CI) and swift-check (macOS only). Swift's generator also could not run here, but its emitted services carry neither operation descriptions nor pagination metadata, and Swift Tests pass in CI.

Review

Adversarial review by separate Opus agents across every head, converged on 262db36d. The first pass found nothing blocking; everything after the first commit is what the passes turned up — the runtime tripwire on the wrong lane, a trait doc claiming nothing guards Go, SPEC §8 under-enumerating, a stale fixture count after the rebase, the absolute Link leaving a same-origin-only walker uncaught, and prose that misstated how Rust and Ruby fail on a flip.

Copilot AI balanced review requested due to automatic review settings September 16, 2026 12:36
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK spec Changes to the Smithy spec or OpenAPI python Pull requests that update the Python SDK rust Rust SDK labels Sep 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The Ruby metadata catalogue drops both cursor collection keys, leaving its declared contracts incomplete.

Pull request overview

Declares event-feed polling as cursor pagination without enabling automatic Link walking.

Changes:

  • Adds cursor traits for PollEvents and PollInbox.
  • Regenerates contracts, metadata, documentation, and Rust routes.
  • Adds regression coverage for single-page behavior and route classification.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
SPEC.md Documents cursor pagination semantics.
behavior-model.json Catalogues both cursor operations.
openapi.json Adds pagination extensions.
spec/basecamp.smithy Declares cursor traits.
typescript/src/generated/schema.d.ts Updates generated API documentation.
typescript/src/generated/openapi-stripped.json Mirrors OpenAPI changes.
typescript/src/generated/metadata.ts Adds complete cursor metadata.
typescript/tests/services/event-feed.test.ts Guards against automatic Link walking.
ruby/lib/basecamp/generated/types.rb Refreshes generation timestamp.
ruby/lib/basecamp/generated/metadata.json Adds cursor metadata but omits collection keys.
python/src/basecamp/generated/services/event_feed.py Updates generated documentation.
rust/basecamp-sdk/src/generated/services/event_feed.rs Updates generated documentation.
rust/basecamp-sdk/src/generated/routes.rs Classifies both routes as cursor-paged.
rust/basecamp-sdk/tests/guarantees.rs Pins cursor routes and keys.
Review details
  • Files reviewed: 6/14 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 On the Ruby metadata catalogue dropping the cursor collection keys — checked, and it is not a gap this PR opens.

ruby/scripts/generate-metadata.rb has never emitted key for any operation. It copies style, totalCountHeader and maxPageSize and stops there, so all 61 link-paginated operations are missing it too:

$ jq '.operations.ListProjects.pagination' ruby/lib/basecamp/generated/metadata.json
{ "style": "link", "totalCountHeader": "X-Total-Count", "maxPageSize": 50 }

GetPersonProgress, whose items live under an events key in a wrapper object, reads the same. So the two cursor lanes land exactly as complete as every other paginated operation in that file — the omission is uniform, and pre-dates this change by a long way.

It is also inert. Ruby's runtime reads one thing out of metadata.json: Http.operation_retry digs out the retry block (ruby/lib/basecamp/http.rb:621-628). The pagination block is catalogue only, and the key that actually drives Ruby's envelope unwrapping is baked into the generated service method by generate-services.rb, not read back from metadata. Python's metadata emits no pagination block at all, for what it is worth.

Widening the Ruby emitter would be the right fix for the underlying inconsistency, but it belongs in its own change: it touches 63 operations' metadata in a field nothing reads, and its justification is about catalogue parity across the SDKs rather than about the event feed. Happy to open it separately if that is wanted.

For contrast, the TypeScript catalogue copies the trait wholesale and does carry both keys, and the Rust route table carries them in Pagination::Cursor { key } — which is where this PR pins them, in guarantees.rs.

@github-actions github-actions Bot added the conformance Conformance test suite label Sep 16, 2026
PR 898 modelled the event feed's paging as an absence: PollEvents and
PollInbox carried no @basecampPagination trait, because the only thing
the trait could mean was the Link-following walk, and following that walk
would flatten the pages and swallow the per-page `position` a consumer
resumes from. Declaring nothing was the only way to not declare the wrong
thing.

PR 905 gave the trait a second mode — style: "cursor", which declares the
paging and generates no walk — and #910 closed the two malformed-trait
passes it left. So the feed can now say what it does.

Both poll lanes declare it: `key` names the envelope member holding the
page (`events`, `items`) and `maxPageSize` is bc3's PAGE_SIZE of 100 on
both Event::Feed and Event::Inbox. CreateStreamTicket declares nothing —
it mints one ticket per call and pages in no sense.

No generated method changes. All six service generators read the cursor
style as "no walk", which is what the absent trait produced, so the only
diff in the SDKs is the doc comment. What does change is the catalogue:
openapi.json, behavior-model.json, the Ruby and TypeScript metadata, and
the Rust route table, which now says Cursor rather than the None that
meant "a single answer".

The route table is where it is pinned. guarantees.rs asserts the two
lanes are the cursor set, with their keys, and that CreateStreamTicket
still pages not at all; the Link-paginated count stays 61, which is the
assertion that the declaration did not quietly enrol them in the walk.

The TypeScript test serving a Link: rel="next" header is a tripwire
against a future style flip, not a proof of this change — an undeclared
operation makes one request too. It goes from 1 request to 10,000 if the
style is changed to "link".
Adversarial review of the commit before this one, over two passes.

The runtime tripwire was on the wrong lane. The TypeScript test serving a
Link: rel="next" header duplicated a conformance case that already did
that for PollEvents in all seven SDKs, asserting one request. PollInbox
had nothing: its envelope case serves no `next` and no Link header, so the
cursor style declared on the inbox had no runtime assertion anywhere. The
TypeScript test is gone and the inbox gets its own conformance case,
serving both continuation signals and asserting a single request with
`next` decoded. One fixture covers seven languages.

Both cases now serve a relative Link, as pagination.json's do. The feed
case's absolute one meant a generated walk was caught in most runners
only by the walker's cross-origin refusal. Each runner mocks its own
origin, so an absolute production URL is cross-origin everywhere except
Ruby and Python. A wrapper that walked same-origin links and stopped at
foreign ones would have passed while walking in production, where `next`
is same-origin. Resolved against the request, the relative link is
same-origin in every runner, so a walk is caught by the request count
itself. The body's `next` stays absolute, as on the wire.

Proven: flipping both lanes to style "link" and regenerating turns both
cases red in the TypeScript and Python runners with "Expected 1 requests,
got 2". With the absolute Link, TypeScript failed earlier, at the origin
guard.

The trait's own doc said a cursor operation's Go wrapper is guarded by
nothing, "and no build will say so". Conformance says so, since Go's
runner executes these fixtures like the other six. The doc now names that
guard and says a new cursor operation owes the same fixture.

SPEC §8 listed the operations that carry the pagination trait without a
`page` parameter. The two lanes now belong on that list, for a different
reason than the six already there, so they get their own carve-out.
SPEC §23's fixture paragraph and the fixture-section map now count
thirteen cases and name the new one.

`key`'s member doc still read purely as link-walk framing, though every
service generator withholds it for the cursor style. It now says so, and
that on a cursor operation it is catalogue only.

A correction to the previous commit's message: the Link-paginated count
staying at 61 does not show the declaration kept these lanes out of the
walk. That filter already matched Link routes alone, so a cursor
declaration leaves it at 61 by construction. What it catches is a "link"
typo.
Two wording corrections from the converged review of the rebased head.

SPEC §23 said both cursor lanes are "caught by count" if a walk is ever
generated, and the trait doc said a walking Go wrapper fails "exactly as
the other six". Count is the mechanism where the return type survives a
flip to the link style: TypeScript, Python, Ruby, Go and Kotlin. In Rust
and Swift the flip changes the generated return type, so the runner fails
to compile before it counts anything. The walk still goes red, just not
by count, and both sentences now say so.

The feed's envelope case serves a relative Link beside an absolute body
`next`, unlike bc3's wire, and only the inbox case explained why. The
feed case now says it too, so nobody "fixes" it back to absolute.
A Go wrapper that follows the Link walk fails conformance on the request
count. One that chased the body's absolute `next` would be refused at the
runner's mock origin and still count one request. That walker is
contrived, but the trait doc claimed more than the fixture guards, so it
now names the walk it catches and the one it does not.
Review of the previous two commits, which set out to state exactly how a
generated walk goes red and still got one SDK wrong.

A lane flipped to the link style does not fail Rust's conformance runner
at compile, or at all. Rust's link-style method calls send_page, which
fetches one page and leaves the walk to collect_all and items. Page<T>
derefs to T, so the runner's summarizer still compiles. One request, every
assertion green. What pins the style in Rust is the route-table test in
guarantees.rs, and SPEC §23, the trait doc and both fixture descriptions
now say so.

The rest of the split is now stated as each runner behaves: request count
in TypeScript (its runner is not type-checked), Python, Ruby and a
Link-following Go wrapper; compile in Kotlin and Swift. A Go wrapper that
chased the absolute body `next` fails on the cross-origin error rather
than on count. Only one that swallowed the error slips through, and the
trait doc names it.
… mock

Review of the previous commit, which converged with three wording nits.
Two of them corrected claims that were false.

Ruby's link-style method fetches page one eagerly and places a lazy
enumerator under the key. The runner's summarizer calls `.length`, which
Enumerator lacks, so the case fails on the NoMethodError after one
request, not on the request count. SPEC §23 now says so.

In Go an absolute `next` is refused by the client, whose base URL is the
runner's loopback mock. The mock never sees that request.

Also untangles two parentheticals in the inbox case's description and a
doubled "so" in the feed case's.
@jorgemanrubia
jorgemanrubia merged commit 641cd65 into main Sep 16, 2026
53 checks passed
@jorgemanrubia
jorgemanrubia deleted the feed-ops-cursor-paging branch September 16, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conformance Conformance test suite python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK rust Rust SDK spec Changes to the Smithy spec or OpenAPI typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants