Skip to content

fix(v2): give every collection one pagination contract and close four envelope holes - #6620

Open
waleedlatif1 wants to merge 2 commits into
stagingfrom
fix/v2-api-contract-hardening
Open

fix(v2): give every collection one pagination contract and close four envelope holes#6620
waleedlatif1 wants to merge 2 commits into
stagingfrom
fix/v2-api-contract-hardening

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes a set of v2 API contract defects found by stress-testing the live staging
surface across all 128 documented operations, and gives every v2 collection one
pagination contract instead of five divergent ones.

The defects, as observed

In Was Now
GET /workflows?limit=1.5 500 INTERNAL_ERROR 400 limit must be a whole number
GET /audit-logs?limit=1.5 500 INTERNAL_ERROR 400 (same)
limit=Infinity 400 expected number, received number 400 limit must be a number
POST with malformed/absent JSON {"error":"Request body must be valid JSON"} — a bare string {"error":{"code":"BAD_REQUEST","message":...}}
GET /api/v2/nonexistent Next.js HTML 404 document {"error":{"code":"NOT_FOUND","message":"Not found"}}
GET /skills?limit=1 200, 11 items, nextCursor present but unusable 200, 1 item, working nextCursor
GET /knowledge?limit=1 400 Unrecognized key: limit, yet nextCursor in the body 200, 1 item, working nextCursor

Root cause

limit was re-declared inline by every list. GET /workflows was copied from a
sibling and lost its .int(), so 1.5 passed validation and reached Postgres as
LIMIT 2.5. Same defect, independently, on GET /audit-logs. Meanwhile the same
param was validated five different ways across the API, and five collections
emitted nextCursor while accepting no limit at all — or accepting one and
silently discarding it, because Zod strips unknown keys unless the schema is
.strict().

So this is one problem — no shared pagination contract — wearing five hats, and
it is fixed once: v2PaginationFields() in lib/api/contracts/v2/shared.ts,
adopted by all 17 paged lists.

Why now, and what is user-visible

The v2-api rollout flag is off in production and enabled only for a
staging cohort. The four changes below are breaking the moment v2 goes GA, and
cost nothing today. This is the last cheap window.

Intentional, user-visible:

  1. Five collections now enforce a default page size. /skills,
    /custom-tools, /secrets, /credentials, /knowledge previously returned
    every row; they now return 50 by default (matching every already-paged v2
    list) with limit up to 100 and a working cursor. A caller that read the
    full set in one request now gets a page and must follow nextCursor.
  2. Unknown query params are rejected. Seven list queries gained .strict(),
    so a stray param is a 400 instead of being silently dropped. This is what
    turns "your limit was ignored" into an error you can see.
  3. An empty cursor= is now a 400 on /workflows, /workflows/{id}/versions
    and /logs. This is a deliberate behaviour change to three endpoints that
    previously accepted it, not a bug fix
    — they returned 200 and treated the
    empty value as "no cursor". The other five paged lists already rejected it, so
    this aligns them; and a client that serialises an unset variable into
    cursor= silently restarts at page one on every request and loops forever,
    which a 400 surfaces immediately. Called out here so it can be objected to.
  4. An offset cursor replayed under different filters or sort is now a 400 on
    /skills and /knowledge/{id}/documents. An offset names a position in one
    exact sequence; applied to a re-sorted or re-filtered one it names a different
    row, silently skipping or repeating results. The cursor is now stamped with
    the query state it was minted under and re-checked, which is what the keyset
    cursor's sort stamp has always done. limit is excluded from the stamp, so
    paging with a different page size still works.

Deliberately not changed: /files, /logs, /tables still truncate and
clamp an out-of-range limit rather than rejecting it. That leniency is
published in their OpenAPI description, so tightening it would break working
callers. It is now an explicit, named mode on the shared schema (outOfRange: 'clamp') and pinned by a test, rather than three hand-rolled copies.

Pagination, per collection

Both cursor schemes are the existing opaque base64-JSON codecs; no new format.

  • Keyset (encodeSortedCursor, the /workflows model) — /custom-tools,
    /credentials, /secrets, /knowledge. Each sort now ends in a unique id
    key; knowledge previously tie-broke on createdAt, which cannot separate two
    rows created in the same millisecond and would repeat or drop a row at a page
    boundary.
  • Offset (encodeCursor({offset}), the /knowledge/{id}/documents model) —
    /skills only, because it merges the 4-entry static builtin registry into the
    DB rows and re-sorts the merged array in JS. A SQL keyset cannot name a
    position inside that merged sequence: the builtins have no row for a WHERE to
    act on, and under createdAt asc they all sort ahead of every DB row. This is
    documented at the function.

Two correctness fixes that pagination forced:

  • Secrets: a JS post-filter (row.type === 'env_workspace' || row.envOwnerUserId === userId)
    ran after the query. Trimming rows after the page is cut hands the caller
    fewer than limit while nextCursor still claims more, so it moved into SQL.
    It drops exactly the rows the JS filter dropped and never widens visibility.
  • Skills: the list selected the full content column (up to 50k chars/skill)
    for every skill and discarded it in the presenter. The paged read projects
    without it.

Known limitation

/skills pagination bounds the response, not the DB read. Because the four
built-in skills merge in JS and the merged array is re-sorted before slicing,
LIMIT cannot be pushed down, so every page still reads the workspace's skill
rows. The projection now drops the content column (up to 50k chars/skill),
which is the large win; bounding the row count needs a second query to resolve
built-in shadowing across the truncation boundary, and that has a correctness
surface worth testing against a real database rather than landing blind here.

Not fixed, and why

405 responses still have no Allow header and an empty body. Next.js
generates that itself for a verb a route file does not export, before any Sim
code runs. Fixing it means either exporting rejecting handlers from all 77 v2
route files, or building a static path→methods table in proxy.ts that
duplicates the router. Both are worse than the defect; it is recorded as a known
gap in the new skill. Unknown paths are fixed by the catch-all.

limit=0x10 → 16, 1e2 → 100. Inherited from z.coerce.number()'s JS
numeric parsing. Both land inside the accepted range, so the bound is still
enforced. Left alone deliberately.

Preventing recurrence

list-pagination.test.ts already swept every v2 contract and pinned which lists
are paged. It now also asserts, over that same sweep:

  • every paged list rejects a fractional limit (this is what caught
    /audit-logs, which my first pass missed because the assertion was originally
    a hand-written list of contracts)
  • every list query is .strict()
  • the three clamping lists still truncate rather than reject

Plus .agents/skills/v2-api-conventions/SKILL.md (projected to
.claude/commands and .cursor/commands by scripts/sync-skills.ts) documenting
the envelope, the status-code table, the pagination contract, and a checklist.
The test's failure messages point at it.

Verification

  • bun run type-check, bun run check:api-validation:strict, bun run check:openapi — pass
  • 4784 tests across app/api/v2, lib/api, lib/knowledge, lib/credentials,
    lib/secrets, lib/workflows, lib/skills, lib/table, lib/uploads — pass
  • Each new test was proven to fail against the unfixed code before being kept

Not verified locally: no DB was exercised, so the generated keyset SQL is
covered by unit tests and the cursor algebra, not by a real paged read. A human
should confirm on staging: walk nextCursor to the end on each of the five newly
paged collections and check for duplicate or missing rows across page boundaries,
and confirm /api/v2/nonexistent returns the JSON 404.

Type of Change

  • Bug fix
  • Breaking change (three items flagged under "Intentional, user-visible" above)

Testing

  • bun run type-check, bun run lint:check, bun run check:api-validation:strict, bun run check:openapi — all pass
  • 4924 tests pass across app/api/v2, lib/api, lib/knowledge, lib/credentials, lib/secrets, lib/workflows, lib/custom-tools, lib/skills, lib/table, lib/uploads, lib/folders
  • Every new test was reverted against unfixed source and confirmed red before being kept
  • Not exercised against a database — see the verification note above for what a human should check on staging

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

… envelope holes

A fractional `limit` reached Postgres as `LIMIT 2.5` and answered 500 on both
`GET /workflows` and `GET /audit-logs`: each list re-declared the param inline,
and these two copies lost their `.int()`. The same divergence left `limit`
validated five different ways and five collections emitting `nextCursor` while
accepting no `limit` at all, or accepting one and silently discarding it.

Adds `v2PaginationFields()` in `contracts/v2/shared.ts` — a bounded integer
`limit` and an opaque `cursor` — and adopts it across all 17 paged lists, so the
family cannot drift again. `/files`, `/logs` and `/tables` keep the truncate-and-
clamp leniency they published, now as an explicit named mode rather than three
hand-rolled copies.

Gives `/skills`, `/custom-tools`, `/secrets`, `/credentials` and `/knowledge`
real pagination using the existing cursor codecs: a keyset for the four whose
page comes from one ordered SQL read, and the offset cursor for `/skills`, whose
merge of the static builtin registry into DB rows cannot be expressed as a SQL
keyset. Each keyset sort now ends in a unique `id`; knowledge tie-broke on
`createdAt`, which cannot separate rows sharing a millisecond.

Two correctness fixes pagination forced: the secrets visibility filter moved from
a post-query JS pass into SQL, because trimming rows after the page is cut
returns fewer than `limit` while `nextCursor` claims more; and the skills list
stopped selecting the 50k-char `content` column only to discard it.

Also restores the canonical error envelope where it had holes: a malformed JSON
body returned a bare `{"error":string}` because the envelope was a per-route
opt-in only 8 of 77 routes remembered, and an unknown `/api/v2` path returned an
HTML 404. Both are now defaults — `V2_PARSE_DEFAULTS` on the builders and the two
raw routes, and a catch-all whose body is byte-identical to the rollout gate's so
an unknown path stays indistinguishable from an ungated one.

Consolidates the keyset paging block (`resumeKeyset`/`keysetPage` in
`list-query.ts`) that had been open-coded in six modules, and folds the bespoke
`InvalidWorkflowListCursorError` into the `OrchestrationError` every other list
already used.

Prevention: the contract sweep in `list-pagination.test.ts` now also asserts that
every paged list rejects a fractional `limit`, that every list query is
`.strict()`, and that the three clamping lists still truncate. The fractional-
limit assertion is what caught `/audit-logs`. Documented in
`.agents/skills/v2-api-conventions/SKILL.md`.

405 responses still carry no `Allow` header — Next.js generates those before any
handler runs. Recorded as a known gap.
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 12, 2026 11:30am

Request Review

@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches many public v2 list contracts with intentional breaking pagination defaults for five collections, plus cursor/validation behavior. Mitigated by the v2-api rollout flag still being off in production.

Overview
Closes four v2 contract holes and replaces divergent per-route pagination with one shared contract via v2PaginationFields / v2LimitSchema.

Envelope fixes: fractional limit now rejects with 400 instead of reaching SQL as a 500; malformed JSON uses the canonical {error:{code,message}} envelope through builder V2_PARSE_DEFAULTS; unknown /api/v2/* paths return a JSON 404 (byte-identical to the rollout gate) instead of HTML.

Pagination: /skills, /custom-tools, /secrets, /credentials, and /knowledge now actually page (default 50, max 100) with working nextCursor. Keyset lists share readSortedCursor; offset lists (/skills, knowledge documents) stamp filter/sort scope so a cursor cannot silently resume under different query state. List queries become .strict() so ignored params are 400s. /files, /logs, and /tables keep published clamp/truncate limit behavior as an explicit shared mode.

Also adds conformance tests, OpenAPI updates, and the v2-api-conventions skill/checklist.

Reviewed by Cursor Bugbot for commit f77fddb. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR standardizes pagination and v2 response envelopes across collection endpoints, including shared limit validation, strict query schemas, and JSON transport errors.

  • Adds working keyset or offset pagination to previously unpaged collections.
  • Binds offset cursors to filtering, ordering, and resource scope.
  • Moves pagination-sensitive filtering into database queries and adds unique keyset tie-breakers.
  • Updates contracts, OpenAPI documents, route builders, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported offset-cursor query-state defect is fixed by stamping and validating all reachable sequence-defining parameters.

Important Files Changed

Filename Overview
apps/sim/app/api/v2/lib/response.ts Adds scoped offset-cursor encoding and strict replay validation; the prior query-state replay defect is addressed.
apps/sim/app/api/v2/skills/route.ts Stamps every reachable skills filter, sort, and workspace parameter into the offset cursor scope.
apps/sim/app/api/v2/knowledge/[id]/documents/route.ts Applies equivalent cursor-scope validation to document pagination using the knowledge-base, filter, search, and sort state.
apps/sim/lib/api/contracts/v2/shared.ts Centralizes standard and clamping pagination schemas with consistent validation and defaults.
apps/sim/lib/api/offset-cursor-scope.test.ts Covers valid replay, changed filters and sorting, malformed offsets, page-size changes, and scope key ordering.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Contract
  participant Route
  participant UseCase
  participant Store
  Client->>Contract: GET collection with limit, cursor, filters, sort
  Contract->>Contract: Validate strict query and pagination fields
  Contract->>Route: Parsed query
  Route->>Route: Decode cursor and verify query scope
  Route->>UseCase: Canonical pagination input
  UseCase->>Store: Read limit + 1 ordered records
  Store-->>UseCase: Page candidates
  UseCase-->>Route: Data, hasMore, pagination state
  Route->>Route: Encode next cursor under current scope
  Route-->>Client: "{ data, nextCursor }"
Loading

Reviews (2): Last reviewed commit: "fix(v2): bind the offset cursor to the q..." | Re-trigger Greptile

Comment thread apps/sim/app/api/v2/skills/route.ts Outdated
…s in

An offset names a position in one exact sequence. `GET /skills` accepted a bare
`{offset}` cursor and applied it to whatever sequence the next request asked
for, so following `nextCursor` with a different `search`, `sortBy` or
`sortOrder` silently skipped rows, repeated them, or landed past the end and
returned an empty page while the cursor implied more.

Fixed in the codec rather than the route so the sibling could not keep the gap:
`decodeOffsetCursor` now takes a scope stamp and rejects a cursor minted under a
different one, which is what `decodeSortedCursor` has always done for keysets.
`offsetCursorScope()` builds the stamp from every param that filters or orders
the sequence; `limit` is excluded because it selects how much of the sequence to
return, not what the sequence is, so paging with a different page size still
works.

`GET /knowledge/{id}/documents` had the identical latent gap and gets the same
treatment — the compiler surfaced it as soon as the signature changed.

@cursor cursor Bot 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f77fddb. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant