Skip to content

Make the pagination trait's cursor style mean something - #905

Merged
jorgemanrubia merged 6 commits into
mainfrom
pagination-cursor-style
Sep 16, 2026
Merged

jorgemanrubia merged 6 commits into
mainfrom
pagination-cursor-style

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 16, 2026

Copy link
Copy Markdown
Member

basecampPagination has advertised three styles since it was written — "link", "cursor", "page" — and only one of them has ever meant anything.

Five generators decide whether to emit an auto-paginating method by testing the trait's presence, never its style. So declaring the documented cursor mode on an operation gets you the Link-following walk it exists to avoid: the generated method follows Link: rel="next" and flattens every page into one array. Rust is the exception and fails the other way — it reads style only to reject anything that is not "link", so the same declaration fails the build. The one thing nobody could do with the cursor style was use it.

Why it matters

A cursor-paginated endpoint hands back a page and a position. The position is the durable checkpoint: the thing a consumer persists after accepting that page, and the only thing it can resume from after a crash. Flattening a walk swallows every intermediate position and leaves a consumer with a single array and no way back into the middle of it.

That is the shape of the account event feed, whose three operations are landing in #898. That PR reaches the same runtime answer by declaring no pagination trait at all, which works and is what should ship there. This is the other half: making the vocabulary the trait already publishes mean what it says, so the next cursor endpoint is a declaration rather than an omission with a comment explaining it.

The change

All six generators branch on style. "link" keeps today's behaviour exactly. "cursor" declares the mode in openapi.json, behavior-model.json and Rust's route catalogue, and emits no auto-pagination: one call, one page, carrying its own position.

Anything else is refused, by name, in every generator. This is the half the first round got wrong, and it is the reason to read the second commit: keying off style makes an unrecognised value more dangerous than the old presence check did. Read as "not paginated", a typo silently ships a method that never walks — in five SDKs at once, with only Rust's build to catch it. Now all six name it and stop.

Cursor is catalogued as cursor. Collapsing it into "no pagination" made Rust's shipped route table render Pagination::None, which route.rs documents as "A single answer" — the opposite of true for a paginated operation. The generator's Option<Pagination> is now a three-state PaginationMode, and the public enum gains a Cursor variant (additive; it is #[non_exhaustive]).

Ruby needed a second fix. is_paginated asks (returns_array || has_pagination), so a cursor operation answering a bare array would still have walked, through the other half of that disjunction, with has_pagination already false. Latent today — no operation has an inline-array response — and precisely the kind of thing that stops being latent quietly.

And the trait stops advertising what it cannot do. "page" was documented for years, implemented nowhere, and is now actively rejected; leaving it would be this change's own defect one level up. It is gone, and pageParam — whose only purpose was serving it — with it. Add it back with an implementation, not before.

Verification

A no-op on today's spec. All 61 paginated operations are "link"; regenerating all seven SDKs produces byte-identical output apart from the dead pageParam? leaving the TypeScript metadata interface. Nothing moves until something declares the new style. Still true after merging main: #898's event feed operations declare no pagination trait at all, so the recount is unchanged at 265 operations and 61 declarations, all "link".

Tests follow the code rather than trailing it. Rust covers the catalogue entry and every refused spelling ("page", "linkk", "Link", "", absent), including the case where an unsupported style is also missing from the behavior model — which used to report the wrong problem. Python and Ruby gain coverage of the branch in the two generators that had none, Ruby's including the disjunction above.

make check clean except rs-deny, which will not build here (cargo-deny's zstd-sys fails to link). It reads Cargo.lock and deny.toml; this branch touches generator sources, one public enum and tests, so it cannot move that gate. CI runs it, and full CI is green on the merge.

Reviewed by Copilot and by four adversarial passes; their findings and mine converged, which is the best evidence any of them were real. The last pass ran on the merged head and found the refusal above was true only for a well-formed trait: Kotlin gated it on as? JsonObject and TypeScript on truthiness, so a bare "x-basecamp-pagination": "page" and a falsy false / 0 / "" both slipped through as "not paginated" — the same hole this branch had already closed in Swift, surviving in two of the six because the fix went where a reviewer pointed rather than everywhere it belonged. Both are closed, with tests in each language that I confirmed fail when the guard is reverted. Raised from a review of #898, tracked on 08 SDK Layer 1: the feed operations in the Smithy model and Pagination trait: style "cursor" is documented but unimplemented.

`basecampPagination` has advertised `style: "cursor"` since it was
written, and nothing has ever implemented it. Five generators branch on
the trait's PRESENCE alone, so declaring the documented mode would emit
the Link-following walk it exists to avoid; Rust reads `style` only to
reject anything that is not "link", so declaring it fails the build. The
one thing nobody could do with the cursor style was use it.

All seven now branch on `style`. "link" keeps today's behaviour --
follow Link: rel="next" and flatten the walk into one array, which is
right when the only thing a page carries is more items. "cursor"
declares the mode in openapi.json and behavior-model.json, for the
catalogues and for anyone reading the contract, and generates no
auto-pagination: each call returns one page carrying its own opaque
position, and flattening would swallow every intermediate one and leave
a crashed consumer with nothing to resume from.

A no-op on today's spec. All 61 paginated operations are "link", and
regenerating all seven SDKs produces byte-identical output -- the point
is that the NEXT cursor declaration works rather than silently flattens.

Widening Rust's match could have widened it to everything, so the
unknown-style refusal is pinned alongside the new behaviour.
Copilot AI balanced review requested due to automatic review settings September 16, 2026 10:07
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK kotlin swift python Pull requests that update the Python SDK rust Rust SDK labels Sep 16, 2026
@jorgemanrubia

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

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.

🟡 Changes recommended

Unresolved critical and moderate findings affect style validation and pagination behavior.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR makes basecampPagination.style control SDK pagination behavior, preserving Link traversal while supporting cursor-style single-page responses.

Changes:

  • Adds cursor-style handling across seven generators.
  • Preserves existing Link-style behavior and generated output.
  • Adds Rust coverage for cursor support and unsupported styles.
File summaries
File Review summary
typescript/scripts/generate-services.ts Critical (3 votes): Reject styles other than link and cursor; current logic accepts page and unknown values.
swift/Sources/BasecampGenerator/OpenAPIParser.swift Critical (3 votes): Reject unsupported pagination styles instead of treating them as non-paginated.
rust/generator/tests/fixtures.rs Adds cursor and unsupported-style validation coverage.
rust/generator/src/model.rs Handles cursor acceptance and unsupported-style rejection.
ruby/scripts/generate-services.rb Moderate (2 votes): Ensure bare-array cursor responses do not enter Link-pagination wrapping.
python/scripts/generate_services.py Moderate (3 votes): Reject page and unknown styles instead of treating them as cursor pagination.
kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/OperationParser.kt Critical (3 votes): Reject page and unknown styles rather than silently accepting them.
Review details

Suppressed comments (4)

kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/OperationParser.kt:206

  • The parser preserves paginationKey for cursor style, while ServiceEmitter uses that key in both findUnderlyingEntitySchema and the non-paginated decode path. A cursor envelope with key: "events" can therefore generate FeedEvent as the return/decode type and fail when the wire body is the { events, position, ... } object. Only expose the key to the link-pagination path, or guard all of those consumers with hasPagination.
        val paginationKey = operation["x-basecamp-pagination"]?.jsonObject?.get("key")?.jsonPrimitive?.content

ruby/scripts/generate-services.rb:479

  • Because has_pagination is now only a link-style check, style: "page" and unknown style strings silently become ordinary one-request methods in Ruby. That differs from Rust's explicit unsupported-style error and can hide an unimplemented pagination mode; reject unsupported present styles instead of collapsing them to false.
      has_pagination: operation.dig('x-basecamp-pagination', 'style') == 'link',

swift/Sources/BasecampGenerator/OpenAPIParser.swift:213

  • paginationKey remains set for cursor style, but Swift's return-type path uses it without checking hasPagination (buildReturnTypegetEntityTypeName). A cursor response envelope with a key will therefore be generated with the array element type as its public return type and decoded incorrectly. Restrict this internal key to link-style pagination or add the same guard at every consumer.
    let paginationKey = paginationExt?["key"] as? String

typescript/scripts/generate-services.ts:835

  • paginationKey is still populated for a cursor operation even though hasPagination is false. buildReturnType later passes this key to getEntityTypeName, so a cursor envelope such as { events: [...], position: ... } is emitted as the item alias (for example FeedEvent) instead of the response envelope. Keep the key only for the link style, or make every type resolver gate it on hasPagination.
  const paginationKey = operation["x-basecamp-pagination"]?.key;
  • Files reviewed: 7/7 changed files
  • Comments generated: 5
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


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

Comment thread swift/Sources/BasecampGenerator/OpenAPIParser.swift Outdated
Comment thread typescript/scripts/generate-services.ts
Comment thread python/scripts/generate_services.py Outdated
Comment thread ruby/scripts/generate-services.rb Outdated
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Adversarial pass on c74e848c. Twelve findings; two of them falsify claims in my own description, so I am flagging rather than quietly patching.

1. The catalogue claim is false in Rust (medium). I said "cursor" "declares the mode in openapi.json and behavior-model.json for the catalogues". It does — but rust/generator/src/emit/routes.rs:72-80 renders a None pagination as Pagination::None, and rust/basecamp-sdk/src/route.rs:101-104 documents that variant as "A single answer." So the shipped Rust route catalogue would assert a cursor operation is not paginated at all. Ok(None) conflates "no pagination declared" with "cursor declared", and those are different facts.

The fix is additive: Pagination is #[non_exhaustive], so a Cursor { key } variant costs nothing downstream. The blast radius of the cursor style in Rust is three files — routes.rs, types.rs (PageItems impl dropped), services/*.rs (return type) — and my test asserts only the third.

2. The refusal I claimed to pin is pinned in one generator out of six (medium). My commit message says widening Rust's match "could have widened it to everything, so the unknown-style refusal is pinned alongside the new behaviour". That refusal exists only in Rust. TypeScript, Python, Ruby, Kotlin and Swift now compute style == "link" and treat every other string — "page", "Link", a typo like "linkk" — as "no auto-pagination", silently. So this change converts a style typo from a loud uniform failure into a silently non-paginating shipped method in five SDKs, with the Rust build as the only tripwire. spec/basecamp-traits.smithy:41 types style as an unconstrained String and no check script validates it. An enum on the trait member would restore the guarantee I claimed.

Lower-severity, all substantiated: Rust's reordering degrades the error message for an unknown style that is also missing from behavior-model.json (points at the behavior model rather than the style); Ruby's is_paginated at generate-services.rb:666 also keys off returns_array, so "all seven branch on style" is not literally true there — latent only, since no operation has an inline-array 200 today; Python keeps the "follow the Link header" docstring boilerplate for cursor operations, which is wrong advice; the acceptance helper duplicates refusal and has already diverged (it leaks its temp dir on failure); "page" is a documented style, so the test named "unknown style" pins something narrower than its name; the trait's own docs still list three styles flatly; and Go has no generator reading this at all — its pagination is hand-written wrappers, so the first cursor operation there is human discipline with no drift check.

Verified clean: the no-op claim holds — all 61 paginated operations carry style: "link", and generating against the real repo root produces byte-identical output. No new throw paths in any generator. TS and Ruby metadata emitters key off presence and still emit the full block including style, as claimed. Nothing in any SDK runtime reads pagination metadata to decide auto-pagination.

I have not pushed fixes: there is a live question about who owns this branch, and a second writer on it is worse than a slow fix. Once that is settled I will push findings 1 and 2 at minimum.

Review of the commit before this, from Copilot and an adversarial pass,
which converged on the same defect from opposite directions.

Reading `style` to decide auto-pagination made an unrecognised value
dangerous in a way the old presence check never was: five generators
read anything that was not "link" as "not paginated" and silently
emitted a method that never walks. Only Rust refused. So a typo went
from a loud uniform failure to a shipped defect in five SDKs at once.
All six now refuse anything but "link" or "cursor", by name.

Ruby needed a second fix. `is_paginated` asks
`(returns_array || has_pagination)`, so a cursor operation answering a
bare array would have walked through the other half of that disjunction
with has_pagination already false. Latent -- no operation has an inline
array response today -- and exactly the kind of thing that stops being
latent quietly.

The Rust catalogue was asserting the opposite of the truth. Collapsing
cursor into Ok(None) rendered Pagination::None, which route.rs documents
as "a single answer", so a shipped route table would have said a
paginated operation answers once. The generator's Option<Pagination> is
now a three-state PaginationMode and the public enum gains a Cursor
variant -- additive, since it is #[non_exhaustive].

Style is checked before the behavior model, so an unsupported style is
named as what it is instead of sending the reader off to regenerate a
behavior model that was never the problem.

And the trait stops advertising what it cannot do. "page" was documented
for years, implemented nowhere, and is now actively rejected; leaving it
would be this change's own defect one level up. It is gone, and
pageParam -- whose only purpose was serving it -- with it.

Tests follow the code rather than trailing it: Rust covers the catalogue
and every refused spelling, and Python and Ruby cover the branch in the
two generators that had no coverage of it at all.
@github-actions github-actions Bot added the spec Changes to the Smithy spec or OpenAPI label Sep 16, 2026
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 The four suppressed comments were the valuable ones, and they found a defect the headline findings did not. Fixed in the commit that follows.

paginationKey was still populated for a cursor operation, and three type resolvers use it without checking hasPagination. Confirmed all three: generate-services.ts:1576 (getEntityTypeNamefindUnderlyingEntitySchema, which unwraps whenever the key is set), ServiceEmitter.swift:220, and OperationParser.kt:315. So a cursor envelope like {events: [...], position, next} would have been typed as FeedEvent — the item under the key — instead of the envelope the wire actually sends. Wrong public return type, and it would not decode.

That is precisely the bug this PR exists to prevent, one level down: the whole point is that the next cursor declaration works, and it would have silently mistyped itself.

Fixed at the source rather than at each consumer, in all five non-Rust generators: the key is populated only for the link style. Gating every consumer means every future consumer has to remember; withholding the key means none of them can get it wrong. Python and Ruby already gated theirs correctly, but they get the same treatment so the invariant is uniform rather than coincidental. Rust is unaffected by construction — its key lives inside PaginationMode::Cursor, reachable only by the route catalogue, never by type resolution.

Pinned in the Python and Ruby generator tests (link keeps the key, cursor has none).

On the headline findings — reject non-link/cursor styles in TypeScript, Swift, Kotlin, Python, and the Ruby bare-array gate: all of those landed in d32d8424, which was pushed after this review was generated. Every generator now refuses an unrecognised style by name, and Ruby's is_paginated no longer walks a cursor operation through the returns_array half of its disjunction.

Worth noting the convergence: your findings and an independent adversarial pass arrived at the same two defects from opposite directions, which is the best evidence either was real.

Jorge Manrubia and others added 3 commits September 16, 2026 12:37
Copilot, in the comments it collapsed rather than the ones it led with.

The key drives envelope unwrapping in type resolution --
findUnderlyingEntitySchema turns `{events: [...], position}` into the
item type whenever it is set -- and three consumers call it without
checking hasPagination: generate-services.ts:1576,
ServiceEmitter.swift:220 and OperationParser.kt:315. A cursor operation
carrying a key would therefore have been typed as FeedEvent instead of
the envelope the wire sends: wrong public return type, and it would not
decode.

Which is this change's own purpose failing one level down. The point of
implementing the cursor style is that the next declaration of it works,
and it would have silently mistyped itself.

Fixed at the source rather than at each consumer: the key is populated
only for the link style. Gating every consumer means every future
consumer has to remember. Python and Ruby already gated theirs
correctly and get the same treatment anyway, so the invariant is uniform
rather than coincidental. Rust needs nothing -- its key lives inside
PaginationMode::Cursor, which only the route catalogue can reach.
Adversarial review of the tip, with two findings that blocked and three
corrections to claims this branch itself made.

The cursor key was never validated. wrapped_items is the only thing that
checks a declared key against the response schema, and it early-returns
on .link(), so cursor walked past it and routes.rs emitted the key
verbatim into the public catalogue. The previous commit dismissed this
-- "only the route catalogue can reach it" -- which inverts the point:
the catalogue is the shipped artifact, so a key reaching only the
catalogue is a key no one checked before consumers read it. The check is
now split from the wrapper-type production and both styles run it.

The Ruby test could not fail. Its private predicate was a hand-copy of
generate_method's, so deleting the cursor guard from the generator left
it green -- the reviewer demonstrated exactly that. It now asserts on
the emitted method, with a link-style control so the assertion
discriminates rather than matching nothing.

Three things this branch asserted that were not true. "page" was never
an implemented pagination *style*, but page-number paging is real and
has its own SPEC section, so "implemented nowhere" was misleading about
the wrong thing. "Every generator rejects any other value" overclaimed:
the metadata emitters and the behavior model copy style through
unvalidated, so the refusal is a build gate, not a schema constraint.
And the style union in the generated TypeScript metadata still
advertised the word the trait had just retired.

Kotlin crashed on a literal null trait, where JsonNull is not Kotlin
null and the other five read it as unpaginated. Rust gained the absent,
null and non-string style cases Python and Ruby already covered, plus
the cursor-key refusal. A binding named `refused` held success().
A second adversarial pass, on two things the first missed.

The commit that withheld the key exists because TypeScript, Swift and
Kotlin would mistype a cursor operation -- and the tests went to Python
and Ruby, neither of which had the bug, asserting only that the key is
nil. Nothing reached the type resolution that is the whole point, so the
defect could have come back in all three affected languages with CI
silent.

typescript/tests/generator/pagination-return-type.test.ts already drives
buildReturnType directly, with fixtures of exactly the envelope shape at
issue. It gains a cursor pair: one asserting the operation types as its
envelope, and one showing it WOULD unwrap to the item type if the key
were passed through. The second is the one worth having -- it pins the
mechanism, so a regression cannot be misread as a fixture problem.

Swift was the one generator of six that could still ship a silently
non-walking method: `as? [String: Any]` answers nil for a trait that is
present but not an object, skipping the refusal entirely. Presence is
now tested before the cast, with a literal null still reading as absent
to match the other five.

And Go reads this trait nowhere -- its list wrappers are hand-written --
so the first cursor operation's Go wrapper is discipline rather than a
gate. Said plainly in the trait rather than guarded, because a guard
would block the mode the moment anyone used it.
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Two adversarial passes since the last update. Between them they found two blocking defects and three false claims in this branch's own text. All addressed; the tree is clean at 487e303a and that is the SHA under review.

The one I had waved away. A cursor operation's key reached the public route catalogue with nothing validating it — wrapped_items is the only thing that checks a key against the response schema, and it early-returns on .link(). My commit message dismissed this as "only the route catalogue can reach it", which inverts the point: the catalogue is the shipped artifact, so a key reaching only the catalogue is a key nobody checked before consumers read it. A reviewer proved it by running the generator — key: "totally_not_a_member" generated cleanly. The check is now split from wrapper-type production and both styles run it, with fixture cases.

A test that could not fail. The Ruby test guarding the second gate re-implemented the generator's predicate in a private helper, with a comment claiming the opposite. Deleting the guard from the real generator left it green. It now asserts on generate_method's emitted output with a link-style control, and I confirmed it fails when the guard is removed.

Tests were in the wrong languages. The key-withholding commit exists because TypeScript, Swift and Kotlin mistype a cursor operation — and I tested Python and Ruby, neither of which had the bug. tests/generator/pagination-return-type.test.ts now carries a cursor pair: one asserting the envelope type, one showing it would unwrap to the item type if the key were passed through, so the mechanism is pinned rather than just the outcome.

Swift could still silently pass. as? [String: Any] answers nil for a present-but-non-object trait, skipping the refusal — the exact failure mode "all six refuse by name" was written to eliminate, surviving in one of the six. Presence is now tested before the cast.

Three claims of mine that were not true, all corrected: "page" was never an implemented style, but page-number paging is real and has its own SPEC section, so "implemented nowhere" was misleading about the wrong thing; "every generator rejects any other value" overclaimed, since the metadata emitters and behavior model copy style through unvalidated; and the retired word still sat in the generated TypeScript metadata's style union.

Also: Kotlin crashed on a literal null trait where the other five read it as absent; Rust gained the absent/null/non-string style cases; SPEC §8 and the deprecated legacy trait no longer describe pagination as presence-based; and the trait now states plainly that Go reads it nowhere — its list wrappers are hand-written, so the first cursor operation's Go wrapper is discipline, not a gate. I documented that rather than adding a guard, since a guard would block the mode the moment anyone used it.

Still a no-op on today's spec: all 61 paginated operations are "link", GetPersonProgress is the only one with a key and keeps its wrapped treatment in all five, and regenerating all seven SDKs is byte-identical apart from pageParam? and "page" leaving the TypeScript metadata interface.

make check clean except rs-deny, which will not build here (cargo-deny's zstd-sys fails to link) and reads only Cargo.lock/deny.toml.

Three conflicts, all in generated output, all on the embedded generation
timestamp alone: ruby/lib/basecamp/generated/metadata.json, types.rb and
typescript/src/generated/metadata.ts. main's event feed operations (#898)
and this branch's PaginationConfig narrowing merged cleanly around them.

Resolved by taking main's files whole and regenerating from the merged
inputs (make rb-generate, make ts-generate) rather than picking hunks, so
the committed artifacts are what the generator actually produces. The
result is main's content plus this branch's only real generated change:
PaginationConfig.style loses "page" and pageParam goes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jorgemanrubia
jorgemanrubia requested a balanced review from Copilot September 16, 2026 11:18
@jorgemanrubia
jorgemanrubia merged commit e96b491 into main Sep 16, 2026
55 checks passed
@jorgemanrubia
jorgemanrubia deleted the pagination-cursor-style branch September 16, 2026 11:19
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Merged main in, and a pass on the merged head found the claim in this PR's own description was still not quite true. Head is now a106b26c.

The merge. main moved twice under this branch — #898 then #902 — and left three conflicts, all in generated output: ruby/lib/basecamp/generated/metadata.json, ruby/lib/basecamp/generated/types.rb, typescript/src/generated/metadata.ts. All three were on the embedded generation-timestamp line and nothing else; the event feed operations and this branch's PaginationConfig narrowing merged cleanly around each other.

Resolved by discarding both conflicted versions, taking main's file whole, and regenerating with make rb-generate and make ts-generate. A hand-picked hunk in a generated file that happens to compile is a lie that survives until the next regeneration, and the drift gates would not have caught a plausible one. The result is main's content plus this branch's only real generated change: PaginationConfig.style loses "page", and pageParam goes with it.

Still a no-op on the merged spec. Recounted: 265 operations, 61 pagination declarations, all "link", in openapi.json, behavior-model.json and the Smithy model alike. PollEvents, PollInbox and CreateStreamTicket declare no pagination trait, so #898's operations do not meet the new refusal at all.

What the pass on the merged head found. "All six generators refuse an unrecognised style by name" was true for a well-formed trait and false for a malformed one, in two of the six:

  • Kotlin gated the refusal on as? JsonObject, which answers Kotlin null for a present-but-non-object trait. A bare "x-basecamp-pagination": "page" skipped the refusal entirely and generated an unpaginated method for a spec that plainly said "page".
  • TypeScript gated it on truthiness, so false, 0 and "" were exempt and read as "not paginated".

That is the same hole this branch already closed in Swift two commits ago, and finding it twice more is the interesting part: the Swift fix was applied where a reviewer pointed rather than everywhere it belonged, which is exactly the failure this PR is about one level up. Both are fixed now — presence is tested before the cast in Kotlin, and TypeScript narrows through unknown and reads the key off the same narrowed value so it cannot outlive the style check. Kotlin also refuses a non-primitive style by name instead of throwing the cast error jsonPrimitive would.

Neither generator changes its answer for any operation in the spec, and both drift gates report no drift.

Tests in both languages, and I checked that they fail. The last test on this branch that re-implemented the predicate it was guarding stayed green when the guard was deleted from the real generator, so this time: reverting the TypeScript guard to truthiness turns exactly the false / 0 / "" cases red, and the Kotlin test drives the real OperationParser against an in-memory spec rather than a copy of its predicate. parseOperation is exported from the TypeScript generator to reach it — generator tooling, outside both the tsc build and the published export map, so it widens nothing shipped.

Gates. Full CI green on the merge commit (54 checks, 0 failing); re-running now on a106b26c. Locally: :generator:test (Kotlin, on Temurin 17 — the system JDK is 27 and mismatches the Kotlin target), the TypeScript suite at 1918 passing, typecheck, lint:test-timers, and both check-typescript-service-drift.sh and check-kotlin-service-drift.sh clean. cargo deny still will not build here (zstd-sys fails to link); CI covers it.

Reviews. The five Copilot threads were all the same finding — the generators accepting "page" and arbitrary values as unpaginated — and all five are addressed at this head; each has a reply pointing at the line that refuses, and they are resolved. Copilot's review itself is from the pre-merge head: this repo's ruleset sets review_on_push: false and a re-request is refused through the API, so there is no fresh Copilot pass on the merged head. The adversarial pass on a106b26c above returned no blocking finding.

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.

🟡 Changes recommended

Pagination validation still mishandles malformed Kotlin/TypeScript extensions, and the TypeScript test does not cover the parser gate.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

typescript/scripts/generate-services.ts:845

  • The truthiness check treats a declared but falsy extension (false, 0, or "") as absent, so it bypasses the new unsupported-style gate and emits an ordinary unpaginated operation. Rust and Swift reject any non-null extension before style dispatch; distinguish missing/null from other values here so malformed pagination cannot silently ship.
  if (operation["x-basecamp-pagination"] && paginationStyle !== "link" && paginationStyle !== "cursor") {
    throw new Error(
      `${operationId}: unsupported pagination style ${JSON.stringify(paginationStyle)} (expected "link" or "cursor")`
    );
  }

typescript/tests/generator/pagination-return-type.test.ts:228

  • This test constructs ParsedOperation directly, so it never exercises the new parseOperation gate that withholds paginationKey for cursor styles. Reverting generate-services.ts:852 to pass the key through would leave both assertions green while generated cursor methods again resolve TimelineEvent instead of the envelope; add a parser/integration assertion using a cursor OpenAPI operation.
          hasPagination: false,
          paginationKey: undefined,
  • Files reviewed: 19/22 changed files
  • Comments generated: 1
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.

jeremy added a commit that referenced this pull request Sep 16, 2026
)

Follow-up to #898 carrying the two review findings decided after it merged (the third, BareForbiddenError on PollInbox, landed in #898 itself).

1. The poll lanes' 410 is two shapes on two operations. PollEvents answers FeedPositionGoneError {error, epoch_after_id, resume}: epoch_after_id is now required and resume re-enters at since=<epoch_after_id>, so the servable history above the fence is not skipped. PollInbox answers a distinct InboxPositionGoneError {error, resume}: no epoch, resume re-entering at since=0, the earliest retained item. They were one shape with an optional epoch_after_id, so a consumer could write one errors.As arm that was silently wrong on one lane. The recoveries are not interchangeable, and the spec now says so.

2. A dedicated 400 with a reason. FeedRequestError {error, reason?} on both poll operations replaces the shared BadRequestError there. bc3 adds reason: "invalid_position" | "invalid_filter" beside error; reason is optional, and when it is absent the 400 is undifferentiated: a consumer surfaces it rather than guessing between re-entering with since= and stopping. Fixtures cover the 400 with reason on both lanes and the reason-less fallback.

CreateStreamTicket keeps @basecampIdempotent(natural: true): Ruby, TypeScript and Go classify retry off that flag, so dropping it fails check-idempotency-parity. The cursor pagination style from #905 is not applied to these operations here.

What changed:
- Spec: FeedRequestError (400), FeedPositionGoneError (410, PollEvents, epoch_after_id required), InboxPositionGoneError (410, PollInbox); the two poll operations' error lists and docs. openapi.json regenerated.
- Generated code, all seven SDKs. Generated Go: PollEventsResponse.JSON400 / PollInboxResponse.JSON400 are *FeedRequestErrorResponseContent; PollInboxResponse.JSON410 is *InboxPositionGoneErrorResponseContent; FeedPositionGoneErrorResponseContent.EpochAfterId is int64 (was *int64).
- Go wrapper: *FeedRequestError{Err, Reason} with FeedReasonInvalidPosition / FeedReasonInvalidFilter (Reason == "" is the undifferentiated case); *FeedPositionGoneError{Err, EpochAfterID int64, Resume} returned by PollEvents only; *InboxPositionGoneError{Err, Resume} returned by PollInbox only; each unwraps to the canonical *Error. Tests cover the reasoned and reason-less 400, the feed 410, and that an inbox 410 never types as the feed's.
- Conformance: event_feed.json gains the 400-with-reason case on each lane (12 cases, all seven runners); the 410 and 403 descriptions name the shapes.
- Docs / registry: SPEC section 23 "Wire Operations" rows and typed-error paragraph; spec/api-gaps/event-feed.md smithy_refs.

Breaking: generated Go types change shape (EpochAfterId pointer to value; the poll responses' JSON400/JSON410 types; PollInboxResponse loses its JSON403 body). The typed feed surface shipped in #898 is unreleased, so MIGRATING.md carries no entry.

Verification: make generate (no diff), all 46 static gates, go/ts/py/rb/rs/kt/swift checks, and check-fixture-execution across all seven runners, rebased on main at e58b582.

Review: 3 threads resolved (2 fixed, 1 declined with reasoning in the thread); Copilot and Codex reported on the merged head.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kotlin 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 swift typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants