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:
- Bare
list[T]— backward compatible. Most pre-P6-002 routes keep this shape because the editor frontend unpacks the list directly. The route still acceptslimit/offset; pagination metadata is implicit (the client paginates by incrementingoffsetuntil a short page is returned). PaginatedResponse[T]— preferred for new routes. Defined insrc/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:
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
offsetto a route that previously only acceptedlimitis non-breaking (defaultoffset=0is the historical behaviour). - Tightening the
limitupper bound (e.g. 500 → 100) is breaking — coordinate via a new card. - Switching a route's response from
list[T]toPaginatedResponse[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.