Skip to content

Pagination (Phase 13)

What

Offset-based pagination on every list endpoint. Adds limit + offset query params and a total field on the response so consumers can render "showing N–M of T" with prev/next controls.

Defensive hardening before a partner firm hits volume. At ~50 engagements / 75 findings per engagement, payloads are still small (a full list of all engagements is ~50KB JSON), but a firm running 200+ engagements with 100+ findings each would saturate the response without a cap.

Endpoint changes

Endpoint Default limit Max limit Default sort
GET /auditforge/engagement 100 500 updated_at desc
GET /auditforge/engagement/{id}/findings 200 1000 severity desc, then confidence desc
GET /auditforge/firm 100 500 display_name asc
GET /auditforge/user 100 500 firm_id asc, then email asc
GET /auditforge/findings/search (already had) 100 200 (no change — already pagiated)

/findings/portfolio-clusters is not paginated — clusters are themselves the aggregation; the result is always small.

Response shape

All list endpoints now return:

{
  "<collection>": [...],     // page of items
  "count": 42,               // items in this page
  "total": 217,              // full collection size after filters
  "limit": 100,
  "offset": 100              // 0-indexed
}

Frontend renders pagination controls when total > limit:

Showing 101–200 of 217 · ← prev · next →

Validation

limit must be in [1, max_limit_per_endpoint]; offset must be >= 0. Invalid values return 422 with the constraint.

Sort order is fixed per endpoint

Different consumers want different orderings (most recent vs alphabetical vs severity), but for a single endpoint the order is fixed and documented. This makes pagination correct: callers iterate offset=0, offset=limit, etc. and see every record exactly once.

Adding a sort= query param is on the roadmap for endpoints where the partner wants choice (e.g., engagements by client_name). Today the defaults match the most common workflow:

  • Engagements default to most-recently-active first (matches the dashboard's expected use)
  • Findings default to severity desc (review priority)
  • Firms / users default to alphabetical (predictable)

Per-firm scoping interaction

For non-admin session callers, the firm_id query param on /engagement is ignored (overridden to caller's firm) before pagination. Pagination operates on the scoped subset, so a partner with 30 engagements always sees total=30 regardless of how many engagements other firms have.

What's NOT paginated

Endpoint Why
GET /engagement/{id} Single record; no list to paginate
GET /findings/portfolio-clusters Clusters are the aggregation; result is always 0–8 items
POST /findings/portfolio-clusters/recompute Same reason
GET /auth/me Single caller record
Auth / firm CRUD detail / user detail Single records

Frontend

The existing UI components fetch with default limits (100 or 200) which covers all current data. Pagination controls in the UI are deferred — when a real partner crosses 100 engagements we'll add prev/next buttons; today the response shape is already correct, so adding UI later is a frontend-only change.

Cost

Zero LLM cost. The store loads always read the full collection (S3 → memory → cache); the new code just slices the in-memory result. Performance overhead is negligible (slicing a 1000-item list is microseconds).

For very large portfolios (10K+ engagements, hypothetical), an indexed S3 layout or DynamoDB-backed list would be needed. That's a separate scaling phase, not on the current roadmap.

Code

  • app/auditforge_endpoints.py — 4 list endpoints add limit + offset + total + sort + slice

Verification

End-to-end: GET /engagement?limit=2&offset=0 returns 2 engagements + total=N; GET /engagement?limit=2&offset=2 returns the next 2; consistent ordering across pages.