Skip to content

fix(pagination): fix reverse paging and reject stale-sort cursors - #3131

Open
mcm wants to merge 2 commits into
TracecatHQ:mainfrom
mcm:mcm/fix-prev-cursor
Open

fix(pagination): fix reverse paging and reject stale-sort cursors#3131
mcm wants to merge 2 commits into
TracecatHQ:mainfrom
mcm:mcm/fix-prev-cursor

Conversation

@mcm

@mcm mcm commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

Reverse cursor pagination (reverse=true) returns the wrong page.

prev_cursor itself is computed correctly — it encodes the first row of the current page. The path that consumes it is what's broken. In CasesService.search_cases, the reverse branch inverted the cursor WHERE clause but never inverted the ORDER BY: the sort branched only on sort_direction, and the rows were never reversed back before returning. So a backward query selected everything preceding the cursor and then kept the top limit of it in forward order.

With the default created_at desc, limit=20:

Request Rows matched by WHERE Returned Expected
page 2 → back 1–20 1–20 ✅ 1–20
page 3 → back 1–40 1–20 ❌ 21–40
page 6 → back 1–100 1–20 ❌ 81–100

Going back from page 2 worked by accident — the preceding set is exactly one page. From page 3 on, you were teleported to the top of the list.

Two more defects in the same block:

  • prev_cursor was None on every reverse response. The if params.reverse: branch assigned next_cursor and fell through the else, so prev_cursor was never set — you could step back once, then the Previous control was dead, while has_previous still reported true.
  • has_more / has_previous were not swapped. In reverse mode has_more describes rows behind the cursor but was returned with forward semantics.

Who was exposed

Not the cases table UI — use-cursor-pagination.tsx hardcodes reverse: false and keeps its own client-side cursor stack. But reverse is public API surface on the cases endpoints and is plumbed through the registry actions (core/cases.py) and the SDK (sdk/cases.py), so workflow authors and agents paginating backwards hit it silently: wrong rows, no error.

Scope

The audit found six more listings with subsets of the same defect:

Service ORDER BY inverted Page re-reversed Cursors swapped Flags swapped
cases/service.py search_cases
tables/service.py list_rows
cases/rows/service.py ❌ (no-op) ❌ (double)
agent/skill/service.py (×2)
agent/preset/service.py
workflow/management/management.py
EE inbox agent_runs.py

Two worth calling out:

  • tables/service.py has the same page-1 teleport as cases — it reverses the page and swaps the cursors, but the ORDER BY only ever branched on sort_direction.
  • cases/rows/service.py applied its reverse ORDER BY to a statement that already had one. Select.order_by() appends, so the first key won and the inversion was a no-op. It also derived the cursors from the already-reversed page and then swapped them a second time, pointing both the wrong way.

Changes

Shared helpers in tracecat/pagination.pytake_cursor_page() trims the limit + 1 over-fetch; build_cursor_page() puts the rows back into display order and swaps the cursors and flags. All seven call sites now route through them, so the trim/reverse/swap step has one definition and one set of tests. Sort-aware callers derive the scan direction with scan_ascending = (sort_direction == "asc") != params.reverse.

One behavioral refinement: on an empty reverse page, has_more is derived from next_cursor is not None rather than cursor is not None, so an empty backward page can't advertise a page there's no cursor to reach. This mirrors the reasoning already documented in the inbox provider's grouped path.

Stale-sort cursors now 400 instead of silently rewinding. The mismatch was already detected (cursor_data.sort_column == sort_column), but nothing acted on it: the cursor filter was dropped entirely and the query returned the first page while the response still advertised prev_cursor and has_previous=true. Tells that this was an unwritten else rather than a decision: cursor_id was decoded and then went unused on that path, and the sibling implementation in workflow/management/management.py does write the branch (ID-only fallback, with a comment). Both call sites now call validate_cursor_sort_column(), which raises InvalidCursorError — a ValueError subclass, which the cases and tables routers already map to 400, so no router changes were needed.

This is the one deliberately behavior-changing part of the PR. It's easy to drop if you'd rather keep the current leniency. Note it also rejects sortless cursors (sort_column: None), which only pre-#1749 code paths produced; softening that to an ID-only fallback would be a one-line change. The cases table UI is unaffected either way — use-cursor-pagination.tsx:93-100 resets pagination state in the same render as any sort change, so it never submits a stale-sort cursor.

Tests

tests/unit/test_cursor_page.py (new, 16 tests) covers the helpers directly: the page-3 → back → page-2 walk, cursor round-tripping in both directions, the boundary at page 1, the empty reverse page, and the six validator cases.

Service-level regression tests were added where none existed — no test exercised reverse=True for cases at all before this:

  • test_cases_service.py: reverse paging under desc, asc, and enum (priority) sorts, plus stale-cursor rejection
  • test_tables_service.py: multi-page backward walk, plus stale-cursor rejection
  • api/test_api_cases.py: confirms InvalidCursorError surfaces as 400 end-to-end

Verification

  • tests/unit -m "not (slow or integration or temporal)": 6547 passed, 24 skipped, 8 failed — all 8 failures are subprocess.TimeoutExpired in nsjail sandbox tests that shell out to docker compose run --build, unrelated to this change and failing identically on a clean tree. -- these are almost certainly just from my local environment and should pass on the CI run.
  • ruff check / ruff format --check: clean.
  • basedpyright --warnings: 0 errors, 0 warnings.
  • The ORDER BY inversion was additionally checked against compiled SQL for all four sort × reverse combinations.

🤖 Generated with Claude Code


Summary by cubic

Fixes reverse cursor pagination across listings so "Previous" returns the correct page, and returns 400 for cursors from a different sort. Also supports NULL sort cursors for table rows and unifies keyset logic for consistent behavior.

  • Bug Fixes

    • Reverse paging now returns the preceding page with correct ORDER BY inversion, page re-reversal, and cursor/flag swaps. Affected: cases, tables, case rows, agent skills & versions, agent preset versions, workflow management, and EE inbox agent runs.
    • Cursors and flags are now consistent in both directions (e.g., prev_cursor set in reverse; empty reverse pages don’t advertise an unreachable next page).
    • Stale-sort cursors now raise InvalidCursorError (HTTP 400) instead of returning page 1.
    • Tables now follow sort_value: null cursors for nullable sort columns and handle NULLS FIRST/LAST via keyset_filter; EE inbox agent runs validates cross-sort cursors and only allows created_at/updated_at. Shared helpers take_cursor_page, build_cursor_page, and validate_cursor_sort_column unify trim/reverse/swap and validation across services.
  • Migration

    • Clear/reset the cursor when changing sort. Clients will get 400 if they send a cursor created under a different sort.

Written for commit dced07b. Summary will update on new commits.

Review in cubic

@zeropath-ai

zeropath-ai Bot commented Jul 26, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to dced07b.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► packages/tracecat-ee/tracecat_ee/inbox/providers/agent_runs.py
    - Normalize sort column to created_at/updated_at and validate cursor sort column
► tests/unit/test_cursor_page.py
    - Added comprehensive tests for cursor page building, validation, and edge cases
► tests/unit/test_inbox_agent_runs_cursor.py
    - Added tests for cross-sort cursor validation in agent runs inbox provider
Enhancement ► tracecat/agent/preset/service.py
    - Use take_cursor_page and build_cursor_page for paginated versions listing
Enhancement ► tracecat/agent/skill/service.py
    - Use take_cursor_page and build_cursor_page for paginated listing of skills
Enhancement ► tracecat/cases/rows/service.py
    - Integrate take_cursor_page/build_cursor_page for list_rows pagination; handle reverse sorting and appropriate cursor encoding
Enhancement ► tracecat/pagination (implied in multiple files via imports and usage in tests)
Enhancement ► tests/unit/test_tables_service.py
    - Import and adapt to new pagination helpers (BaseCursorPaginator, InvalidCursorError) and related cursor handling
Enhancement ► tests/unit/api/test_api_cases.py
    - Added test for 400 response on stale sort cursor (InvalidCursorError)
Enhancement ► tests/unit/test_cases_service.py
    - Added tests for reverse pagination behavior, including enum sorts and rejecting cross-sort cursors; added tests for cursor validation and NULL handling in sort boundaries
Enhancement ► tests/unit/test_cursor_page.py (new)
    - Implemented in-depth tests for cursor paging helpers: take_cursor_page, build_cursor_page, keyset_filter, validate_cursor_sort_column, etc.

@daryllimyt

Copy link
Copy Markdown
Contributor

@codex review

@daryllimyt daryllimyt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for two cursor-contract gaps: server-issued NULL table-sort cursors cannot be consumed, and the touched inbox provider still accepts cross-sort cursors.

Comment thread tracecat/pagination.py
f"but this request sorts by {sort_column!r}. "
"Restart pagination without a cursor after changing the sort."
)
if cursor.sort_value is None:

@daryllimyt daryllimyt Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats every null sort value as a malformed or missing cursor, but TablesService.list_rows() can issue exactly that cursor: custom table columns default to nullable=True, and the cursor encoder passes row.get(sort_column) through unchanged. With more than limit rows sorted descending by a newly added nullable column, PostgreSQL puts the NULL rows first, so page 1 returns a next_cursor containing "sort_value": null; following it now returns 400 and makes all later rows unreachable.

Please distinguish an omitted legacy sort field from an explicit NULL and implement a NULL-aware keyset order and predicate, including the NULL/non-NULL transition, in both directions. Add a regression test for this server-issued cursor.

cursor=cursor,
reverse=reverse,
has_more=has_more,
encode_cursor=lambda session: self.encode_cursor(

@daryllimyt daryllimyt Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This provider now uses the canonical page builder, but its cursor consumers still do not validate cursor_data.sort_column against the requested sort_col in either the ungrouped or grouped path. A created_at cursor submitted with order_by=updated_at is accepted; I reproduced compiled SQL applying that timestamp to agent_session.updated_at < ..., which can skip or duplicate rows instead of returning 400.

Please call validate_cursor_sort_column(..., sort_column=sort_col, expected_type=datetime) in both decode paths before applying the keyset predicate.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c10c8713f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tracecat/pagination.py
Comment on lines +190 to +194
if cursor.sort_value is None:
raise InvalidCursorError(
f"Cursor is missing a sort value for column {sort_column!r}. "
"Restart pagination without a cursor."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve NULL sort values in table cursors

When table rows are sorted by a nullable custom column, TablesService.list_rows can legitimately encode sort_value=None from the page boundary (row.get(sort_column)), but this check rejects that service-issued cursor on the next request. Since custom table columns are nullable by default and PostgreSQL places NULLs first for descending sorts, a table with more than one page of unset values returns page 1 successfully and then HTTP 400 instead of continuing; NULL therefore needs an explicit cursor representation and NULL-aware keyset predicate rather than being treated as a missing value.

AGENTS.md reference: tracecat/AGENTS.md:L97-L99

Useful? React with 👍 / 👎.

mcm and others added 2 commits August 3, 2026 16:45
Reverse (`reverse=true`) cursor pagination was broken across several
listings. The cases list inverted its cursor WHERE clause but never
inverted the ORDER BY, so the query selected every row preceding the
cursor and then kept the first `limit` of them in forward order: paging
back from page 3 returned page 1, not page 2. It also never set
`prev_cursor` on a reverse response and never swapped
`has_more`/`has_previous`, so the Previous control went dead after one
step while the response still reported `has_previous=true`.

Six other listings had subsets of the same defect:

- tables `list_rows`: no ORDER BY inversion, so the same page-1 teleport
- case table rows: the reverse ORDER BY was appended to the one already
  on the statement, making it a no-op, and cursors were derived from the
  already-reversed page and then swapped a second time
- agent skills (skills and versions) and agent preset versions: the page
  was never reversed back into display order and neither the cursors nor
  the flags were swapped
- workflow management: `has_more`/`has_previous` never swapped
- EE inbox agent runs: reverse-mode cursor guards and flags

Add `take_cursor_page` and `build_cursor_page` to `tracecat/pagination.py`
and route all seven call sites through them so the trim/reverse/swap step
has a single definition and a single set of tests.

Also reject a cursor whose `sort_column` does not match the sort of the
request carrying it. Previously the mismatch was detected and then the
cursor filter was dropped entirely, returning the first page while still
advertising `prev_cursor` and `has_previous=true`. `InvalidCursorError`
subclasses `ValueError`, which the cases and tables routers already map
to 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses review feedback on TracecatHQ#3131.

Table rows sort on user-defined columns, which are nullable by default, so
list_rows legitimately encodes a cursor whose sort value is NULL whenever a
page boundary lands inside the NULL block. Treating that server-issued
cursor as malformed returned 400 on page 2 and made every later row
unreachable.

CursorData.has_sort_value now distinguishes an explicitly serialized
sort_value: null from a legacy cursor that omits the key, and
validate_cursor_sort_column grows allow_null for columns that admit NULLs.
The new keyset_filter builds the matching predicate: NULL placement follows
PostgreSQL's defaults, which invert consistently (ASC NULLS LAST reversed is
DESC NULLS FIRST), so an ascending scan treats NULL as greater than every
value and a descending scan as less, and only the id tie-breaker orders rows
inside the NULL block. list_rows uses it, collapsing four duplicated cursor
branches into one scan direction and spelling out NULLS FIRST/LAST in ORDER
BY so the ordering matches the predicate. NOT NULL columns pass
nullable=False and keep their original two-branch predicate.

The agent runs inbox provider validated no cursor at all, so a created_at
anchor submitted with order_by=updated_at compiled into a predicate against
the wrong column and could skip or duplicate rows. Both the grouped and
ungrouped decode paths now validate before applying the keyset predicate.
The ungrouped path also normalizes sort_col to the two columns the query can
actually order by, matching the grouped path, so an unrecognized order_by
cannot mint a cursor labelled with a column the scan never sorted on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mcm
mcm force-pushed the mcm/fix-prev-cursor branch from c10c871 to dced07b Compare August 3, 2026 20:47
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.

2 participants