DEV Community

Dakota Liu
Dakota Liu

Posted on

Case Study: Freeze Cursor Pagination Before an Agent Writes the List Client

You should freeze cursor pagination in a contract test before an agent writes any list client. Coding agents routinely invent page numbers, offset parameters, and decodeable cursors that your public API never documented. Those inventions compile, look tidy during review, and then break the first time a real cursor is opaque. A frozen fixture plus a failing contract test will catch that drift before the client file exists.

This case study walks one small invoice list end to end: background, goal, implementation, expected results, and limits. You can strip every product mention and still run the same workflow in your own repository. Treat the code as a labeled local walkthrough, not as a production benchmark or a claim about live traffic.

Background: one list endpoint, not a billing platform

The project is deliberately small. You already have GET /v1/invoices with opaque cursor pagination, a hard maximum limit of 100, and no total count. The response always includes items, next_cursor, and limit. The last page returns next_cursor: null. The cursor is an HMAC blob, not JSON, and clients must never parse it.

The failure mode is boring and expensive. An agent sees the word "list" and writes offset pagination because that is the template it remembers. It then adds a helper that JSON-decodes the cursor so it can "skip bad tokens." Your mocks accept that helper, staging rejects it, and reviewers miss the gap because the tests were generated against the agent's own invented fixture.

You are not redesigning invoices. You are stopping the agent from treating pagination as generic CRUD folklore. That constraint is the whole case, and it is enough work for one article.

Goal: make illegal paging unrepresentable

Before any client code is generated, you want three guarantees that a reviewer can check in minutes.

  1. The response shape is frozen in a fixture the agent cannot rewrite without failing CI.
  2. The client must treat next_cursor as an opaque string or null, never as missing, empty, or parsed JSON.
  3. Query keys such as page, offset, per_page, and starting_after are absent from source, not merely unused at runtime.

If the agent cannot satisfy those rules, the patch is rejected. Green tests that required editing contracts/ do not count as success. The contract is an input to the agent, not an output it is allowed to negotiate.

The frozen artifact

Keep one contract file and one golden response in the repo. Do not let the agent own those paths. The JSON below is the source of truth for this walkthrough.

{
  "endpoint": "GET /v1/invoices",
  "query": {
    "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 },
    "cursor": { "type": ["string", "null"] }
  },
  "forbidden_query_keys": ["page", "offset", "per_page", "starting_after"],
  "response": {
    "type": "object",
    "additionalProperties": false,
    "required": ["items", "next_cursor", "limit"],
    "properties": {
      "items": { "type": "array" },
      "next_cursor": { "type": ["string", "null"] },
      "limit": { "type": "integer", "minimum": 1, "maximum": 100 }
    }
  },
  "cursor_rules": {
    "opaque": true,
    "may_parse_json": false,
    "empty_string_means": "illegal",
    "missing_field_means": "illegal"
  }
}
Enter fullscreen mode Exit fullscreen mode

Save that document as contracts/invoices_list.json. Then freeze a golden page the client must keep matching.

{
  "items": [
    { "id": "inv_1001", "cents": 4900, "status": "open" }
  ],
  "next_cursor": "opaque_token_not_json",
  "limit": 25
}
Enter fullscreen mode Exit fullscreen mode

Notice that next_cursor is present on every page, including the last page where the value becomes null. Presence is part of the contract. If your real API omits the field instead, freeze omission explicitly and never let the agent invent a third shape during generation.

Implementation: tests the agent is not allowed to edit

Put the tests behind the same path discipline you already use for review. The example is local and unexecuted here; run it on your machine before you trust the output. You will need pytest, jsonschema, and httpx.

# tests/test_invoices_list_contract.py
from __future__ import annotations

import ast
import json
from pathlib import Path

import httpx
import jsonschema
import pytest

ROOT = Path(__file__).resolve().parents[1]
CONTRACT = json.loads((ROOT / "contracts/invoices_list.json").read_text())
GOLDEN = json.loads((ROOT / "fixtures/invoices_list_page.json").read_text())
CLIENT = ROOT / "src/invoices_client.py"


def test_golden_response_matches_frozen_schema() -> None:
    jsonschema.validate(instance=GOLDEN, schema=CONTRACT["response"])
    assert GOLDEN["next_cursor"] is None or isinstance(GOLDEN["next_cursor"], str)
    assert GOLDEN["next_cursor"] != ""
    assert 1 <= GOLDEN["limit"] <= 100


def test_client_source_does_not_invent_paging() -> None:
    source = CLIENT.read_text(encoding="utf-8")
    tree = ast.parse(source)
    banned = set(CONTRACT["forbidden_query_keys"])
    found: set[str] = set()

    for node in ast.walk(tree):
        if isinstance(node, ast.Constant) and isinstance(node.value, str):
            if node.value in banned:
                found.add(node.value)
        if isinstance(node, ast.keyword) and node.arg in banned:
            found.add(node.arg)

    assert not found, f"client invented paging keys: {sorted(found)}"


def test_client_does_not_decode_cursor_payload() -> None:
    source = CLIENT.read_text(encoding="utf-8").lower()
    needles = (
        "json.loads(cursor",
        "json.loads(next_cursor",
        "base64.b64decode(cursor",
        "bytes.fromhex(cursor",
    )
    for needle in needles:
        assert needle not in source, f"client parses an opaque cursor via {needle}"
Enter fullscreen mode Exit fullscreen mode

Add one transport test so the client cannot "pass" by ignoring the network shape.

def test_list_page_rejects_missing_next_cursor() -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        assert "page" not in str(request.url)
        assert "offset" not in str(request.url)
        return httpx.Response(200, json={"items": [], "limit": 25})

    transport = httpx.MockTransport(handler)
    from invoices_client import InvoiceListClient

    client = InvoiceListClient("https://api.example.test", transport=transport)
    with pytest.raises(ValueError, match="missing next_cursor"):
        client.list_page(limit=25)
Enter fullscreen mode Exit fullscreen mode

Run the file before the agent writes anything, and keep the first failure loud.

python -m pip install pytest jsonschema httpx
python -m pytest tests/test_invoices_list_contract.py -q
Enter fullscreen mode Exit fullscreen mode

You should see a missing-file failure first. That is useful. The agent now has a red test and a frozen contract, which is a narrower problem than "write a billing client." A minimal legal client can look like the following once those tests exist.

# src/invoices_client.py
from __future__ import annotations

from typing import Any, Mapping

import httpx

MAX_LIMIT = 100


class InvoiceListClient:
    def __init__(self, base_url: str, transport: httpx.BaseTransport | None = None) -> None:
        self._client = httpx.Client(base_url=base_url, transport=transport, timeout=10.0)

    def list_page(self, *, limit: int = 25, cursor: str | None = None) -> Mapping[str, Any]:
        if limit < 1 or limit > MAX_LIMIT:
            raise ValueError("limit must be between 1 and 100")
        params: dict[str, Any] = {"limit": limit}
        if cursor:
            params["cursor"] = cursor
        response = self._client.get("/v1/invoices", params=params)
        response.raise_for_status()
        payload = response.json()
        if "next_cursor" not in payload:
            raise ValueError("list response missing next_cursor")
        if payload["next_cursor"] == "":
            raise ValueError("empty cursor is illegal; use null on the last page")
        return payload
Enter fullscreen mode Exit fullscreen mode

Decision table: when the agent plan fails

Use this table in review, or encode extra rows as assertions. The point is to fail the plan before the client grows helpers you did not ask for.

Agent change Why it usually appears Verdict
Adds page or offset query params CRUD template memory Fail the patch
Treats missing next_cursor as last page Common REST folklore Fail unless the fixture omits it
JSON-decodes or base64-decodes the cursor Convenience, "validation" Fail; cursors stay opaque
Introduces total or has_more Dashboard habits Fail unless the contract adds them
Raises limit above 100 in client only "just for tests" Fail; clamp must match the API
Rewrites contracts/ to match the client Makes tests green Fail; contracts are input, not output
Replaces null last page with "" String-only thinking Fail; empty string is illegal here

Where a bounded coding loop fits

Once the contract is frozen, you can let an agent propose only src/invoices_client.py against the red tests. You do not need a large workspace for that loop, because the task is one endpoint, one schema, and one client class.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option, which can host that bounded generate-and-test loop while contracts/ stays in your own repository. You still review the diff. You still keep fixtures out of the agent's write set. The environment does not discover missing product rules; it only iterates inside the rules you already froze.

If you use that path, keep the prompt explicit and small: implement InvoiceListClient so tests/test_invoices_list_contract.py passes, and do not edit files under contracts/ or fixtures/.

Results you should expect from the walkthrough

This is a worked example, not a traffic study and not a claim about model quality. After the contract test exists, a typical agent draft fails in one of three ways that the assertions above are built to catch.

  1. It emits page because the prompt contained the word pagination.
  2. It omits next_cursor handling because the first mock returned only items.
  3. It parses the cursor so it can skip tokens the server already signed.

Each of those failures is cheaper than a staging incident with real invoices. When the tests pass, you get a client that can only speak the frozen query keys. You do not get retries, auth, or a full SDK. That scope limit is the result you should want, not a weakness to hide in the write-up.

Re-run the same file after the client exists. You want a boring, repeatable command, not a demo script that only works in one chat transcript.

python -m pytest tests/test_invoices_list_contract.py -q --tb=short
Enter fullscreen mode Exit fullscreen mode

Lessons learned

  • Freeze response presence, not only types, because null and missing are different last-page stories.
  • Ban illegal query keys in source, not in a comment, because agents treat comments as optional color.
  • Opaque cursors need an explicit parse ban, or the client will decode them "just in case."
  • Put contracts outside the agent's write paths, because a test the agent can edit is only a suggestion.
  • Keep the first agent task smaller than the domain; one list endpoint is enough to surface the folklore.

The pattern also scales sideways without copying this invoice example. The next frozen field is usually sort stability, then filter names, then the error envelope. Do not freeze all of them in one prompt. Freeze one contract, fail one plan, then stop.

Limitations and who should skip this

Do not use this approach if you do not have a real pagination rule yet. Freezing fiction trains the agent on fiction, and the tests will defend the wrong story. Do not treat AST string bans as a security boundary. They catch sloppy drafts, not adversarial patches, renamed variables, or generated code that builds query keys at runtime.

Skip this if your list API is still offset-based and you have no plan to change it. Skip it for payment-critical paths that need a human-written client and a formal review, not a generated first draft. Skip it if reviewers cannot keep contracts/ off the agent's write list, because the agent will "fix" the fixture instead of the client.

The walkthrough ignores authentication, idempotency, rate limits, and streaming. Those need their own frozen contracts and their own failing tests. A free model run will not invent those rules for you. It will only fit code to the rules you already wrote down, which is exactly why the fixture has to exist first.

Top comments (0)