company_dns

V4 server-side pagination (SIC search surfaces)

Status: Scoped (2026-10-01), deferred to V4.1.0. Raised while reviewing V3’s Swagger UI against V4’s and realizing every “paginated” list in V4 today is actually a full fetch with client-side .slice() - a gap worth fixing deliberately before sic-hybrid-search.md and sic-global-search.md both ship on top of the same pattern and have to be retrofitted a second and third time. Explicitly not part of v4-initial-release-roadmap.md’s initial dev/staging release or the V4.0.0 production-cutover milestone (§3a of that doc) - this doc’s design stays as a ready-to-build reference for whenever V4.1.0 is actually scheduled, not something blocking either nearer-term milestone. Owner: michael.hay@mediumroast.io Scope: real server-side LIMIT/OFFSET pagination for SIC search endpoints and the frontend changes needed to actually use it. Out of scope: EDGAR/Wikipedia/firmographics (audit found these are single-company lookups today, not paginated lists - see §2).


1. Why this matters now, not later

Today’s “pagination” only feels instant because the entire result set is already sitting in the browser’s memory before the user ever clicks “next page” - that’s not representative of what real server-side paging will feel like (network latency per page click, previously absent). Worth deciding the backend contract and the UX tradeoffs before sic-hybrid-search.md’s Hybrid endpoint and sic-global-search.md’s multi-system endpoint get built on the same fetch-everything assumption and need the same fix applied twice more.

2. Current state (audited 2026-10-01)

3. The real constraint: V3 byte-parity

envelope.rs’s own header comment is explicit: “every V4 endpoint that’s a V3-parity replacement keeps this envelope byte-for-byte identical.” sic_description_impl is that same function backing both /V3.0/na/sic/description/{sic_desc} and /V4.0/na/sic/description/{sic_desc} (main.rs:315-344). Adding limit/offset query params or changing the data shape (from a bare array to {results, pagination}) on that handler risks silently changing the V3 alias’s contract too, since it’s the same code path.

This doc’s position: give the V4.0 route its own handler (e.g. sic_description_paginated), calling a new lookup.rs function, rather than branching one shared handler on “is this the V3 route.” The V3.0 alias keeps calling the existing unpaginated find_by_description untouched, forever. Cleaner than optional params with “pagination only activates if present” logic living inside a handler two different route contracts depend on.

4. Design

4.1 Wire params: limit / offset

Chosen to match the SQL underneath directly (and similarity’s existing k precedent) rather than page/per_page. Defaults and max clamp not chosen yet (§6).

4.2 Total count

A paginated UI needs “N results” / “page X of Y,” which means knowing the total match count, not just the current page’s rows. Two options:

  1. A second SELECT COUNT(DISTINCT class_id) ... WHERE ... query, same WHERE clause - simple, correct, doubles query cost per page load.
  2. A COUNT(*) OVER() window function inside the same query (SELECT *, COUNT(*) OVER() AS total_count FROM (...) LIMIT n OFFSET m) - one round trip. DataFusion’s window-function support is already being relied on in sic-hybrid-search.md’s RANK() OVER design, so this is plausible, but not yet verified working in this codebase - confirm before committing to it over the simpler two-query approach.

4.3 Per-endpoint plan

4.4 Frontend changes

5. Rollout order (proposed)

  1. Keyword first. Simplest case (substring match, no ranking ambiguity to resolve first), and it’s the one place that already has a full pagination UI built and waiting - proves the limit/offset + total-count pattern and the Alpine real-fetch- per-page change together, in the lowest-risk spot.
  2. Decide Semantic’s paging semantics (§6) before touching it at all.
  3. Build Global (sic-global-search.md) and Hybrid (sic-hybrid-search.md) with real pagination from the start, reusing whatever contract step 1 establishes.

6. Open questions

7. Explicitly out of scope