Every listing in the control plane speaks one contract. A client learns it once, and a new endpoint cannot invent its own.
GET /applications?page=2&pageSize=25&sort=-createdAt,name&search=ana&filter[state]=captured
| Parameter | Default | Meaning |
|---|---|---|
page |
1 |
1-based |
pageSize |
25, capped at 200 |
An unbounded page size is an accidental denial of service on any table with history behind it |
sort |
per endpoint | Comma-separated; a leading - is descending |
search |
— | Free text, case-insensitive, across the fields the endpoint opens |
filter[field] |
— | Matched exactly or as a substring, as the endpoint declares |
Every listing answers the same envelope:
{ "data": [ … ], "meta": { "page": 2, "pageSize": 25, "total": 314, "lastPage": 13 } }
#Whitelists, not conveniences
Each endpoint declares a ListSpec: which fields may be sorted, which may be
filtered and how, which are searched, and the order used when the caller asks for
none.
This is not ceremony. An open sort lets anyone order by an unindexed column on
a table with history behind it, and an open filter lets them probe fields the
endpoint never meant to expose. A field that is not declared is an error, not a
silently ignored parameter:
| Error | Cause |
|---|---|
unsortable_field |
sort named a field the endpoint did not open, and the answer lists the ones it did |
unfilterable_field |
filter[…] named a field the endpoint did not open |
#Two properties that are easy to lose
A listing is never unordered. When the caller asks for no order, the
endpoint's default applies — and the primary key is always appended last.
Ordering by a column with ties leaves rows within a tie in whatever order the
database happened to find them, and that order is not stable between two
queries, so page 2 of a listing sorted by name can repeat a row from page 1 and
skip another entirely. A unique tiebreaker is what makes paging mean anything.
A filter narrows and never widens. The endpoint's own scope — the application a listing belongs to, the rows this caller may see — is intersected with the caller's filters. A filter can never be a way out of a tenant. See identity and scope.
#Listings that are not tables
The built-in scenarios and the provider catalogue live in code, not in the database. They are paginated by the same helper over an array, with the same whitelists enforced identically, because a client should not have to tell those listings apart from the rest.
#The console side
useEntityCollection implements the caller's half once: params, fetching,
paging, sorting, filtering, searching, and optional URL sync so a filtered view
is a link. core/data/serialization/list-params.serializer.ts maps its shape
onto the query above — filters are nested rather than spread at the top level, so
a filter called search, page or sort cannot collide with the contract
itself.
A column that claims sortable must name a field the endpoint declared sortable,
or the API answers unsortable_field and the operator finds out. That the two
agree is deliberately not restated in the columns file: a second copy of the rule
could disagree with the first.
#Where it lives
apps/api/src/core/http/list-query.dto.ts— the query shape and the envelope.apps/api/src/core/http/paginate.ts— the whitelists, the tiebreaker, and the array variant.apps/console/app/core/composables/collection/useEntityCollection.ts— the caller's half.apps/api/test/integration/list-contract.spec.ts— what pins it.