Accepted
2026-03-18
All list endpoints (GET /v1/mitigations, GET /v1/events, GET /v1/audit) used offset-based pagination (?limit=100&offset=200). This has well-known problems at scale:
-
Drifting windows — When new rows are inserted between page fetches, offset-based queries skip or duplicate rows. During an active attack, mitigations are created continuously, making offset pagination unreliable for operators paging through results.
-
O(n) cost — PostgreSQL must scan and discard
offsetrows before returning results. Page 100 of 100-row pages requires scanning 10,000 rows. This becomes a performance problem as the mitigations table grows. -
No stable reference — Offsets are positional, not stable. A client cannot bookmark "where I left off" across requests if the underlying data changes.
Cursor-based pagination solves all three: the cursor is a stable reference to a specific row's timestamp, the database seeks directly to that position via an indexed WHERE clause, and insertions don't affect the cursor's position.
Replace offset-based pagination with cursor-based pagination on all three list endpoints. This is a breaking change — the offset query parameter is removed entirely rather than supported alongside cursors during a transition period.
The cursor is a base64-encoded RFC 3339 timestamp of the ordering column:
| Endpoint | Ordering column | Example cursor (decoded) |
|---|---|---|
GET /v1/mitigations |
created_at |
2026-03-18T12:00:00Z |
GET /v1/events |
ingested_at |
2026-03-18T12:00:00Z |
GET /v1/audit |
timestamp |
2026-03-18T12:00:00Z |
-- First page (no cursor)
SELECT ... FROM mitigations
WHERE (filters)
ORDER BY created_at DESC
LIMIT $limit + 1
-- Subsequent pages
SELECT ... FROM mitigations
WHERE (filters) AND created_at < $cursor
ORDER BY created_at DESC
LIMIT $limit + 1Fetching limit + 1 rows determines has_more without a separate COUNT query. The extra row is not returned to the client.
All three endpoints gain next_cursor and has_more fields:
{
"mitigations": [...],
"count": 20,
"next_cursor": "MjAyNi0wMy0xOFQxMjowMDowMFo=",
"has_more": true
}next_cursorisnullwhen there are no more pages.has_moreis a convenience boolean so clients don't need to check for null.- First request omits the
cursorparameter to get the most recent page.
Supporting both offset and cursor during a transition period was considered and rejected:
- No known external consumers depend on offset pagination today — the API is pre-v1.0.
- Dual support adds handler complexity (which mode? what if both are provided?).
- Offset encourages patterns we want to discourage (deep pagination in automation scripts).
- A clean break now avoids a deprecation cycle that delays the inevitable.
- Breaking — Clients sending
?offset=Nwill receive an error or have the parameter ignored. Documented in CHANGELOG as a breaking change. - Frontend — Page number navigation replaced with cursor stack (push on "Next", pop on "Previous"). Selecting a new filter or date range resets the cursor.
- prefixdctl — The
listsubcommands switch from--offsetto--cursor(or omit for first page). - Performance — Deep pagination cost drops from O(offset + limit) to O(limit). All ordering columns are already indexed.
- Consistency — All new list endpoints must follow this pattern. No future endpoint should use offset.