Skip to main content

CursorProof

Check that cursor pagination returns the correct stream, not just successful pages.

Every request can return 200 OK while the complete traversal loses records, duplicates them, or enters a cursor cycle. CursorProof checks the whole traversal and can compare it with an independent authoritative oracle.

Install and run

Requires Python 3.13 or newer. After the first tagged release is published, install the CLI from PyPI:

pipx install cursorproof
# or
python -m pip install cursorproof

To install from this checkout instead:

uv sync
uv run cursorproof --help
uv run cursorproof check examples/broken.yml

To install the CLI from the checkout into an isolated tool environment:

uv tool install .

Start the demo in one terminal:

uv run python examples/demo_api.py

In another terminal:

uv run cursorproof run examples/broken.yml
uv run cursorproof replay cursorproof-repro.json
uv run cursorproof run examples/correct.yml --format json --repro correct-repro.json

The demo has 40 records with repeated sort values. /broken uses only the timestamp for continuation, so page boundaries skip records despite successful HTTP responses. /correct also uses the unique ID. The broken configuration reports CP003 MISSING_ITEMS and differences between limits; the correct configuration passes.

Releases

Update the version in pyproject.toml, then push a matching v-prefixed tag (for example, v0.1.0):

git tag v0.1.0
git push origin v0.1.0

The release workflow runs the checks, builds a wheel and source distribution, creates a GitHub Release with generated notes and both files, and publishes those same files to PyPI through Trusted Publishing.

Before the first release, create a GitHub Actions environment named pypi and configure the matching PyPI Trusted Publisher. Restrict the environment to v* tags and add required reviewers if desired. The workflow and publisher must keep the same environment name.

Configuration

url: http://localhost:8000/api/orders
headers:
  Authorization: Bearer ${API_TOKEN}
parameters:
  status: open
pagination:
  cursor_param: cursor
  limit_param: limit
  terminal_values: [null]
response:
  items: $.results
  next_cursor: $.next_cursor
  has_more: $.has_more # optional, but must be boolean when configured
  id: $.id
ordering:
  - field: created_at
    direction: desc
    type: datetime
    nulls: last
  - field: id
    direction: desc
    type: number
limits: [1, 2, 5, 10, 50, 51, 100]
repeats: 2
consistency: static
timeout: 10
max_pages: 10001
max_items: 100000
max_response_bytes: 10000000

JSON configuration is also supported (.json). Unknown fields are rejected. Environment references are expanded after parsing, so quotes and newlines in values cannot inject YAML. Missing or empty variables are errors. Configuration validation does not send requests or execute commands.

Paths support $, dotted fields ($.results, $.meta.cursor), and array indices ($.data[0].id); they are a small notation, not full JSONPath. Item paths are relative to each item. Bare field names such as created_at are allowed.

IDs must be strings or integers; "1" and 1 are different identities. Cursor values must be strings or explicit terminal values. An empty string is a valid cursor unless added to terminal_values. A missing cursor field is an error. An empty page with a continuation cursor is allowed. A short page alone does not signal completion. Ordering is lexicographic across all configured fields, within and between pages. type is auto, string, number, or datetime; auto preserves JSON numbers/strings, and mixed incompatible types are errors. Datetime ordering requires ISO 8601 with a timezone. String ordering uses Unicode code point order, not a database locale/collation. nulls is absolute, independent of ascending/descending direction.

Defaults: limits [1, 10, 50, 51, 100], one repetition, static consistency, and the resource limits shown above. Limits apply per traversal, response size per response. HTTP timeout is HTTPX's timeout per network operation, not a total run deadline. Requests are sequential; redirects are not followed, retries are not automatic. URL query parameters are preserved; parameters overrides matching query values.

Oracle

An oracle must contain exactly the same filtered/authorized population as the API. It is fetched once before traversals. Static mode requires unchanged data throughout.

An HTTP oracle returning an array of IDs:

oracle:
  url: http://localhost:8000/internal/orders/all
  headers:
    Authorization: Bearer ${ORACLE_TOKEN}
  items: $.ids
  id: $
  ordered: true

Or a command returning one string ID per line:

oracle:
  command:
    - psql
    - ${DATABASE_URL}
    - -Atc
    - SELECT id FROM orders ORDER BY created_at DESC, id DESC
  format: lines
  ordered: true

Commands are argument arrays, executed without a shell, relative to the config's directory. A command inherits the process environment. Command stderr is not included in reports. Oracle commands and mutation hooks must wait for their work to finish and must not detach child processes. A timeout may not stop a descendant that starts a new session or redirects the supervisor's output streams. Use format: json (default) to preserve integer IDs; a lines oracle always produces string IDs. JSON oracles can use items and id paths. Duplicate oracle IDs are an oracle error. Oracle credentials/cookies are isolated from the pagination client. Set ordered: false for set equality, still checking API duplicates separately.

Without an oracle, CursorProof checks internal consistency but cannot establish completeness. Equal streams across limits can all omit the same records.

Consistency and mutation hooks

static is the default. All traversals must observe the same unchanged dataset; complete ordered ID streams are compared across limits and repetitions. Set repeats: 2 or more to test repeated traversal at each limit.

snapshot requires an ordered oracle and response.snapshot_fields: [$]. The oracle must return full JSON records with the same shape as the API. CursorProof fingerprints each complete record before traversal and detects membership, order, or content changes. It cannot create a shared database/API snapshot through generic HTTP, so the test environment must ensure the oracle represents the same snapshot, including the interval before the first page.

consistency: snapshot
response:
  items: $.results
  next_cursor: $.next_cursor
  id: $.id
  snapshot_fields: [$]
oracle:
  url: http://localhost:8000/internal/orders/all
  items: $.results
  id: $.id

live-keyset checks observed order and duplicate identities with immutable sort keys and identities. It allows inserts/deletes and makes no completeness claim. It requires immutable_ordering: true and configured ordering; an oracle is rejected because a static list cannot describe general concurrent-write visibility. Updating sort keys or reusing deleted identities is outside this model.

Mutation modes currently require one limit and one repetition. Hooks execute between the specified page and the next page, in configuration order:

consistency: snapshot
limits: [5]
response:
  snapshot_fields: [$]
oracle:
  url: http://localhost:8000/internal/orders/all
  items: $.results
  id: $.id
mutations:
  - after_page: 1
    command: [python, mutate.py, insert]
    timeout: 30

Hooks on a terminal page do not execute. An unreachable hook, command failure, timeout, or traversal budget produces an incomplete/error result. Hooks execute real commands: run these configurations against controlled test environments. A timeout stops the command's process group; commands must not leave detached descendants. This is command timeout handling, not process isolation or rollback. A detached process can escape both its process group and parent PID ancestry before cleanup. Run commands that cannot honor this contract inside an externally managed disposable container or OS sandbox with a lifetime limit. CursorProof does not install or manage such isolation. There is no built-in dataset reset adapter or scenario shrinking. The opt-in boundary command below uses fixture commands supplied by the API owner. Hypothesis is used to test CursorProof's own invariants and boundary cases.

For API cardinality boundaries, opt in to fixture commands:

boundary_testing:
  setup: [python, fixtures.py, set-count, "{count}"]
  cleanup: [python, fixtures.py, reset]
  timeout: 30

Run cursorproof boundary cursorproof.yml. For each configured limit, it prepares 0, 1, limit - 1, limit, limit + 1, 2 * limit, and 2 * limit + 1 records, deduplicating repeated sizes. The {count} placeholder is replaced with the target size. Each case gets a fresh traversal and oracle read; cleanup runs after every case. The fixture commands must create exactly the requested population and restore a safe state when cleanup runs. Boundary tests execute real commands and can change data. The first failing scenario is saved to cursorproof-boundary-repro.json by default; use --repro PATH to choose another file. Replay it offline with cursorproof replay PATH. Live replay with --config also requires --execute-hooks; it prepares the saved fixture size, reruns the traversal, and then cleans up.

Cursor binding

Declare original query values and explicit alternatives. Each probe changes one parameter while keeping the others and reusing a cursor from the original stream:

parameters:
  status: open
  sort: -created_at
cursor_binding:
  parameters:
    status: [closed]
    sort: [created_at]
  mismatch_behavior: reject
  reject_statuses: [400, 409, 422]

Binding probes run once using the first usable continuation cursor in static mode. A 2xx response violates the declared rejection policy. Other unlisted statuses (including 401, 403, 429, and 5xx by default) are inconclusive errors, not evidence of correct binding. No available cursor makes this requested check incomplete. This tests query parameters, not a change of authentication identity or headers. Before sending a cursor probe, CursorProof sends the changed query without a cursor; that baseline must succeed. This prevents an invalid alternative value from falsely proving that the cursor is bound.

Findings and exit codes

  • CP001 CURSOR_NOT_ADVANCING: next cursor equals the request cursor.
  • CP002 DUPLICATE_ITEM: identity repeats within or between pages.
  • CP003 MISSING_ITEMS: oracle identities are absent after a complete traversal.
  • CP004 ORDER_VIOLATION / ORACLE_ORDER_MISMATCH: declared or oracle order differs.
  • CP005 PAGE_SIZE_EXCEEDED: response contains more items than requested.
  • CP006 CURSOR_CYCLE: a longer cursor cycle is detected.
  • CP007 CURSOR_BINDING_VIOLATION: changed-query cursor was accepted.
  • CP008 TERMINATION_ERROR: has_more contradicts terminal cursor semantics.
  • CP009 INCONSISTENT_TRAVERSAL: complete streams differ across runs.
  • CP010 UNEXPECTED_ITEMS: returned identities are absent from the oracle.
  • CP011 SNAPSHOT_CONTENT_CHANGED: a selected record field differs from the initial oracle.
  • CP012 BOUNDARY_CARDINALITY_MISMATCH: unique item count differs from the prepared fixture size.

Findings contain locations and affected IDs where available. An ordered oracle locates missing intervals between the nearest observed neighbours. These bound the gap but do not prove which request caused it. An unordered oracle cannot locate the gap. Cross-limit differences may suggest non-unique ordering, but do not prove the underlying cause.

Exit codes: 0 all executed checks passed, 1 invariant violations, 2 invalid configuration or incomplete execution. Errors take precedence over findings, but both remain in the report. A passing result is limited to the observed scenarios and configured contract, not proof for every possible dataset.

--format json writes a machine-readable report to stdout without progress text. The summary counts observed items across all traversals and distinct identities across those traversals. responses counts recorded page and binding responses; it excludes oracle responses and failed requests.

Reproduction and privacy

run saves cursorproof-repro.json by default; use --repro PATH to change it. The default trace filename is ignored by Git. Existing trace files at that path are replaced atomically. replay rechecks the recorded observations offline, without HTTP or commands. To reissue one saved traversal against the API, provide the original configuration and a traversal number:

cursorproof replay cursorproof-repro.json --config cursorproof.yml --traversal 2

Live replay runs the selected limit once, without the other limits and repetitions. If the config contains mutation hooks, pass --execute-hooks to run those commands. They can change real data, so restore the test environment before replay. The trace includes a fingerprint of the sanitized replay configuration. Live replay rejects changes to response paths, ordering, oracle, mutation hooks, and other checks. Sensitive authentication header values are excluded from the fingerprint so credentials can rotate; other header values remain part of the replay contract. Because rotated credentials can represent a different principal or tenant, keep the authorization scope stable and express tenant or dataset selection in a non-secret header or query parameter so live replay can compare it. Live replay detects credential header and query names in the API and HTTP oracle configuration and refuses to run unless you pass --allow-unverified-auth-scope. Use this flag only after independently confirming the same principal and scope. The flag records that scope was not verified; it does not validate token identity. Custom authentication schemes with unrecognized names cannot be detected automatically. For file-loaded configurations, command fingerprints preserve sensitive environment substitutions, so rotating a PIN cannot hide a change to an unrelated dataset argument. Keep dataset selection in a separate, non-secret variable rather than embedding it in credentials. Programmatic callers should pass both values returned by load_config to run. When a plain secret set is used and a secret remains in an unclassified command value, the trace has no live-replay fingerprint; offline replay remains available. This avoids both hiding unrelated command data and exposing a guessable hash of a short secret. Ordinary query parameter values also affect the fingerprint, including values expanded from environment variables, so changing a tenant or filter rejects live replay. Trace schema version 2 migrates version 1 static traces. Older snapshot and cursor-binding traces replay as incomplete because they lack the evidence those checks now require. Older version 2 traces without a replay fingerprint remain available for offline replay but cannot be live-replayed safely. Traces with the older configuration fingerprint algorithm remain available for offline replay; generate a fresh trace for live replay with the current contract algorithm. Live replay compares that contract, not the display URL, which may contain redactions. The versioned trace stores page boundaries, identity sequences, status codes, request descriptions, cursor aliases, and order-preserving ranks for sort values. It does not store full response bodies, headers, commands, or raw cursors.

Environment substitutions and configured header values are redacted from recorded strings; cursor aliases retain equality/cycle evidence. Snapshot field values are stored as SHA-256 fingerprints, not as plain text. IDs containing known secrets receive stable aliases within the run. Sensitive query names are masked. Ordinary item IDs are retained for debugging, so traces may still contain application data. Credentials should always come from environment references. Raw sort values are omitted; ranks preserve ordering evidence, not the original values.

Offline replay reproduces the checks on recorded evidence. Live replay needs the original configuration, credentials, and suitable current data; expiring cursors, external state, and mutation effects cannot be restored automatically.

Development

uv sync --locked
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv build

The core analyzer has no network I/O. The runner extracts observations and the CLI renders reports. Tests use HTTPX transports and Hypothesis, plus a local demo API.

Metadata

Release files for cursorproof 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cursorproof 0.1.0
File Size Uploaded Provenance Actions
cursorproof-0.1.0.tar.gz 55.0 kB With provenance Details for cursorproof-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for cursorproof 0.1.0
File Interpreter ABI Platform Actions
cursorproof-0.1.0-py3-none-any.whl Python 3 none any Details for cursorproof-0.1.0-py3-none-any.whl

Total release size: 92.7 kB

Release files / cursorproof-0.1.0.tar.gz

Download URL cursorproof-0.1.0.tar.gz
Size 55.0 kB
Tags Source
SHA-256 checksum
How to use checksums
aa553f1e69bd60724bcaf20af49c7a077ab9a7f900e1cc9728cf9ad216ab2dd5
BLAKE2b-256 checksum
How to use checksums
8452044205af39ab0b2dd5ddd2a4b1ed6007e0cca14cccbc730d2ff729fc0d02
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release files / cursorproof-0.1.0-py3-none-any.whl

Download URL cursorproof-0.1.0-py3-none-any.whl
Size 37.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9550f7be79132fc816f7d33eb6ce2976076b2216e2d2ab49b70065248ddc2942
BLAKE2b-256 checksum
How to use checksums
d09cd7b467645edc223745f4dbede670000928f85c62f29766af2c535eae25c0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Security for cursorproof 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for cursorproof-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page