Skip to content

ADR-0044: Collection Response Shape — Bare Arrays for Bounded Sets, Cursor Pagination for Unbounded

Accepted Cross-Project Universal

Date: 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 their AppServiceProvider, and emmie's own convention doc mandates Resource::collect() "so collections return arrays of DTOs." isms's array-backed spatie/laravel-data resources (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: GetFamilyPartsAction returns an Illuminate\Contracts\Pagination\CursorPaginator (per_page default 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/cursor query 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

  1. 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.
  2. 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 deliberate withoutWrapping() 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.
  3. 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.
  4. 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 synthesis campaigns/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()); emmie dto-resource-conversion skill (Resource::collect())
  • Related: ADR-0009 (Unified ResourceData Pattern)

Architecture documentation for contributors and collaborators.