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. - 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.
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. When its first unbounded register lands (risk / incident / evidence), that endpoint is built cursor-tier (§3) with its FE consumer expecting the cursor envelope from endpoint #1.
- 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)