Doctrine
Unbounded queries must paginate or scope — never cap with an arbitrary limit
Unbounded queries must paginate or scope — never cap with an arbitrary limit
Rule
When a query intends to return all rows of a data set whose cardinality is not structurally bounded, the query MUST either:
1. Paginate to exhaustion — fetch in fixed-size pages until a short page signals the end, OR 2. Scope with a WHERE clause that bounds the result set semantically (e.g., WHERE project_id = ?)
Using .limit(N) as a substitute for "all" is forbidden. It silently truncates — the function works until the data outgrows the cap, then degrades without any error signal.
What counts as "structurally bounded"
A data set is structurally bounded when its maximum cardinality is guaranteed by the schema or business domain. Examples:
- Bounded: conversation spans for one interaction (capped by call duration), projects for one user (tens), config rows (single digits).
.limit()is fine as a safety net here. - Unbounded: all contacts in the system, all aliases, all interactions, all receipts. These grow with business activity.
.limit(N)on these is the bug.
Safe patterns
Pattern A — Paginate to exhaustion (preferred for full-table scans)
const PAGE_SIZE = 1000;
const MAX_PAGES = 25;
const rows = [];
for (let page = 0; page < MAX_PAGES; page++) {
const from = page * PAGE_SIZE;
const { data, error } = await db
.from("contacts")
.select("name,company")
.range(from, from + PAGE_SIZE - 1);
if (error) throw new Error(error.message);
rows.push(...(data || []));
if ((data || []).length < PAGE_SIZE) break;
}
if (rows.length >= PAGE_SIZE * MAX_PAGES) {
console.warn(`contacts lookup hit page cap at ${PAGE_SIZE * MAX_PAGES} rows`);
}
Pattern B — Scope with WHERE (preferred when a natural key exists)
const { data } = await db
.from("journal_claims")
.select("*")
.eq("call_id", interactionId);
Anti-pattern — arbitrary cap masquerading as "all"
const { data } = await db
.from("contacts")
.select("name,company")
.limit(1000); // WRONG: silently drops contact 1001+
When .limit() IS correct
- Existence checks:
.limit(1)— you only need to know if a row exists - Display queries:
.limit(10)— you're showing "recent 10" to a user - Top-N queries:
.limit(50).order(...)— you want the top N by some criteria - Safety nets on bounded sets:
.limit(100)on a set you know has <50 rows but want to guard against schema drift
The distinguishing test: does the function's correctness depend on seeing every row? If yes, .limit(N) is wrong.
Known instances in camber (as of 2026-04-17)
| File | Limit | Risk | Status |
|---|---|---|---|
generate-summary contacts | .limit(1000) | Silent contact truncation | FIXED (paginated in HCP-0005, commit a72f81b9) |
alias-scout | .limit(500) | Silent alias truncation | OPEN — audit needed |
embed-facts | .limit(500) | Silent fact truncation | OPEN — audit needed |
gmail-context-lookup | .limit(2000) | Silent context truncation | OPEN — audit needed |
Enforcement
- Code review: any
.limit(N)where N > 1 on a query that lacks a scoping WHERE clause should trigger a "is this set bounded?" question - Edge function authors: when writing a new query, ask "will this table have more than N rows in 12 months?" If the answer isn't confidently "no," paginate