ADR-0044: Collection Response Shape — Bare Arrays for Bounded Sets, Cursor Pagination for Unbounded
Accepted Cross-Project UniversalDate: 2026-07-16
Context
A cross-stack contract-verification mission on isms (the compliance-governance territory) surfaced a standing war-room open question: there is no documented convention for the shape of a list/collection API response. isms returns its three list endpoints (GET /policies, GET /controls, GET /control-owner-candidates) as bare JSON arrays ([...]), and the frontend reads response.data as the array. The Liaison escalated a forward-risk: if a future register list endpoint (risk / incident / evidence) shipped as a Laravel ResourceCollection ({data:[...]}) or a paginator ({data, links, meta}), the bare-array consumer would silently break.
A fresh peer-review debrief knocked the "overdue governance failure" framing down to forward-guidance — and a fleet recon then confirmed why. The convention the Liaison feared was missing already exists de-facto, coherently, across the fleet; it was simply never written down.
Fleet ground truth (verified 2026-07-16):
- No territory uses offset pagination (
->paginate()/LengthAwarePaginator) anywhere in application code. No longer true on emmie (verified 2026-09-29 atc20b3c6437):GetContractLogbookActionmerges four audit tables newest-first and calls->paginate(), and the frontend's "meer laden" appends page N+1, so an audit write between two loads repeats a row. It landed inf0b221dc19on 2026-08-16. - The small/bounded tier is bare arrays fleet-wide. kendo and emmie both call
JsonResource::withoutWrapping()in theirAppServiceProvider, and emmie's own convention doc mandatesResource::collect()"so collections return arrays of DTOs." isms's array-backedspatie/laravel-dataresources (ADR-0009) serialize bare by the same effect. isms is the norm, not the outlier. - The large/unbounded tier is cursor pagination. BIO (
brick-inventory-orchestrator) already ships it:GetFamilyPartsActionreturns anIlluminate\Contracts\Pagination\CursorPaginator(per_pagedefault 25, capped at 100,cursorName: 'cursor', ordered on a stable key), serializing to{data, next_cursor, prev_cursor, per_page, …}.
So the real division of labour was never "will we paginate?" — it is a two-tier convention the fleet arrived at by practice:
- Small/bounded sets → the backend returns the whole collection; the client paginates. (kendo, emmie, isms.)
- Large/unbounded sets → the backend cursor-paginates. (BIO.)
This ADR codifies that convention, fixes the decision criterion, and settles the shape a consumer must expect — closing both the war-room "pagination ADR" open item and the isms Liaison forward-risk.
Decision
1. Two tiers, keyed on a semantic criterion (not a cardinality number)
The tier of a list endpoint is decided at design time, per endpoint, by a single question:
Can this set grow without a known bound?
- No — bounded / known ceiling (a fixed catalogue, an enum-like corpus, a per-parent set with a small natural cap): small tier.
- Yes — unbounded DB-backed growth (a register/log/event stream that accretes over the system's life): large tier.
The criterion is semantic, not a magic row-count threshold. "Bounded" means the domain guarantees a ceiling (e.g. the ISO 27001 Annex A control set, a family's storage locations), not "small today."
2. Small tier — bare JSON array, client-side pagination
- The endpoint returns the entire collection as a bare JSON array (
[...]), no envelope. This matches the fleet default (withoutWrapping+Resource::collect()/ array-backed spatie Data). - The frontend paginates/filters client-side. No
page/cursorquery params, no server round-trip per page.
3. Large tier — Laravel CursorPaginator, cursor mandated
- The endpoint returns a Laravel
CursorPaginator({data, next_cursor, prev_cursor, per_page, …}), per the BIO reference shape. - Cursor pagination is mandatory for this tier; offset (
LengthAwarePaginator/->paginate()) is ruled out. Cursor keying is stable under concurrent inserts, has no offset drift, and is efficient at depth — the correct properties for the append-heavy compliance registers (audit/incident/evidence) this tier exists to serve. Offset pagination's page-drift under inserts is a correctness hazard on exactly those tables. - Bound
per_page(BIO: default 25, cap 100); order on a stable, unique key (cursor pagination requires it).
4. The tier is a per-endpoint design decision; the shapes coexist permanently
- A bounded list is never flipped to an envelope, and an unbounded register is never shipped bare. The two shapes live side by side across the fleet indefinitely.
- A frontend consumer is built for its endpoint's tier from endpoint #1. There is no "start bare, wrap later" path — see the accepted trade-off below.
Options Considered
- Codify the existing two-tier convention (bare bounded / cursor unbounded) — CHOSEN. Documents reality, closes the open item, zero migration. Its cost is the accepted trade-off in Consequences.
- Wrap every collection in
{data:[…]}fleet-wide for forward-compatibility. A single shape everywhere; a small list could gain pagination (meta/cursor) without a breaking change. Rejected: it requires undoing the deliberatewithoutWrapping()on kendo/emmie (+ isms's array-backed resources) and migrating every bare-array FE consumer across the fleet — large, cross-territory churn to buy forward-compat for a rare event (§Consequences). Contradicts a standing, intentional fleet choice. - Mandate offset pagination for the large tier. Rejected: offset drifts under concurrent inserts — a correctness hazard precisely on the append-heavy registers this tier serves. Cursor is strictly better here; BIO already proved it.
- No ADR — leave the convention tacit. Rejected: the tacit convention is exactly what let the isms Liaison read a silent-break risk into it, and it leaves the next register-builder without a decision rule or a target shape.
Consequences
Positive
- The fleet gains a written, one-line decision rule for every new list endpoint, and a fixed target shape per tier — no per-territory re-litigation.
- The isms Liaison forward-risk is structurally dissolved: bounded lists stay bare, unbounded registers ship cursor-tier from day one, and the two never cross. Nothing retrofits, nothing silently breaks.
- Cursor pagination is locked in for the compliance registers where insert-drift would otherwise be a latent correctness bug (A.8.15 / audit integrity gravity).
- Zero migration: every territory is already compliant.
Negative / accepted trade-off (Commander-ratified 2026-07-16)
- With bare arrays for the small tier, a bounded list that unexpectedly outgrows client-side pagination requires a one-time breaking shape change (bare → cursor envelope) for that single endpoint and its consumer. This is accepted deliberately over the alternative (Option 2's fleet-wide
{data}wrapping) — the churn to pre-empt a rare event exceeds the cost of the rare event. The mitigation is the semantic criterion: classify by unbounded-growth potential, not current size, so a set that could grow is cursor-tier from the start and never hits this path.
Implementation
Per-territory state against the two tiers (§1 criterion: bounded → bare array; unbounded DB-backed register → cursor envelope). States below are those the 2026-07-16 fleet recon established (campaigns/war-room/2026-07-16-collection-envelope-pagination-adr.md); a territory not measured there reads Not Assessed until a Liaison or Cartographer mission grades its list endpoints against §1.
| Territory | State | Notes |
|---|---|---|
| kendo | Complete | Small tier is the de-facto shape: JsonResource::withoutWrapping() in AppServiceProvider::boot(); no offset pagination in app code. |
| emmie | Partial | Small tier via withoutWrapping() + Resource::collect() (the dto-resource-conversion skill mandates it). One offset-paginated endpoint since 2026-08-16: the contract logbook (GetContractLogbookAction, ->paginate()), which is owed a cursor (Liaison M7, 2026-09-29). |
| brick-inventory-orchestrator | Complete | Large-tier reference: GetFamilyPartsAction returns a CursorPaginator (per_page 25 default / 100 cap, cursorName: 'cursor', stable ordering key). |
| isms | Complete | Bounded lists (policies / controls / owner-candidates) correctly bare arrays. Control review history (GET controls/{framework}/{ref}/reviews) is the first unbounded register (append-only evidence; Commander ruling 2026-10-01) and ships the §3 cursor envelope {data, next_cursor, prev_cursor, per_page} since isms#228 (ISMS-0065, 2026-10-02), its FE consumer paging on next_cursor (verified at 597ce14, Liaison M7, 2026-10-16). Later registers (risk / incident / evidence) follow the same rule — see § Migration. |
| ublgenie | Not Assessed | In the 2026-07-16 zero-offset-pagination sweep; tier conformance per endpoint not graded. |
| entreezuil | Not Assessed | As ublgenie. |
| wijs | Not Assessed | As ublgenie. |
| codebook | Not Assessed | As ublgenie. |
| lokalekeuze | Partial | Shipped every response under a {data} envelope (a local decision, LK-0867), contrary to §2. Commander ruling 2026-10-06: the envelope is retired; cursor lists keep §3. Migration runs per app under epic 187 (LK-1466..1472). Since slice 0a, every new route is born bare and the legacy roster can only shrink. Open: whether four server-truncated lists and the A13 remainder list are §3 cursor tier (WR-2071). |
| laravel-skeleton | Complete | JsonResource::withoutWrapping() plus ResponseShapeTest, which pins bare single and bounded responses AND the §3 cursor shape (laravel-skeleton#66, 2026-10-06). Every territory cast from it starts conformant. |
| daymate-api | Not Assessed | v4 rebuild in flight on development; grade after the v3→v4 cut. |
| scripthub | Not Assessed | Annexed after the recon. |
| techniekwerkt (tc-api) | Not Assessed | Flown-in engagement; tier grading rides a Liaison review sweep, not a dispatch. |
Migration
None required — the ADR codifies existing practice.
- isms: its three current lists (policies / controls / owner-candidates) are correctly small-tier (bounded corpora) and stay bare arrays. Its first unbounded register is the per-control review history (append-only evidence, Commander ruling 2026-10-01), which shipped as a bare array and moved to cursor tier under ISMS-0065 (isms#228, 2026-10-02). Every later unbounded register (risk / incident / evidence) is built cursor-tier (§3) from endpoint #1, with its FE consumer expecting the cursor envelope.
- New list endpoints (any territory): apply the §1 criterion at design time.
- Territory CLAUDE.md projections ride the standard ADR-0015 projection cadence.
References
- Reasoning / fleet recon:
campaigns/war-room/2026-07-16-collection-envelope-pagination-adr.md - Trigger: isms Liaison field report + debrief
reports/isms/{field,debrief}/2026-07-16-liaison-controls-seam*.md; wave synthesiscampaigns/isms/2026-07-16-phase4-controls-spy-wave.md - Large-tier reference: BIO
backend/app/Actions/Family/GetFamilyPartsAction.php(CursorPaginator) - Small-tier reference: kendo/emmie
AppServiceProvider::boot()(JsonResource::withoutWrapping()); emmiedto-resource-conversionskill (Resource::collect()) - Related: ADR-0009 (Unified ResourceData Pattern)