Skip to content

feat(phase9 #97 D10.e): cursor pagination contract + 18 unit tests#1712

Merged
earayu merged 2 commits intomainfrom
bryce/phase9-task97-d10e-pagination-cursor
Apr 25, 2026
Merged

feat(phase9 #97 D10.e): cursor pagination contract + 18 unit tests#1712
earayu merged 2 commits intomainfrom
bryce/phase9-task97-d10e-pagination-cursor

Conversation

@earayu
Copy link
Copy Markdown
Collaborator

@earayu earayu commented Apr 25, 2026

Summary

Write set (per §G D10.e boundary)

File Purpose
aperag/mcp/cursor/__init__.py Public surface
aperag/mcp/cursor/codec.py CursorPayload + encode_cursor / decode_cursor + is_expired (DEFAULT_TTL_SECONDS=3600 per §C.4, CURSOR_SCHEMA_VERSION=1)
aperag/mcp/cursor/invariants.py compute_invariant_hash over (sort_key + filters + collection_id + tenant_id + index_id)
aperag/mcp/cursor/schemas.py PaginationParams (cursor + limit conint 1..200) + PaginationResult[T] generic
aperag/mcp/cursor/errors.py 6 canonical snake_case codes (per §C.3 + #1710 amendment) + CursorError + CursorErrorEnvelope Pydantic + SILENT_RESET_FORBIDDEN=True anti-pattern guard
tests/unit_test/mcp/test_cursor_contract.py 18 tests, all green

Deferred to follow-up (post-#1711 merge)

Per §G D10.e write-set the lane also includes:

  • aperag/service/pagination.py — seek-pagination integration helper that wraps cursor encode/decode against D10.c list_collections / list_documents / read_document_chunk ORM queries
  • tests/e2e_http/hurl/<NN>_d10_pagination.hurl — cross-tool e2e flow

Both depend on D10.c's actual primitive implementations landing (currently stub-only on #1711), so they ride a follow-up PR on the same task lane after #1711 merges. Splitting keeps the cursor contract reviewable in isolation without coupling to D10.c implementation churn.

Cross-lane discipline (per architect msg=669db73c)

  • Canonical error codes match docs(modularization): D10 spec amendment — back-align §G D10.e cursor error codes to §C.3 canonical #1710-corrected SSoT verbatim — cursor_invalid / cursor_expired / cursor_filter_mismatch / cursor_tenant_mismatch / cursor_index_changed / cursor_schema_unsupported. No SCREAMING_SNAKE / collapsed-mismatch / CURSOR_FOREIGN / CURSOR_PAGE_OUT_OF_RANGE retained.
  • SILENT_RESET_FORBIDDEN = True pins the §C.3 anti-pattern: a server that quietly resets to first page on cursor failure is forbidden — the explicit-not-silent contract is loud-fail.
  • Each canonical code carries its §C.3 client recovery path in module docstring (restart vs surface-to-user vs retry-from-null). The test surface parametrises across all six codes so any future addition / collapse cannot silently land.

Test plan

  • uv run pytest tests/unit_test/mcp/test_cursor_contract.py -v — 18 passed in 0.72s
  • uv run ruff check aperag/mcp/cursor tests/unit_test/mcp/test_cursor_contract.py — clean
  • uv run ruff format --check — clean
  • CI lint-and-unit / e2e-http-smoke / provider-preflight / e2e-http-provider — pending PR open

🤖 Generated with Claude Code

Bryce added 2 commits April 26, 2026 03:38
Per design pack §C (canonical post-#1710 SSoT):
- aperag/mcp/cursor/codec.py: CursorPayload (sort_key + last_position + invariant_hash + issued_at + ttl_seconds 1h default + server_id + schema_version 1) + base64url JSON encode/decode + is_expired TTL check
- aperag/mcp/cursor/invariants.py: compute_invariant_hash sha256 over (sort_key + filters + collection_id + tenant_id + index_id) deterministic across dict ordering
- aperag/mcp/cursor/schemas.py: PaginationParams (cursor + limit conint 1..200) + PaginationResult[T] generic (items + next_cursor + total_count)
- aperag/mcp/cursor/errors.py: 6 canonical snake_case codes (cursor_invalid / cursor_expired / cursor_filter_mismatch / cursor_tenant_mismatch / cursor_index_changed / cursor_schema_unsupported per §C.3 + #1710 amendment) + CursorError exception + CursorErrorEnvelope wire shape + SILENT_RESET_FORBIDDEN guard
- aperag/mcp/cursor/__init__.py: public surface for D10.c read primitive imports

tests/unit_test/mcp/test_cursor_contract.py:
- 5 codec round-trip / wire format / TTL boundary tests
- 2 invariant_hash determinism + binding sensitivity tests
- 7 error envelope round-trip tests (parametrized over each canonical code) + SILENT_RESET_FORBIDDEN pin
- 4 PaginationParams/PaginationResult shape tests including end-to-end cursor flow

Pending D10.c stub head landing for `aperag/service/pagination.py` integration helper + `tests/e2e_http/hurl/<NN>_d10_pagination.hurl` cross-tool e2e — those are Window 1 work.
…_cursor

Per Weston msg=cc4a3ab0 二线 CR blocker: decode_cursor() previously
surfaced malformed / wrong-schema / expired wire payloads as bare
ValueError / KeyError, leaving every D10.c / D10.d caller to
re-derive the canonical mapping. That violates the §C.3
explicit-not-silent invariant by construction — any forgotten
mapping silently degrades into ValueError → tool error → first-page
restart, which is exactly the anti-pattern SILENT_RESET_FORBIDDEN
guards against.

Fix:
- decode_cursor() now raises CursorError directly with the right
  canonical code: cursor_invalid (malformed wire / base64 / json /
  missing field), cursor_schema_unsupported (unknown schema_version),
  cursor_expired (past issued_at + ttl_seconds clock).
- _decode_cursor_payload() preserved as a private structural-only
  decode for tests that need to craft expired / wrong-schema
  payloads to exercise the canonical error paths.
- 3 new canonical-code tests + 1 internal-decode escape hatch
  test added; old raw-error test deleted (pre-#1710 wire shape
  no longer reachable through public surface).

_payload() fixture's issued_at now defaults to current time so
round-trip tests stay green when run far from the fixture's
drafting date; tests that need expired / fixed payloads override
explicitly.

21/21 tests pass; ruff check + format clean.
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