Offsets are not slow, they are wrong
LIMIT 50 OFFSET 50 re-runs the whole ordering and counts forward. If rows are
inserted before the cursor position between two requests, everything shifts by
one and the client silently skips a row. Findings are written constantly —
every scan produces some.
For a person browsing a backlog that is an annoyance they will probably never
notice. For a machine enumerating findings to decide whether a release ships,
it is a clean result reported by a gate that never saw the row that mattered.
No error is raised, because from the database’s point of view nothing went
wrong.
Keyset pagination asks a different question — “give me what comes after this
exact position” — which no concurrent insert can perturb.
Reading a page
next_cursor back to fetch the following page:
next_cursor is absent on the final page, and has_more is false. Loop
until has_more is false rather than until a page comes back short — a page
may be short for reasons that have nothing to do with being last.
Two orders, two guarantees, both declared
The default is
severity, because the default caller is the dashboard and a
person works a backlog worst-first.
Machines should enumerate with order=recent. It is the order whose keys
cannot change underneath a traversal, which is exactly what makes the traversal
exhaustive.
severity is not broken; it is honest about what it is. Severity is re-rated
when advisory data improves, and last-seen changes on every scan. A finding
that moves mid-traversal moves relative to your cursor, so it can land on a
page you have already read or on one you have already passed. That is fine for
browsing and unsuitable for anything that must be complete.
Pretending both orders are equivalent is how a client comes to trust a
traversal it should not, which is why every response reports page.stable.
Assert it rather than assume it:
order value is refused rather than defaulted. Falling back would
hand a caller who asked for the exhaustive order the browsable one, and it
would traverse happily while missing rows — a failure the caller has no way to
detect.
What a cursor is
A cursor is a position, not a credential and not a query.
The tenant binding is the one that gets left out, on the reasoning that the
signature already prevents forgery. It does — and forgery is not the threat.
The threat is a genuine cursor arriving somewhere it was never meant to be.
Changing
order or any filter parameter invalidates the cursor you are
holding. Keep them identical for the whole traversal:
Cursors expire after 24 hours
This is not a security control — the signature is. It exists because a traversal resumed a day later silently misses everything created since, and a client that believes it enumerated a set while holding a stale position is wrong in the direction that matters for a security product. Failing loudly beats paginating through a set that no longer exists. An expired cursor, a forged one, one for the wrong tenant and one for a different filter set all produce the same error. Telling a caller their cursor was valid but for the wrong tenant would confirm it was issued to somebody, which is a disclosure a paging error has no business making.Limits and the effective page size
limit accepts 1–200 and defaults to 50. A larger value is clamped, and
the effective value is reported in page.limit.
Read the echoed value rather than the one you sent. Clamping silently inside
the query would tell a client that its request for 100,000 rows succeeded, and
a client paginating on its own assumption would skip almost everything.
A cursor longer than 4096 characters is rejected before any work is done.
total
total is returned alongside every page. It is a count of the whole filtered
set, not of the page.
Treat it as advisory. It is computed independently of the page you are holding,
so under concurrent writes it can disagree with what a full traversal yields —
and it is recorded internally as the part of a listing that will become
expensive before the rows do.
Which listings are cursor-paginated
GET /assets, GET /assets/{id}/findings, GET /scans, GET /findings and
GET /findings/{id}/events take limit and cursor; the finding listings
also take order.
The remaining listings — schedules, policies, policy versions, verdicts,
webhook deliveries — are bounded by limit rather than paginated, because the
answer is always near the top and the collections do not grow without bound.
See Endpoints.
One consequence, named
The asset listing is ordered by creation, not by “most recently scanned first”. The latter cannot be paginated safely:last_scanned_at changes on every scan,
so a row moves mid-traversal. It is also its own usability problem — a list
that reorders itself while somebody is reading it.
