Repository navigation
The event feed's paging is declared as an absence - #914
Conversation
There was a problem hiding this comment.
🔵 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
PollEventsandPollInbox. - 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 rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto 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.
|
🤖 On the Ruby metadata catalogue dropping the cursor collection keys — checked, and it is not a gap this PR opens.
It is also inert. Ruby's runtime reads one thing out of 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 |
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.
8f61a8b to
01efc54
Compare
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.
The account event feed pages by cursor: each
GET /events.jsonorGET /inbox.jsonanswers one page of at most 100 rows carrying its own opaqueposition, and anextURL 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@basecampPaginationtrait 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 intermediateposition, 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, whichroute.rsdocuments as "a single answer".behavior-model.jsonand 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.
keynames the envelope member holding the page (events,items) andmaxPageSizeis bc3'sPAGE_SIZE— 100 onEvent::FeedandEvent::Inboxalike.totalCountHeaderis omitted because the feed sends noX-Total-Count.CreateStreamTicketstays 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 saysCursorwith 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.rsasserts the two lanes are the entire cursor set, with their keys, and thatCreateStreamTicketpages 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"(aspagination.jsondoes) beside the absolute bodynext, 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 tostyle: "link"and regenerating turns both cases red in the TypeScript and Python runners withExpected 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 checktarget list passes except two things this machine cannot run:rs-deny(cargo-deny not installed; passes in CI) andswift-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.