Public API — Versioning Policy
Last updated: September 2026
Docs portal: /api/v1/reference · /api/v2/reference (ReDoc) · /api/v1/docs · /api/v2/docs (Swagger UI) · /api/v1/docs-json · /api/v2/docs-json (raw OpenAPI 3.0)
Machine-generated reference: public-api-reference.md
SDK: @cmdash/api-sdk — v2 under client.<namespace> (the default) and client.v2.<namespace>; v1 under client.v1.<namespace> (see "SDK version targeting" below)
API v1 deprecation (effective 2026-08-24; v2 is now live)
This is the entry the "What happens to v1 when v2 ships" section below said would go here once a v2 actually existed. It now does.
- What changed, and why it required a new version. Two things this repo
committed to never changing silently on
/v1/*:GET /v1/compliance/frameworks/:slug/controlsandGET /v1/integrations/:id/healthreturn200 { error: '...' }for an unknown slug/id instead of a real404. Fixing that is itself a breaking change under this policy (a status-code-meaning change) — see "Error shapes" below. v2 fixes both; v1 keeps the200.- Seven of the fourteen declared API-key scopes had no route enforcing
them (
compliance:write,integrations:write,reports:read,reports:generate,webhooks:read,webhooks:write,audit-logs:read). Wiring a route to check a previously-unenforced scope silently expands what an already-issued key can do the moment that route ships — also a breaking change under this policy (see "What forces a v2" below). v2 enforces six of the seven with a real route each; the seventh,compliance:write, was removed fromVALID_PERMISSIONSentirely rather than carried forward unenforced — see "Per-scope resolution" below for why.
- v1 status: deprecated, not removed. Every
/v1/*response now carriesDeprecation: Mon, 24 Aug 2026 00:00:00 GMTandSunset: Wed, 24 Feb 2027 00:00:00 GMTheaders (RFC 8594), plusLink: </api/v2/reference>; rel="successor-version". Nothing about a/v1/*route's behavior changed to produce this entry — the headers are purely additive (see "What v1 guarantees" below). - Sunset date: 2027-02-24 (6 months out). There are zero live API keys in production as of this writing (verified 2026-08-24), so this window is conservative by policy rather than by any real integration's migration timeline — there is nothing to time it against yet. If a real integrator shows up before the sunset date, extend it; do not let the calendar force a removal nobody asked for.
- Where to go instead:
/v2/*— same auth, same rate limits, same response envelope, same pagination conventions as v1 (see the sections below; none of that changed). Only the two fixed endpoints and the newly- enforced scopes behave differently.packages/sdk-jsandpackages/sdk-pythonboth now cover v2 under av2sub-namespace (see "SDK version targeting", ROADMAP-V100 I4) — the SDKs' unprefixed namespace IS v2, so a new integration gets it by default.
This is the policy prose a generated doc can't produce on its own — see
framework-coverage.mjs's own header for why: classification and
commitments are a judgment call, written once by a human, not a fact parsed
out of source. Everything factual about the v1 surface (which endpoints
exist, which permission each requires) lives in the generated reference
doc instead, precisely so this file doesn't have to restate it and can't
drift from it.
v2 CSV export format change (2026-09-16, v8.13.0 — ROADMAP-V90 H4/H5/H6)
GET /v2/audit-logs/export?format=csv is the one public-API route that
returns a CSV file, and its BYTES changed. This is recorded here rather
than shipped silently because a file format is part of the contract even
when the JSON envelope is not involved.
What changed
- A UTF-8 BOM (
EF BB BF) now leads the file. Without it, Excel on Windows reads the file as the system code page and mis-renders every non-ASCII user name — the reason auditors were getting mojibake in the exact artifact they file. A parser that reads the first column of the first row without stripping the BOM will now see\ufeffIDinstead ofid;csv.readerin Python needsencoding='utf-8-sig', andpapaparse/csv-parsestrip it by default. - Records end with CRLF, not LF (RFC 4180 §2). Most parsers already accepted both.
- Headers are Title Case:
ID, Timestamp, User Email, User Name, Action, Resource, IP Address, Metadata— wasid,timestamp,user_email,user_name,action,resource,ip_address,metadata. Column ORDER and column MEANING are unchanged. A consumer reading by INDEX is unaffected; one reading by header name must update its map. Content-Dispositioniscmdash-<org-slug>-audit-log-<YYYY-MM-DD>.csv— wasaudit-log-export-<timestamp>.csv. A non-ASCII org slug is sent as bothfilename=(ASCII fallback) andfilename*=UTF-8''…(RFC 6266).- An empty date range returns
200with a header-only file. It used to return404 No audit logs found for the specified date range, which made "nothing happened in this window" indistinguishable from a broken export. Clients that treated that 404 as "no data" should now read zero data rows. - The 10,000-row cap is now stated. Responses carry
X-Total-Count(rows matching the range),X-TruncatedandX-Ceiling, and a capped file ends with a trailer row"Showing the first N of M rows — narrow the date range or filters to export the rest".X-Total-Records(rows in the file) is unchanged and still sent.format=jsoncarries the same three facts astotalAvailable,truncatedandceilingfields.
Why this was not a /v3. Items 1–4 are byte-level presentation of a
file, not the response-field contract items 2 and 5 of "What v1 guarantees"
cover; item 5 is a status-code change that makes the route more
conformant with the rest of v2 (the 404-for-empty-collection shape is the
same defect v2 already fixed on two other routes); item 6 is additive. It
is recorded here anyway, because a consumer parsing the file by header name
or asserting on the filename has to be told.
What "v1" is, mechanically
Every Public API v1 route is mounted at a path starting with v1/ — there is
no unversioned Public API route to migrate away from. The documentation portal
at /api/v1/* is built by filtering the live OpenAPI document down to exactly
the paths matching that prefix, so the v1 docs cannot advertise a route that
isn't real, or silently include something that shouldn't be public.
The usage endpoints (/api-usage/*) and the key-management endpoints
(/api-keys/*) are not part of the versioned surface — they're
JWT-authenticated dashboard endpoints for administering keys and viewing
usage, not data the v1 SDK/API-key surface exposes. The webhook management
endpoints (/webhooks/*) are the same: JWT-authenticated, unversioned,
out of scope for this document.
v2 is the same mechanism. Every v2 route is mounted at a path starting with
v2/, and the /api/v2/* documentation is built by the same
filter-the-live-spec technique, so the same guarantee holds there. Where a v2
route behaves identically to its v1 counterpart (posture, risks, gate, and two
of the three compliance and integration routes each), it returns the same
shape; every v2 route is declared explicitly in the generated reference, the
Postman collection and both SDKs.
What v1 guarantees
For every endpoint currently documented at /api/v1/reference:
- The route keeps responding at the same path with the same HTTP
method.
GET /v1/posture/latestwill not becomeGET /v1/postures/latestor aPOST. - Response fields are not removed or repurposed. A field present in a v1 response today keeps the same name and the same meaning. New optional fields may be added (clients must ignore unknown fields — this is standard REST/OpenAPI practice and nothing in the current SDK or any known integration depends on strict/closed object shapes).
- Existing query/body parameters keep working the same way. A parameter that's optional today stays optional; a parameter that accepts a given value keeps accepting it.
- Auth, permission scopes, and rate-limit semantics don't tighten
silently. A key with
risks:readtoday keeps being able to callGET /v1/riskstomorrow. If a scope's behavior needs to change, that's itself a breaking change (see below), not a patch. - The error envelope shape stays what
HttpExceptionFilterdocuments —{ success: false, requestId, timestamp, statusCode, error, message, details? }. See "Error shapes" below for two endpoints that don't follow this consistently — that inconsistency is called out explicitly rather than glossed over. Now fixed in v2 (a real404like every other:idlookup here); v1 keeps the inconsistent200on purpose, since fixing it in place would itself be the breaking change this policy exists to prevent.
Additive, backward-compatible changes (new endpoints, new optional fields/params, new permission scopes, new webhook event types) ship inside v1 with no version bump — that's the entire point of versioning by commitment rather than by "anything that changed gets a new number."
What forces a v2
Any of the following is a breaking change and requires a new version
prefix (/v2/...), never a silent change to /v1/...:
- Removing or renaming a response field, or changing what an existing field means (units, encoding, nullability going from "always present" to "sometimes null").
- Removing an endpoint, or changing its HTTP method.
- Making a previously-optional parameter required, or narrowing what values an existing parameter accepts.
- Changing the meaning of an existing status code (e.g. an endpoint that
returns
200with an error-shaped body today starts returning404for that same case — again, note the two known instances of this above are the reverse direction: fixing them to return proper 404s is the breaking change, not the bug itself). - Changing the pagination shape (
{ total, risks: [...] }today forGET /v1/risks) or the rate-limit header contract (X-RateLimit-Limit/Remaining/Reset). - Retiring a permission scope a key may already have been granted, in a way that changes what that key can still do.
What happens to v1 when v2 ships: v1 keeps running, unannounced-silent-break-free,
for a stated deprecation window before any v1 route is removed. v2 now
exists (see the "v1.0.0 deprecation" entry at the top of this document) —
that window is live: v1 is deprecated as of 2026-08-24 and sunsets
2027-02-24. Every /v1/* response carries the Deprecation/Sunset
response headers (applied per route, so they never appear on a v2 response).
"We changed it, sorry" after the fact is not an
acceptable way to run a versioned API that appears in procurement
conversations.
Per-scope resolution (v2)
For each of the seven scopes flagged unenforced below, v2 made one of two
honest choices — enforce it with a real route, or remove it from
VALID_PERMISSIONS entirely rather than leave a grant that does nothing:
| Scope | v2 resolution | Why |
|---|---|---|
integrations:write | Enforced — POST /v2/integrations/:id/sync | Triggers the same sync as the dashboard's "Sync now" button. No user attribution is needed. |
reports:read | Enforced — GET /v2/reports, GET /v2/reports/:id | The same report list and content the dashboard shows. Read-only and tenant-scoped. |
reports:generate | Enforced — POST /v2/reports/generate | Generates a report. The creator attribution is optional (some callers have no user identity), so an API key is a supported caller. |
webhooks:read | Enforced — GET /v2/webhooks, GET /v2/webhooks/:id/logs | The same registrations and delivery logs the dashboard shows. |
webhooks:write | Enforced — POST/PUT/DELETE /v2/webhooks, POST /v2/webhooks/:id/rotate-secret, POST /v2/webhooks/:id/test | Create, update, delete, secret rotation and test-event send need no user identity. Delivery-log replay is deliberately not exposed on v2: it requires a real user id for its audit trail, the same reason as compliance:write below. |
audit-logs:read | Enforced — GET /v2/audit-logs, GET /v2/audit-logs/export | The same audit-log list and export the dashboard offers. Read-only. |
policies:read | Enforced — GET /v2/policies, GET /v2/policies/:id | A1009 G8. Read-only, paginated (limit default 50, max 500; offset), filterable by status and category. The detail adds the active version's Markdown and the published version history. The access-code hash and public-portal access window are never returned. |
vendors:read | Enforced — GET /v2/vendors, GET /v2/vendors/:id | A1009 G8. Read-only, paginated, filterable by status, tier and criticality. The point-of-contact and internal-owner fields are not returned. |
evidence:read | Enforced — GET /v2/evidence, GET /v2/evidence/:id | A1009 G8. Read-only, paginated, filterable by status, collectionMethod, frameworkSlug and controlId. Raw integration payloads (metadata, diff) and reviewer notes are not returned. Distinct from evidence:write (POST /v2/evidence). |
assets:read | Enforced — GET /v2/assets, GET /v2/assets/:id | A1009 G8. Read-only, paginated, filterable by assetType and criticality. The FAIR dollar value is not returned. |
incidents:read | Enforced — GET /v2/incidents, GET /v2/incidents/:id | A1009 G8. Read-only, paginated, filterable by status and severity; each incident lists its linked controls. |
compliance:write | Removed from VALID_PERMISSIONS | The one operation it would have covered, updating a control assessment, requires a real user id for its audit trail and its compliance.milestone webhook. An API key has no user identity to supply, so this ships as a removal instead of a grant that does nothing. Creating a key with it is now rejected with a 400. This changes the unversioned key-management surface (/api-keys/*) only — no v1 or v2 data route's behavior changes, since no route ever checked this scope. |
SDK version targeting
Both @cmdash/api-sdk (JavaScript) and cmdash-api-sdk (Python) cover v1 AND v2 as separate
sub-namespaces. #575 introduced them with v1 on the bare namespace and
v2 under client.v2.*; I4 swapped which one is the default, because the
bare namespace is what every quickstart, README and copy-pasted sample
shows, and that was pointing new integrators at the version carrying a
Sunset header.
| before I4 | after I4 | |
|---|---|---|
client.<resource> | v1 | v2 |
client.v2.<resource> | v2 | v2 (unchanged — the same object the bare name resolves to) |
client.v1.<resource> | — | v1 |
- v1 is not removed or weakened. Every v1 method keeps its exact path,
parameters and return type at
client.v1.*, including the two documented200 { error: ... }quirks oncompliance.getFrameworkControls/integrations.getHealth. - The swap is deliberately loud, not silent. In TypeScript it surfaces
as a compile error at exactly the call sites that care: v2 drops those two
union return types, so code that branched on
'error' in resultstops compiling. In Python the client raises oneDeprecationWarningper client the first time a/v1/path is called, naming the sunset date. Both SDKs accept an opt-out (suppressDeprecationWarnings/suppress_deprecation_warnings) for the warning, andclient.v1.<resource>is a mechanical, type-checked migration back to the previous behaviour. - Nothing else differs between the two versions on a shared resource. The five resources that exist in both (posture, compliance, risks, integrations, gate) require the same key scopes in both; the six previously-unenforced scopes v2 wired up all belong to v2-ONLY resources (webhooks, reports, audit-logs, the sync trigger), which have no v1 counterpart to break.
Both SDKs are checked against the served API specification for both
namespaces independently. v2-only resources with no v1 equivalent (webhooks, reports,
audit-logs, collector/evidence pushes) are reachable both bare
(client.webhooks) and as client.v2.webhooks.
Authentication
-
Bearer token, prefix
cmdash_ek_:Authorization: Bearer cmdash_ek_.... -
Keys are SHA-256 hashed at rest; the full key is shown exactly once, at creation/rotation.
-
Keys carry an explicit, whitelisted set of permission scopes chosen at creation time. Every v1 route requires one specific scope — see the generated reference doc for the exact scope per route. Seven scopes in that whitelist had no v1 route that checked them (
compliance:write,integrations:write,reports:read,reports:generate,webhooks:read,webhooks:write,audit-logs:read) — corrected from an earlier draft of this document, which undercounted this as six. That was not a security hole (nothing is unlocked by holding an unused scope), but a key issued with one of those grants could exercise none of it. v2 resolved all seven — see "Per-scope resolution (v2)" below — six got a real enforcing route, andcompliance:writewas removed from the whitelist entirely. v1 itself is unchanged: none of its routes check any of these seven scopes today, and none ever will (enforcing one on a v1 route would itself be the breaking change this policy exists to prevent — see "What forces a v2" above). -
Keys expire: 90-day default, 365-day maximum, enforced at creation and rotation.
-
Creating/listing/revoking/rotating keys is a JWT-authenticated, Enterprise-gated dashboard operation (Owner or Admin role) — separate from the v1 data API's own auth.
-
The entitlement is re-checked on every request, not only when the key was minted. An earlier revision of this page recorded the opposite as an accepted audit finding ("a key minted while a tenant held that entitlement keeps working … even if the tenant's current plan changes afterward"). That is not the case: the calling tenant's effective entitlements are resolved on every
/v1and/v2request, and the request is refused whenPUBLIC_APIis gone. What a caller sees:Tenant's plan grants Result PUBLIC_APIevery method the key's scopes allow PUBLIC_API_READonly (Starter and up, V81 D2)GET/HEAD/OPTIONSonly; any other method is 403, even if the key carries a:writescope minted under a previous planneither 403 on every route — "This API key is valid, but your plan no longer includes Public API access." The 403 is deliberate: the key itself authenticated, so a 401 would send a customer off re-minting credentials that would fail identically.
The entitlement is the union of the plan's features and any paid add-ons, so a tenant whose
PUBLIC_APIcame from an add-on rather than the Enterprise bundle is not wrongly locked out.Propagation. The verdict is cached per tenant for 60 seconds so the check costs at most one resolve per tenant per minute on a path that serves up to 20,000 requests/hour for one Enterprise tenant. Every plan change that goes through the product — Stripe checkout, plan change, cancellation, an AWS Marketplace tier move, a staff tier change, or the staff add-on editor — evicts that entry as it writes, so a downgrade takes effect on the next request. The 60 seconds is the backstop for a row changed some other way (a direct database edit, say). A key that must stop working immediately should be revoked, which does not go through this cache at all: the key is checked on every request.
Rate limits
Per API key, fixed 1-hour window, resolved from the calling tenant's plan:
| Tier | Requests / hour |
|---|---|
| Starter | 500 |
| Essentials | 2,000 |
| Command | 10,000 |
| Enterprise | 20,000 |
| (tenant/tier unresolvable) | 1,000 (flat fallback) |
An operator-set API_RATE_LIMIT env var overrides the tier map entirely
(applies the same flat number to every key) — used for tests/staging, not
expected to be set in production. Every response from a rate-limited route
carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
(Unix seconds). A 429 carries the standard error envelope. A1009 P4: all of a
workspace's keys also share one window of 5 times the limit above, and
X-RateLimit-Reset is epoch seconds on every limiter (see
limits.md).
Fails OPEN on Redis outage or connection failure — checkLimit()
returns "allowed" with the full limit rather than blocking every Public
API call because the rate limiter's own backing store is unavailable. This
is a deliberate availability-over-strict-enforcement tradeoff (mirrors
AiBudgetService), not an oversight — worth stating plainly since "what
happens when the limiter itself is down" is exactly the kind of thing a
procurement/security review will ask.
That fail-open state is no longer permanent: the limiter probes Redis again after a
5-second cooldown and resumes metering as soon as it answers (A1009 P4;
limits.md).
NODE_ENV=test skips the real Redis dial entirely (a mock is injected via
_setRedisForTest) — this only affects the test suite; nothing about
this changes production behavior, and no production deploy should ever run
with NODE_ENV=test.
Pagination (verified per-endpoint — there is no single global convention)
GET /v1/risks— offset/limit:?limit=50&offset=0(defaults shown), response is{ total, risks: [...] }. This is the only paginated list endpoint in v1 today.- Every other list endpoint (
GET /v1/compliance/frameworks,GET /v1/integrations,GET /v1/compliance/frameworks/:slug/controls,GET /v1/risks/:id/controls) returns the full array, unpaginated — these are tenant-scoped collections that are small by construction (a handful of frameworks/integrations/controls-per-risk, not an unbounded log). Introducing pagination on one of these later would itself be a breaking change per the policy above (the response shape changes from a bare array to an envelope object) — noted here so it's a deliberate v2 decision if it ever comes up, not an accidental v1 patch.
Error shapes
Standard envelope for every error (auth failures, rate limits, validation, not-found, unhandled 500s):
{
"success": false,
"requestId": "...",
"timestamp": "2026-08-21T12:00:00.000Z",
"statusCode": 404,
"error": "Not Found",
"message": "Risk 123 not found"
}
Two known exceptions that do NOT follow this shape, found while auditing the real behavior (not previously documented):
GET /v1/compliance/frameworks/:slug/controls— an unknownslugreturns HTTP200with body{ "error": "Framework not found" }, not a404with the standard envelope.GET /v1/integrations/:id/health— an unknownidreturns HTTP200with body{ "error": "Integration not found" }, not a404. (The OpenAPI document for this route declares a404response that the route never actually produces.)
Every other :id/:slug lookup in v1 (GET /v1/risks/:id/loss-exposure,
PUT /v1/risks/:id, GET /v1/risks/:id/controls) returns a real 404 with
the standard envelope. The two endpoints above are inconsistent with the rest
of the surface and with what their own OpenAPI document promises. This is
documented as the actual, current v1 behavior — still true today,
unchanged — rather than silently "fixed" to match the rest, since
changing response semantics on a live v1 route is itself a breaking change
under the versioning policy above.
Fixed in v2. GET /v2/compliance/frameworks/:slug/controls and
GET /v2/integrations/:id/health return a real 404 (the standard envelope)
for the same unknown-slug/unknown-id case. v1 keeps its documented behavior.
Insufficient scope: 401 on v1, 403 on v2
An API key that authenticated but does not carry the scope a route requires
(API key lacks required permission: <scope>) is answered differently by the
two versions, on purpose:
| Version | Status | Why |
|---|---|---|
/v2/* | 403 Forbidden | The key is valid; it is not allowed to do this. A client that treats 401 as "re-authenticate" (Zapier's connection test did, and looped on reconnect) must not be told to re-authenticate. |
/v1/* | 401 Unauthorized (unchanged) | Changing the meaning of a status code on a frozen, deprecated surface is itself a breaking change under "What v1 guarantees" above. v1 keeps its 401 until it is sunset (2027-02-24). |
The message, the envelope and the scope named are identical in both. The other 403s on this surface (a plan without Public API access, a read-only plan attempting a write, a sandbox key attempting a write) were already 403 on both versions.
Query-string validation (A1009 P2)
On GET /v1|v2/risks, GET /v2/reports, GET /v2/webhooks/:id/logs,
POST /v2/webhooks/:id/rotate-secret and GET /v1|v2/posture/history, a query
value that is not the shape its parameter names is a 400 with the field
errors under details (limit=abc, status=FOO, minScore=x, a repeated
?status=A&status=B). An empty value (?status=) still means "not filtered".
limit is a positive whole number and is clamped to 500 on the risk list
(and to the endpoint's own ceiling elsewhere): an unbounded page was a defect,
not a feature, so this tightening applies to v1 as well. Values that used to
fail with a 500 (a NaN reaching the database) are the only inputs whose
outcome changed from "works" to "400"; status accepts exactly the
RiskStatus values OPEN, IN_PROGRESS, MITIGATED, ACCEPTED, CLOSED
(the OpenAPI enum listed a non-existent TRANSFERRED, which always failed).
Success envelope
Every non-error response is wrapped by the global ResponseInterceptor:
{ "success": true, "requestId": "...", "timestamp": "...", "data": { ... } }
The SDK (packages/sdk-js) unwraps this automatically — every client
method resolves to data, not the outer envelope.
meta is an OPTIONAL fifth key, present only on responses that read a
row-capped list — see "List truncation" below.
Asynchronous jobs (ROADMAP-V100 I7)
Exports, evidence packages and generated reports are jobs: you start one, then poll. Four things are true of every such surface, and the same four values are used everywhere:
status | meaning |
|---|---|
PENDING | accepted, not started |
PROCESSING | running |
COMPLETE | finished; output available |
FAILED | finished; no output |
Alongside status, every status read carries three derived fields so a
client never has to hard-code which strings mean "keep going":
{ "status": "PROCESSING", "ready": false, "terminal": false, "retryAfterSeconds": 5 }
ready— the output can be fetched. Only ever true forCOMPLETE.terminal— the status will not change again. Stop polling.retryAfterSeconds— advisory wait before the next poll;nullonce terminal.
A status read is always 200 while the job exists. 404 means no such
job. Nothing else is an error — an in-flight job is not a client mistake.
A download of an unfinished job answers 409 with
details.code = "JOB_NOT_READY", details.status (the live status) and
a Retry-After header. Retry it; do not treat it as fatal.
Changed in v9.5.0.
GET /v2/reports/{id}previously answered400 "Report is not ready for download (status=PROCESSING)"for the whole life of a running report, which made the documented generate-then-poll loop return a client error on every poll. It now returns200with the block above andcontent: nulluntilreadyis true. If your client treated that400as "not finished yet", switch to checkingready; if it treated it as fatal, it now works. The download routes that used to answer400(report / evidence-package) or404(the three data-export signed-URL routes) for an unfinished job now all answer409 JOB_NOT_READY.
List truncation (ROADMAP-V100 B7 + I11)
Some list endpoints are capped at 500 rows server-side. A capped response discloses itself twice, from one recorded fact — you may read either:
- header
X-List-Truncated: <surface>;cap=500, present ONLY when rows were actually dropped, and metain the body, present on every capped read:
{
"success": true, "data": [ /* … */ ],
"meta": {
"lists": [ { "surface": "tags", "cap": 500, "returned": 500, "hasMore": true, "totalCount": null } ],
"hasMore": true, "totalCount": null, "returned": 500
}
}
meta.hasMore: false is a positive statement that the rows you have are the
complete set — which is why the body signal exists at all: an absent header
cannot distinguish "complete" from "you never heard about it".
totalCount is exact or null, never an estimate:
- complete list → the exact count, for free (no extra query);
- truncated list →
null, unless you ask for it with?totalCount=true(or theX-List-Total-Count: trueheader), which costs oneCOUNT(*)over the same tenant-scoped filter the page used.
The flat hasMore/totalCount/returned fields appear only when the
request read exactly one capped list; meta.lists is always authoritative.