You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Detailed verification report. This is the full investigation this issue and its sub-issues were written from.
Summary
/query and /search accept limit and skip, clamp both to server maximums, and then return the clamped page with no indication that clamping happened.
A client cannot tell a full page from a truncated one, and cannot tell "no more results" from "you asked for more than I will give you."
Both endpoints share getPagination() (controllers/utils.js:21), so this is one contract with one fix site, not two. The same helper also paginates the two Gallery of Glosses endpoints (controllers/gog.js:36,167, default 50, JWT-gated), so they inherit whatever changes here. This issue covers that shared contract. Where /search performs its paging — in application memory rather than in MongoDB — is a separate concern, tracked in the /search paging issue.
Three distinct failures fall out of that silence:
A client that stops when results.length < limitsilently drops records.
A client that stops when results.length === 0 and advances by results.lengthnever terminates once skip passes its maximum, because every subsequent request returns the same page.
The deployment's configured maximums are not in effect at all — .env and the code disagree on the variable names.
The second failure is the one to look at first: the non-terminating client is the paged-query recipe published in our own API documentation (public/API.html:555), and the loop is reachable on production today with ordinary typed queries.
Every behavior below was verified against the public deployments https://store.rerum.io and https://devstore.rerum.io, not only locally.
Why this matters
Pagination is a contract. The server promises that a client can walk a result set to completion by advancing skip until the results run out. Today the server breaks that promise in whichever direction the client happens to lean:
A length < limit stop condition loses data. The client asks for limit=1000, the server decides on 500, returns 500, and says nothing. 500 < 1000 reads as "last page." The walk terminates early and the client reports success on a partial result set. Nothing on the wire distinguishes this from a genuine final page. This is how the problem was found: a client with a configurable page size above 500 walked a result set, received 500, and stopped.
A length === 0 stop condition hangs. Past the skip maximum every request returns an identical page. A client that concatenates and advances by results.length re-fetches that same page forever, growing its accumulator without bound. This is not a hypothetical client shape — it is the exact pagedQuery function we publish as the recommended pattern, and it is reachable on the production data set today.
We already knew, and documented it as folklore.public/API.html warns three times that "your application may experience strange behavior with large limits, such as ?limit=1000" (lines 507, 612, 730). The strange behavior is this truncation. A documented warning not to trigger a bug is not a fix, and nothing enforces it — a client sets its own MAX_LIMIT above the server's and the failure is silent from that point on.
Clients have no way to discover the limits. No response header carries the applied limit/skip or the server maximums. The only Link header emitted is the JSON-LD context (utils.js:163). HEAD /query returns Content-Length for the page it would have sent, not a total count (controllers/history.js:86), and it answers an empty page with a 404 where POST /query answers 200 [], so it cannot be used to probe ahead either. GET /v1/api lists endpoints and nothing else, and the checked-in OpenAPI contract does not mention limit or skip at all. A well-written client that wants to defend itself cannot.
Evidence
Verified 2026-09-02 against three deployments, all read-only POST /query and POST /search requests:
local — the pm2 instance at localhost:3001, which reads the same collection as devstore.rerum.io (the rerum-test cluster, annotationStore.alpha)
devstore — https://devstore.rerum.io
store — https://store.rerum.io, production
Independently re-run twice on 2026-09-03, read-only, against localhost:3001 for source lines and endpoint behavior, with the clamp, skip-repeat, and HEADContent-Length checks re-run against devstore.rerum.io and store.rerum.io. Every reproducible number below held: the clamp values on all three deployments, the repeating page past skip=100000 (…4c0ef016 at skip=99999, …4c0ef019 at skip=100000 through skip=500000 locally; …645fb6b6 / …645fb6b7 on production), the HEAD /queryContent-Length repeat (1883 local, 1891 devstore, 1404 store), the production reachability table, every parsing edge case, and the tiling checks. The environment-variable fix was verified end-to-end: setting RERUM_MAX_QUERY_LIMIT=10 / RERUM_MAX_QUERY_SKIP=20 in the local .env and restarting pm2 made limit=1000 return exactly 10 documents and skip=20, 25, and 100 return the identical page; the .env change was then reverted and pm2 restarted again to restore the original 500 / 100000 caps. Two citations were corrected along the way: the published pagedQuery function in public/API.html begins at line 555, not 556, and the getPagination() test block is __tests__/utils.test.js:329-354, not 329-355.
Limit is clamped silently, both endpoints, all deployments
Requested limit
/query returned
/search returned
100
100
100
500
500
500
1000
500
500
5000
500
500
Identical on local, devstore, and store (limit=1000 returns 500 on all three, both endpoints). Nothing in the response headers describes pagination: the clamped response carries Allow, Content-Type, Content-Length, an ETag, the JSON-LD context Link, and the CORS headers — no applied limit or skip, no maximum, no total.
Skip is clamped to a fixed page that then repeats forever
Querying {"__rerum.APIversion":{"$exists":true}} with limit=2 and comparing the first returned id:
skip
local / devstore
store (production)
9999
…e8255df7
…a7acc9f3
10000
…e8255f68
…a7acc9f4
10001
…e8255d5e
…a7acc9f5
99999
…4c0ef016
…645fb6b6
100000
…4c0ef019
…645fb6b7
100001
…4c0ef019
…645fb6b7
150000
…4c0ef019
…645fb6b7
500000
…4c0ef019
—
The effective skip maximum is 100000 on every deployment, including production. Consecutive pages at skip=100000 and skip=100100 with limit=100 return byte-identical id lists (same md5 over the ids). Any client advancing past 100000 receives the same records indefinitely.
HEAD /query behaves identically: skip=100000 and skip=150000 report the same Content-Length for the same repeated page on every deployment (1883 local, 1891 devstore, 1404 store).
/search applies the same clamp, but it slices the page from its merged in-memory set (see the /search issue), so the repeated page only appears for a term with more than 100000 merged matches. No term tried on the dev collection reaches that — line, the, and of all return an empty page at skip=100000, 100100, and 150000 — so the documented client terminates on /search today. The mechanism is identical; only the data keeps it out of reach.
The loop is reachable on production with realistic queries
Probing store.rerum.io with limit=1 at increasing skip:
Query
skip=10000
skip=50000
skip=100000
{"@type":"oa:Annotation"}
1
1
1
{"__rerum.generatedBy":{"$exists":true}}
1
1
1
{"__rerum.APIversion":{"$exists":true}}
1
1
1
{"@type":"Annotation"}
1
1
0
{"type":"Annotation"}
0
0
0
{"type":"Thing"}
0
0
0
A document at skip=100000 means the query has more matches than the cap. {"@type":"oa:Annotation"} — every IIIF 2.1 annotation — is over the cap on production right now. {"@type":"Annotation"} is between 50000 and 100000 and growing toward it.
The documented client does not terminate
The published pagedQuery from public/API.html:555 was run verbatim — only a request counter, an iteration guard, and logging added — against the local deployment at the real caps (500 / 100000).
Query {"type":"Thing"} with limit=100, 29 matching documents. Terminates correctly:
From request #3 on, every page is the same page. Without the guard this does not stop and the accumulator grows without bound. The same run against a deployment with the caps temporarily lowered to limit=10 / skip=20 shows the same shape from skip=20 on: 12 requests, 110 records accumulated, 29 distinct.
Note that the published example never terminates on this query at any page size, because its only stop condition is an empty page. At limit=1000 it receives 500-record pages, advances by 500, reaches the cap after 200 requests, and then repeats forever exactly as above.
The other common client shape fails the other way. Running the same function with its stop condition changed to page.length < limit — the shape the reporting client used — at limit=1000 against the same query:
req #1: skip=0 -> 500 docs
=> 500 records (client believes the walk is complete)
500 < 1000, so the client stops and reports a complete walk of 500 records. The query matches more than 100000.
The configured maximums are inert
.env in this working tree (the deploy workflows write the servers' .env from the DEV_FULL_ENV / PROD_FULL_ENV secrets in the same shape) sets:
MAX_QUERY_LIMIT=500
MAX_QUERY_SKIP=10000
The code reads different names (controllers/utils.js:12-13):
env-loader.js loads keys verbatim and does no prefixing, so neither RERUM_-prefixed variable is ever set and both fall back to the code defaults. limit appears configured only because 500 happens to be both the intended value and the default.
Confirmed three ways:
Both public deployments clamp at exactly the code defaults, 500 and 100000 (tables above). If MAX_QUERY_SKIP=10000 is set on either server, it is being ignored. If a configured skip cap of 10000 were honored, skip=9999, skip=10000 and skip=10001 would return the same page; on every deployment they return three different pages.
Adding the prefixed names to the local .env and restarting takes effect immediately: RERUM_MAX_QUERY_LIMIT=10 / RERUM_MAX_QUERY_SKIP=20 makes limit=1000 return 10 documents and skip=20, 25, 100 return an identical page. This both proves the diagnosis and validates the rename as a fix.
The existing unit tests for getPagination() (__tests__/utils.test.js:329-354) assert only that a huge limit is clamped "below Number.MAX_SAFE_INTEGER". That passes for any cap under any variable name, which is why the mismatch was never caught. Nothing documents the keys either: .env is not tracked and the repository has no .env template, the 251 memory reins #262 description that introduced them does not name them, and they appear in neither CONTRIBUTING.md, README.md, nor .github/copilot-instructions.md.
The operator-visible consequence: someone tightened skip to 10000 in .env and the server has been allowing 100000 ever since.
Parsing edge cases
Both endpoints, from clampNonNegativeInt() (controllers/utils.js:15). Every one of these returns 200:
limit=1e3 is the worst of these: Number.parseInt("1e3", 10) is 1, so the client receives a single record. A length < limit client stops after one object and reports a completed walk. The same parse makes skip=1e3 land at offset 1 rather than 1000. Non-numeric and non-positive values silently become the defaults rather than being rejected.
A repeated parameter is silently reduced to its first value, from the same parseInt call. Express 5's simple query parser (the default this app uses — app.set('query parser') is never called) hands getPagination() an array, and Number.parseInt(["100","200"], 10) coerces it to the string "100,200" and parses 100:
?limit=100&limit=200 -> 100 records
?limit=200&limit=100 -> 200 records
?skip=0&skip=1000&limit=1 -> offset 0
A bracketed parameter also silently becomes the default: ?limit[a]=5 returns 100 records, because the simple parser keeps limit[a] as a literal key and req.query.limit is never set. Under the extended parser it would instead arrive as the object {a:"5"}, parseInt would give NaN, and the fallback would produce the same 100 — so this one is insensitive to the parser choice, but only by accident. Neither case is an error today; both are cases where a client's intent and the server's behavior differ with nothing on the wire to say so.
Documented default is wrong
public/API.html:506 states the response is "limited to 10 records" by default. The actual default is 100 (getPagination(req.query, 100), called at controllers/crud.js:77 and controllers/search.js:274). A request with no limit parameter returns 100.
The machine-readable contract does not describe pagination
openapi/contracts/core-provider.openapi.yaml is the checked-in contract for this API — synced to the shared spec repository by .github/workflows/sync-rerum-shared-openapi.yml and guarded by __tests__/openapi_sync_artifacts.test.js. It defines /api/query (POST and HEAD), /api/search, and /api/search/phrase, and none of the four declares a limit or skip parameter. The words do not appear anywhere in the file, and neither does any pagination-related response header. The only reusable query parameters it defines are ExpansionGenerator and ExpansionCreator, for /id/{id}/expanded.
So a client generated from the contract cannot page at all, and a client written by hand against the contract has no reason to believe paging exists. Whatever shape the fix takes, the parameters, their maximums, and any Link header need to land here as well as in public/API.html — otherwise the honest behavior is still undiscoverable through the artifact that is supposed to be authoritative.
HEAD /query is not a count
HEAD /query?limit=2 returns Content-Length: 11832, which is exactly the byte size of the body POST /query?limit=2 returns. limit=100 gives 68396 and limit=1000 gives 949640 (the clamped 500-record page). It reports the size of one page, subject to the same clamping, and says nothing about how many records match.
It also disagrees with POST about what the end of a result set looks like. For the same request body and the same pagination parameters:
POST /query?limit=2 {"type":"NoSuchTypeAtAll"} -> 200, []
HEAD /query?limit=2 {"type":"NoSuchTypeAtAll"} -> 404
POST /query?limit=2&skip=100 {"type":"Thing"} -> 200, [] (29 matches, so this is past the end)
HEAD /query?limit=2&skip=100 {"type":"Thing"} -> 404
queryHeadRequest() treats an empty page as "no objects in the database matching the query" (controllers/history.js:99-101) rather than as an exhausted page. So the one endpoint that could cheaply tell a client where a result set ends signals that with a status code the paged endpoint never uses, and a client cannot use HEAD to probe ahead of POST without special-casing the mismatch. A HEAD response is also supposed to carry the headers its GET/POST counterpart would send; a 404 against a 200 is not that. #96 already questions whether these HEAD handlers should exist at all.
What is working correctly
/search pagination tiles consistently — five sequential limit=100 pages return exactly the same records in the same order as a single limit=500 request, and the tail terminates (on the dev collection, searching line: skip=3700 returns 100, skip=3800 returns 25, skip=3900 returns 0). Page boundaries and membership are stable, which is what this issue is about.
/query tiles as well: five sequential limit=100 pages from skip=50000 return the same 500 ids in the same order as one limit=500 request, and repeating a request returns the same page. One caveat for the fix: /query applies no sort, so page boundaries rest on MongoDB natural order. That holds in practice because RERUM marks deletions rather than removing documents, but it is not a guarantee the server makes, and a rel="next" implementation should sort on _id so that consecutive pages are deterministic by construction.
That is not the same as /search being correct. It pages in application memory, its results are not ordered by relevance, and its merge step silently drops some matching documents before paging even begins — so the "honest" tail is honest only relative to a pool that has already lost records. All three are covered in the separate /search issue.
Affected lines
File
Line
Current
controllers/utils.js
12-13
Reads RERUM_MAX_QUERY_LIMIT / RERUM_MAX_QUERY_SKIP; .env sets the unprefixed names
controllers/utils.js
15-19
clampNonNegativeInt() silently substitutes fallbacks and caps
controllers/utils.js
21-28
getPagination() returns clamped values with no report of clamping
controllers/crud.js
77, 87
/query paging
controllers/history.js
89, 91
HEAD /query paging; no total count
controllers/history.js
99-103
HEAD /query returns 404 on an empty page where POST /query returns 200 []
controllers/gog.js
36, 167
/gog/fragmentsInManuscript and /gog/glossesInManuscript paging, default 50; same helper, same clamping
controllers/search.js
274, 282
/search paging
controllers/search.js
360, 368
/search/phrase paging
utils.js
163
configureLDHeadersFor() emits only the context Link
__tests__/utils.test.js
329-354
getPagination() tests pin neither the configured cap nor the env key names
openapi/contracts/core-provider.openapi.yaml
210, 233, 241, 267
/api/query, HEAD /api/query, /api/search, /api/search/phrase declare no limit or skip parameter
public/API.html
506
Documents a default of 10; actual default is 100
public/API.html
507, 612, 730
Warns about "strange behavior" instead of the server being honest
public/API.html
555
Published pagedQuery example is the non-terminating shape
Proposed change
The goal is that a correct client never has to guess. Ordered by how much data loss each item prevents.
1. Reject an out-of-range skip instead of clamping it
Silently returning a page the client did not ask for, forever, has no defensible reading. Return 400 naming the maximum:
{
"message": "The skip value 150000 exceeds the maximum of 100000.",
"status": 400
}
This alone converts the non-terminating loop into an immediate, legible failure.
2. Advertise the next page with a Link header
Link: <…>; rel="next" (RFC 8288) when another page exists, omitted when the result set is exhausted. Clients follow next and stop when it is absent, which makes every length-based stop condition irrelevant.
The server can know this cheaply by over-fetching one record and trimming it before serialization:
/search: $limit: limit + skip + 1 per branch, then slice as today
Access-Control-Expose-Headers: * is already set, so browser clients can read it. Note that configureLDHeadersFor() currently assigns Link as a single string — rel="next" needs to be appended to the existing context link rather than replacing it.
3. Make an over-maximum limit visible
Either reject it with a 400 the way skip is handled above, or keep clamping as a safety valve and report the applied value in a response header. Clamping is reasonable server behavior; doing it in silence is not. Rejecting is the more honest of the two and is the recommendation, though it is the more breaking of the two — worth a decision on this thread.
4. Fix the environment variable names
Either rename the keys in .env on both deploy servers to the RERUM_-prefixed form, or change controllers/utils.js to read the unprefixed names. Whichever way, the two must agree, CONTRIBUTING.md should document the keys alongside the rest of the .env contents, and a unit test should set the key and assert the cap it produces — the current tests would pass under either name. Reading both names with the prefixed one winning would avoid a coordinated deploy, if that is preferred.
5. Reject unparseable and non-positive limit
limit=abc, limit=-5, and limit=0 should be 400s, not a silent 100. limit=1e3 (and skip=1e3) should either parse as 1000 or be rejected; returning one record is the worst available option. A repeated parameter (?limit=100&limit=200) should be rejected too — Number.parseInt currently coerces the array to a comma-joined string and takes the first value, which is a guess dressed up as a result. Validating the raw parameter as a decimal integer string, and rejecting anything that is not a string, covers all of these in one place. ?limit[a]=5 is the one case that cannot be caught: under the simple parser the server never receives a limit key at all, so it is indistinguishable from a request that omitted the parameter.
6. Publish the limits
Add the effective maximums and default to the GET /v1/api response so clients can configure themselves against the deployment they are actually talking to rather than a hardcoded guess. Today that response is a plain list of endpoint descriptions and carries no pagination information.
7. Declare limit and skip in the OpenAPI contract
Add a shared PageLimit / PageSkip parameter pair under components/parameters in openapi/contracts/core-provider.openapi.yaml and reference it from /api/query (POST and HEAD), /api/search, and /api/search/phrase, with the maximums in the schema and a 400 response for out-of-range values once items 1, 3, and 5 land. If rel="next" ships, describe the Link response header there too. Without this the contract keeps describing an API that has no pagination.
8. Make HEAD /query agree with POST /query
An empty page is not a 404. HEAD /query should return 200 with Content-Length: 2 (the [] body POST would send) so that a client can probe with either verb and read the same signal. This is a small change inside queryHeadRequest(), and it is worth resolving alongside #96 rather than independently of it.
Notes
Fixing getPagination() covers /query, HEAD /query, /search, /search/phrase, and the two /gog/*InManuscript endpoints together, which is what keeps them consistent by construction. The rel="next" work is the only part needing per-controller changes.
public/API.html needs updating in the same PR: correct the documented default from 10 to 100, replace the "strange behavior" warnings with the actual maximums, and revise the pagedQuery example to follow rel="next" (or at minimum to stop on a short page rather than only on an empty one).
Items 1, 3, 5, and 8 change response codes for requests that currently return 200 (or, for 8, a 404 that becomes a 200). That is a breaking change for any client relying on the clamping, and it should land on dev first with a note to known client maintainers.
The two issues compound in one place worth noting here: because /search pages in application memory, the skip maximum is also what bounds how many documents that endpoint will pull into the API process for a single request. Lowering the cap helps both issues.
History: getPagination() and the caps arrived in 251 memory reins #262 ("251 memory reins", merged 2026-05-07) as the fix for RERUM memory grows unbounded #251; that PR's description standardizes pagination across the CRUD, search, history, and Gallery of Glosses controllers but never names the environment variables. Query and version endpoints load unbounded results into memory #252 is still open: its first recommendation (a default and maximum limit on query endpoints) is what 251 memory reins #262 shipped, and its third (cursor-based pagination) is the rel="next" work proposed here; its getAllVersions and /history items are separate. HEAD history - since - query #96 questions whether HEAD /query should exist.
Acceptance criteria
skip above the maximum returns 400 rather than a repeated page
limit above the maximum is either rejected or reported in a response header — never silently applied
Responses carry Link: …; rel="next" while more results exist, and omit it on the final page
limit=abc, limit=-5, and limit=0 return 400
.env keys and controllers/utils.js agree on the maximum names, verified by observing the configured cap take effect on devstore
A unit test sets the maximum via the environment and asserts the resulting cap
/query, /search, and the other getPagination() callers (HEAD /query, /gog/*InManuscript) behave identically for every case above
public/API.html documents the real default, the real maximums, and a paged example that terminates correctly
A repeated limit or skip parameter is rejected rather than silently reduced to its first value
HEAD /query and POST /query return the same status for the same request, including on an empty page
openapi/contracts/core-provider.openapi.yaml declares limit and skip on /api/query, HEAD /api/query, /api/search, and /api/search/phrase, with the maximums and any new response codes
Regression tests cover the clamp boundaries and the rel="next" presence and absence
Detailed verification report. This is the full investigation this issue and its sub-issues were written from.
Summary
/queryand/searchacceptlimitandskip, clamp both to server maximums, and then return the clamped page with no indication that clamping happened.A client cannot tell a full page from a truncated one, and cannot tell "no more results" from "you asked for more than I will give you."
Both endpoints share
getPagination()(controllers/utils.js:21), so this is one contract with one fix site, not two. The same helper also paginates the two Gallery of Glosses endpoints (controllers/gog.js:36,167, default 50, JWT-gated), so they inherit whatever changes here. This issue covers that shared contract. Where/searchperforms its paging — in application memory rather than in MongoDB — is a separate concern, tracked in the/searchpaging issue.Three distinct failures fall out of that silence:
results.length < limitsilently drops records.results.length === 0and advances byresults.lengthnever terminates onceskippasses its maximum, because every subsequent request returns the same page..envand the code disagree on the variable names.The second failure is the one to look at first: the non-terminating client is the paged-query recipe published in our own API documentation (
public/API.html:555), and the loop is reachable on production today with ordinary typed queries.Every behavior below was verified against the public deployments
https://store.rerum.ioandhttps://devstore.rerum.io, not only locally.Why this matters
Pagination is a contract. The server promises that a client can walk a result set to completion by advancing
skipuntil the results run out. Today the server breaks that promise in whichever direction the client happens to lean:A
length < limitstop condition loses data. The client asks forlimit=1000, the server decides on 500, returns 500, and says nothing.500 < 1000reads as "last page." The walk terminates early and the client reports success on a partial result set. Nothing on the wire distinguishes this from a genuine final page. This is how the problem was found: a client with a configurable page size above 500 walked a result set, received 500, and stopped.A
length === 0stop condition hangs. Past theskipmaximum every request returns an identical page. A client that concatenates and advances byresults.lengthre-fetches that same page forever, growing its accumulator without bound. This is not a hypothetical client shape — it is the exactpagedQueryfunction we publish as the recommended pattern, and it is reachable on the production data set today.We already knew, and documented it as folklore.
public/API.htmlwarns three times that "your application may experience strange behavior with large limits, such as ?limit=1000" (lines 507, 612, 730). The strange behavior is this truncation. A documented warning not to trigger a bug is not a fix, and nothing enforces it — a client sets its ownMAX_LIMITabove the server's and the failure is silent from that point on.Clients have no way to discover the limits. No response header carries the applied
limit/skipor the server maximums. The onlyLinkheader emitted is the JSON-LD context (utils.js:163).HEAD /queryreturnsContent-Lengthfor the page it would have sent, not a total count (controllers/history.js:86), and it answers an empty page with a 404 wherePOST /queryanswers200 [], so it cannot be used to probe ahead either.GET /v1/apilists endpoints and nothing else, and the checked-in OpenAPI contract does not mentionlimitorskipat all. A well-written client that wants to defend itself cannot.Evidence
Verified 2026-09-02 against three deployments, all read-only
POST /queryandPOST /searchrequests:localhost:3001, which reads the same collection asdevstore.rerum.io(thererum-testcluster,annotationStore.alpha)https://devstore.rerum.iohttps://store.rerum.io, productionIndependently re-run twice on 2026-09-03, read-only, against
localhost:3001for source lines and endpoint behavior, with the clamp,skip-repeat, andHEADContent-Lengthchecks re-run againstdevstore.rerum.ioandstore.rerum.io. Every reproducible number below held: the clamp values on all three deployments, the repeating page pastskip=100000(…4c0ef016atskip=99999,…4c0ef019atskip=100000throughskip=500000locally;…645fb6b6/…645fb6b7on production), theHEAD /queryContent-Lengthrepeat (1883 local, 1891 devstore, 1404 store), the production reachability table, every parsing edge case, and the tiling checks. The environment-variable fix was verified end-to-end: settingRERUM_MAX_QUERY_LIMIT=10/RERUM_MAX_QUERY_SKIP=20in the local.envand restarting pm2 madelimit=1000return exactly 10 documents andskip=20,25, and100return the identical page; the.envchange was then reverted and pm2 restarted again to restore the original 500 / 100000 caps. Two citations were corrected along the way: the publishedpagedQueryfunction inpublic/API.htmlbegins at line 555, not 556, and thegetPagination()test block is__tests__/utils.test.js:329-354, not 329-355.Limit is clamped silently, both endpoints, all deployments
limit/queryreturned/searchreturnedIdentical on local, devstore, and store (
limit=1000returns 500 on all three, both endpoints). Nothing in the response headers describes pagination: the clamped response carriesAllow,Content-Type,Content-Length, anETag, the JSON-LD contextLink, and the CORS headers — no appliedlimitorskip, no maximum, no total.Skip is clamped to a fixed page that then repeats forever
Querying
{"__rerum.APIversion":{"$exists":true}}withlimit=2and comparing the first returned id:skip…e8255df7…a7acc9f3…e8255f68…a7acc9f4…e8255d5e…a7acc9f5…4c0ef016…645fb6b6…4c0ef019…645fb6b7…4c0ef019…645fb6b7…4c0ef019…645fb6b7…4c0ef019The effective
skipmaximum is 100000 on every deployment, including production. Consecutive pages atskip=100000andskip=100100withlimit=100return byte-identical id lists (same md5 over the ids). Any client advancing past 100000 receives the same records indefinitely.HEAD /querybehaves identically:skip=100000andskip=150000report the sameContent-Lengthfor the same repeated page on every deployment (1883 local, 1891 devstore, 1404 store)./searchapplies the same clamp, but it slices the page from its merged in-memory set (see the/searchissue), so the repeated page only appears for a term with more than 100000 merged matches. No term tried on the dev collection reaches that —line,the, andofall return an empty page atskip=100000,100100, and150000— so the documented client terminates on/searchtoday. The mechanism is identical; only the data keeps it out of reach.The loop is reachable on production with realistic queries
Probing
store.rerum.iowithlimit=1at increasingskip:skip=10000skip=50000skip=100000{"@type":"oa:Annotation"}{"__rerum.generatedBy":{"$exists":true}}{"__rerum.APIversion":{"$exists":true}}{"@type":"Annotation"}{"type":"Annotation"}{"type":"Thing"}A document at
skip=100000means the query has more matches than the cap.{"@type":"oa:Annotation"}— every IIIF 2.1 annotation — is over the cap on production right now.{"@type":"Annotation"}is between 50000 and 100000 and growing toward it.The documented client does not terminate
The published
pagedQueryfrompublic/API.html:555was run verbatim — only a request counter, an iteration guard, and logging added — against the local deployment at the real caps (500 / 100000).Query
{"type":"Thing"}withlimit=100, 29 matching documents. Terminates correctly:Query
{"__rerum.APIversion":{"$exists":true}}withlimit=100, entered atskip=99800so the cap is reached cheaply. Never terminates:From request #3 on, every page is the same page. Without the guard this does not stop and the accumulator grows without bound. The same run against a deployment with the caps temporarily lowered to
limit=10/skip=20shows the same shape fromskip=20on: 12 requests, 110 records accumulated, 29 distinct.Note that the published example never terminates on this query at any page size, because its only stop condition is an empty page. At
limit=1000it receives 500-record pages, advances by 500, reaches the cap after 200 requests, and then repeats forever exactly as above.The other common client shape fails the other way. Running the same function with its stop condition changed to
page.length < limit— the shape the reporting client used — atlimit=1000against the same query:500 < 1000, so the client stops and reports a complete walk of 500 records. The query matches more than 100000.The configured maximums are inert
.envin this working tree (the deploy workflows write the servers'.envfrom theDEV_FULL_ENV/PROD_FULL_ENVsecrets in the same shape) sets:The code reads different names (
controllers/utils.js:12-13):env-loader.jsloads keys verbatim and does no prefixing, so neitherRERUM_-prefixed variable is ever set and both fall back to the code defaults.limitappears configured only because 500 happens to be both the intended value and the default.Confirmed three ways:
MAX_QUERY_SKIP=10000is set on either server, it is being ignored. If a configuredskipcap of 10000 were honored,skip=9999,skip=10000andskip=10001would return the same page; on every deployment they return three different pages..envand restarting takes effect immediately:RERUM_MAX_QUERY_LIMIT=10/RERUM_MAX_QUERY_SKIP=20makeslimit=1000return 10 documents andskip=20,25,100return an identical page. This both proves the diagnosis and validates the rename as a fix.getPagination()(__tests__/utils.test.js:329-354) assert only that a hugelimitis clamped "belowNumber.MAX_SAFE_INTEGER". That passes for any cap under any variable name, which is why the mismatch was never caught. Nothing documents the keys either:.envis not tracked and the repository has no.envtemplate, the 251 memory reins #262 description that introduced them does not name them, and they appear in neitherCONTRIBUTING.md,README.md, nor.github/copilot-instructions.md.The operator-visible consequence: someone tightened
skipto 10000 in.envand the server has been allowing 100000 ever since.Parsing edge cases
Both endpoints, from
clampNonNegativeInt()(controllers/utils.js:15). Every one of these returns 200:limit=1e3is the worst of these:Number.parseInt("1e3", 10)is1, so the client receives a single record. Alength < limitclient stops after one object and reports a completed walk. The same parse makesskip=1e3land at offset 1 rather than 1000. Non-numeric and non-positive values silently become the defaults rather than being rejected.A repeated parameter is silently reduced to its first value, from the same
parseIntcall. Express 5'ssimplequery parser (the default this app uses —app.set('query parser')is never called) handsgetPagination()an array, andNumber.parseInt(["100","200"], 10)coerces it to the string"100,200"and parses100:A bracketed parameter also silently becomes the default:
?limit[a]=5returns 100 records, because thesimpleparser keepslimit[a]as a literal key andreq.query.limitis never set. Under theextendedparser it would instead arrive as the object{a:"5"},parseIntwould giveNaN, and the fallback would produce the same 100 — so this one is insensitive to the parser choice, but only by accident. Neither case is an error today; both are cases where a client's intent and the server's behavior differ with nothing on the wire to say so.Documented default is wrong
public/API.html:506states the response is "limited to 10 records" by default. The actual default is 100 (getPagination(req.query, 100), called atcontrollers/crud.js:77andcontrollers/search.js:274). A request with nolimitparameter returns 100.The machine-readable contract does not describe pagination
openapi/contracts/core-provider.openapi.yamlis the checked-in contract for this API — synced to the shared spec repository by.github/workflows/sync-rerum-shared-openapi.ymland guarded by__tests__/openapi_sync_artifacts.test.js. It defines/api/query(POST and HEAD),/api/search, and/api/search/phrase, and none of the four declares alimitorskipparameter. The words do not appear anywhere in the file, and neither does any pagination-related response header. The only reusable query parameters it defines areExpansionGeneratorandExpansionCreator, for/id/{id}/expanded.So a client generated from the contract cannot page at all, and a client written by hand against the contract has no reason to believe paging exists. Whatever shape the fix takes, the parameters, their maximums, and any
Linkheader need to land here as well as inpublic/API.html— otherwise the honest behavior is still undiscoverable through the artifact that is supposed to be authoritative.HEAD /queryis not a countHEAD /query?limit=2returnsContent-Length: 11832, which is exactly the byte size of the bodyPOST /query?limit=2returns.limit=100gives 68396 andlimit=1000gives 949640 (the clamped 500-record page). It reports the size of one page, subject to the same clamping, and says nothing about how many records match.It also disagrees with
POSTabout what the end of a result set looks like. For the same request body and the same pagination parameters:queryHeadRequest()treats an empty page as "no objects in the database matching the query" (controllers/history.js:99-101) rather than as an exhausted page. So the one endpoint that could cheaply tell a client where a result set ends signals that with a status code the paged endpoint never uses, and a client cannot useHEADto probe ahead ofPOSTwithout special-casing the mismatch. A HEAD response is also supposed to carry the headers its GET/POST counterpart would send; a 404 against a 200 is not that. #96 already questions whether these HEAD handlers should exist at all.What is working correctly
/searchpagination tiles consistently — five sequentiallimit=100pages return exactly the same records in the same order as a singlelimit=500request, and the tail terminates (on the dev collection, searchingline:skip=3700returns 100,skip=3800returns 25,skip=3900returns 0). Page boundaries and membership are stable, which is what this issue is about./querytiles as well: five sequentiallimit=100pages fromskip=50000return the same 500 ids in the same order as onelimit=500request, and repeating a request returns the same page. One caveat for the fix:/queryapplies nosort, so page boundaries rest on MongoDB natural order. That holds in practice because RERUM marks deletions rather than removing documents, but it is not a guarantee the server makes, and arel="next"implementation should sort on_idso that consecutive pages are deterministic by construction.That is not the same as
/searchbeing correct. It pages in application memory, its results are not ordered by relevance, and its merge step silently drops some matching documents before paging even begins — so the "honest" tail is honest only relative to a pool that has already lost records. All three are covered in the separate/searchissue.Affected lines
controllers/utils.jsRERUM_MAX_QUERY_LIMIT/RERUM_MAX_QUERY_SKIP;.envsets the unprefixed namescontrollers/utils.jsclampNonNegativeInt()silently substitutes fallbacks and capscontrollers/utils.jsgetPagination()returns clamped values with no report of clampingcontrollers/crud.js/querypagingcontrollers/history.jsHEAD /querypaging; no total countcontrollers/history.jsHEAD /queryreturns 404 on an empty page wherePOST /queryreturns200 []controllers/gog.js/gog/fragmentsInManuscriptand/gog/glossesInManuscriptpaging, default 50; same helper, same clampingcontrollers/search.js/searchpagingcontrollers/search.js/search/phrasepagingutils.jsconfigureLDHeadersFor()emits only the contextLink__tests__/utils.test.jsgetPagination()tests pin neither the configured cap nor the env key namesopenapi/contracts/core-provider.openapi.yaml/api/query,HEAD /api/query,/api/search,/api/search/phrasedeclare nolimitorskipparameterpublic/API.htmlpublic/API.htmlpublic/API.htmlpagedQueryexample is the non-terminating shapeProposed change
The goal is that a correct client never has to guess. Ordered by how much data loss each item prevents.
1. Reject an out-of-range
skipinstead of clamping itSilently returning a page the client did not ask for, forever, has no defensible reading. Return 400 naming the maximum:
{ "message": "The skip value 150000 exceeds the maximum of 100000.", "status": 400 }This alone converts the non-terminating loop into an immediate, legible failure.
2. Advertise the next page with a
LinkheaderLink: <…>; rel="next"(RFC 8288) when another page exists, omitted when the result set is exhausted. Clients follownextand stop when it is absent, which makes every length-based stop condition irrelevant.The server can know this cheaply by over-fetching one record and trimming it before serialization:
/query:db.find(props).limit(limit + 1).skip(skip)/search:$limit: limit + skip + 1per branch, then slice as todayAccess-Control-Expose-Headers: *is already set, so browser clients can read it. Note thatconfigureLDHeadersFor()currently assignsLinkas a single string —rel="next"needs to be appended to the existing context link rather than replacing it.3. Make an over-maximum
limitvisibleEither reject it with a 400 the way
skipis handled above, or keep clamping as a safety valve and report the applied value in a response header. Clamping is reasonable server behavior; doing it in silence is not. Rejecting is the more honest of the two and is the recommendation, though it is the more breaking of the two — worth a decision on this thread.4. Fix the environment variable names
Either rename the keys in
.envon both deploy servers to theRERUM_-prefixed form, or changecontrollers/utils.jsto read the unprefixed names. Whichever way, the two must agree,CONTRIBUTING.mdshould document the keys alongside the rest of the.envcontents, and a unit test should set the key and assert the cap it produces — the current tests would pass under either name. Reading both names with the prefixed one winning would avoid a coordinated deploy, if that is preferred.5. Reject unparseable and non-positive
limitlimit=abc,limit=-5, andlimit=0should be 400s, not a silent 100.limit=1e3(andskip=1e3) should either parse as 1000 or be rejected; returning one record is the worst available option. A repeated parameter (?limit=100&limit=200) should be rejected too —Number.parseIntcurrently coerces the array to a comma-joined string and takes the first value, which is a guess dressed up as a result. Validating the raw parameter as a decimal integer string, and rejecting anything that is not a string, covers all of these in one place.?limit[a]=5is the one case that cannot be caught: under thesimpleparser the server never receives alimitkey at all, so it is indistinguishable from a request that omitted the parameter.6. Publish the limits
Add the effective maximums and default to the
GET /v1/apiresponse so clients can configure themselves against the deployment they are actually talking to rather than a hardcoded guess. Today that response is a plain list of endpoint descriptions and carries no pagination information.7. Declare
limitandskipin the OpenAPI contractAdd a shared
PageLimit/PageSkipparameter pair undercomponents/parametersinopenapi/contracts/core-provider.openapi.yamland reference it from/api/query(POST and HEAD),/api/search, and/api/search/phrase, with the maximums in the schema and a 400 response for out-of-range values once items 1, 3, and 5 land. Ifrel="next"ships, describe theLinkresponse header there too. Without this the contract keeps describing an API that has no pagination.8. Make
HEAD /queryagree withPOST /queryAn empty page is not a 404.
HEAD /queryshould return 200 withContent-Length: 2(the[]bodyPOSTwould send) so that a client can probe with either verb and read the same signal. This is a small change insidequeryHeadRequest(), and it is worth resolving alongside #96 rather than independently of it.Notes
getPagination()covers/query,HEAD /query,/search,/search/phrase, and the two/gog/*InManuscriptendpoints together, which is what keeps them consistent by construction. Therel="next"work is the only part needing per-controller changes.public/API.htmlneeds updating in the same PR: correct the documented default from 10 to 100, replace the "strange behavior" warnings with the actual maximums, and revise thepagedQueryexample to followrel="next"(or at minimum to stop on a short page rather than only on an empty one)./searchpages in application memory, theskipmaximum is also what bounds how many documents that endpoint will pull into the API process for a single request. Lowering the cap helps both issues.getPagination()and the caps arrived in 251 memory reins #262 ("251 memory reins", merged 2026-05-07) as the fix for RERUM memory grows unbounded #251; that PR's description standardizes pagination across the CRUD, search, history, and Gallery of Glosses controllers but never names the environment variables. Query and version endpoints load unbounded results into memory #252 is still open: its first recommendation (a default and maximumlimiton query endpoints) is what 251 memory reins #262 shipped, and its third (cursor-based pagination) is therel="next"work proposed here; itsgetAllVersionsand/historyitems are separate. HEAD history - since - query #96 questions whetherHEAD /queryshould exist.Acceptance criteria
skipabove the maximum returns 400 rather than a repeated pagelimitabove the maximum is either rejected or reported in a response header — never silently appliedLink: …; rel="next"while more results exist, and omit it on the final pagelimit=abc,limit=-5, andlimit=0return 400.envkeys andcontrollers/utils.jsagree on the maximum names, verified by observing the configured cap take effect on devstore/query,/search, and the othergetPagination()callers (HEAD /query,/gog/*InManuscript) behave identically for every case abovepublic/API.htmldocuments the real default, the real maximums, and a paged example that terminates correctlylimitorskipparameter is rejected rather than silently reduced to its first valueHEAD /queryandPOST /queryreturn the same status for the same request, including on an empty pageopenapi/contracts/core-provider.openapi.yamldeclareslimitandskipon/api/query,HEAD /api/query,/api/search, and/api/search/phrase, with the maximums and any new response codesrel="next"presence and absence