Skip to content

Pagination and Filtering Convention

Phase 6 card P6-002 standardised how high-volume list routes under /api/v1/ accept paging and filter query parameters. This reference is the source of truth for new routes and reviewers.

Pagination

Query parameters

Param Type Default Bounds Description
limit int 50 1..500 Max items per page. <= 0 or > 500 → 422.
offset int 0 >= 0 Items to skip before the page. < 0 → 422.

The defaults and bounds live in src/pycharter/api/models/pagination.py as DEFAULT_LIMIT and MAX_LIMIT. Routes that already shipped with different historical defaults (e.g. GET /api/v1/proposals/ defaults limit=200) keep those values for backward compatibility, but they must still honour the 1..500 upper bound and must accept offset so callers can page through the full result set.

422 (HTTP 422 Unprocessable Content) is the canonical response for out-of-range pagination params — FastAPI emits this automatically when the Query(..., ge=..., le=...) constraints fail.

Response shapes

Two shapes coexist:

  1. Bare list[T] — backward compatible. Most pre-P6-002 routes keep this shape because the editor frontend unpacks the list directly. The route still accepts limit / offset; pagination metadata is implicit (the client paginates by incrementing offset until a short page is returned).
  2. PaginatedResponse[T] — preferred for new routes. Defined in src/pycharter/api/models/pagination.py:
class PaginatedResponse(BaseModel, Generic[T]):
    items: list[T]
    total: int | None  # None when the count is expensive
    next_offset: int | None  # None on the last page
    limit: int
    offset: int

Use PaginatedResponse.from_slice(...) to build a populated response from a pre-sliced page; it derives next_offset from total when known, or from len(items) == limit otherwise.

When total is None

total is optional on PaginatedResponse[T]. Set it when a SELECT COUNT(*) over the same filter is cheap (small or indexed tables). For very large append-only tables (binding events, audit trail) it is acceptable to omit the count and let the client rely on next_offset.

Cursor pagination (deferred)

Append-only logs (/bindings/events, /suggestions/{id}/review-events, the forthcoming /audit/export) are candidates for cursor-based pagination (?after=<event_id>) because new rows arriving between pages can shift offsets. P6-002 keeps these on limit/offset for consistency with the rest of the API; migrating them to cursors is a follow-up card.

Filtering

Param names

Filter parameters use the model field name verbatim (?status=accepted, ?actor=alice, ?scheme=billing). Do not prefix with filter_ or pluralise. Boolean filters take the literal strings true / false (FastAPI's stdlib parsing).

Multi-value filters

Multi-value filters are comma-separated in a single query value:

GET /api/v1/semantic/health?severity=error,warning

The canonical helper for normalising these is _normalise_csv() in src/pycharter/api/routes/v1/semantic_health.py. Use it (or copy its shape) when adding multi-value filter support to a new route.

Unknown values

Unknown tokens are silently dropped rather than rejected with a 400. The rationale is graceful degradation: a typo in one token should not blank the entire list. When every token in a multi-value filter is unknown, the route behaves as if the filter were omitted (see _normalise_csv in semantic_health.py).

This applies to filter values, not filter names: unknown query-string keys are accepted by FastAPI but ignored, which is standard HTTP behaviour. Unknown filter values inside a known param are dropped.

Filter composition

When multiple filter params are supplied they compose with AND semantics across facets (e.g. ?severity=error&kind=missing_mapping returns issues that are both error severity AND missing_mapping kind). Multi-value tokens within a single facet compose with OR (any matching token passes).

A small number of legacy routes use mutual exclusivity (e.g. GET /api/v1/semantic/ingestion/assets rejects supplying more than one of scheme, asset_type, ingest_run with a 400). This is a quirk of the underlying repository and not part of the convention — new routes should prefer AND semantics.

Route inventory (as of P6-002)

Route Pagination Filter convention
GET /api/v1/suggestions limit/offset per-facet AND, CSV multi
GET /api/v1/suggestions/{id}/review-events limit/offset n/a
GET /api/v1/semantic/health limit/offset per-facet AND, CSV multi
GET /api/v1/semantic/health/issues/{fp}/history limit (no offset; per-fingerprint scope is small) since/until time bounds
GET /api/v1/semantic/context/query limit n/a
GET /api/v1/proposals/ limit/offset; returns PaginatedResponse[ProposalSummaryResponse] (limit keeps its historical 200 default) status / scope / domain
GET /api/v1/contracts optional limit + offset; omitting limit returns the full set (historical behavior). Envelope fields (limit / offset / next_offset) are additive on ContractListResponse n/a
GET /api/v1/contracts/{cid}/fields/{fp}/binding/events limit/offset (P6-002) concept (path-bound to contract/field)
GET /api/v1/semantic/ingestion/assets limit/offset (P6-002) mutually-exclusive (legacy)
GET /api/v1/semantic/ingestion/runs limit/offset (P6-002) status / actor

Routes not in this list either return a single resource (/{id}), return a bounded structure (graph, tree), or stream a non-list shape (RDF export). They are exempt from the pagination convention.

Forward compatibility

  • Adding a filter is a non-breaking change. Existing clients that do not supply the new param keep their current behaviour.
  • Adding offset to a route that previously only accepted limit is non-breaking (default offset=0 is the historical behaviour).
  • Tightening the limit upper bound (e.g. 500 → 100) is breaking — coordinate via a new card.
  • Switching a route's response from list[T] to PaginatedResponse[T] is breaking; do it in a new major version of the route, via a parallel endpoint, or in lock-step with every known consumer. GET /api/v1/proposals/ made this switch together with its single consumer (the bundled editor UI) in the same change set, so no external contract was broken.