Repository navigation
feat(core): page store queries under signed, version-bound cursors - #393
Merged
Merged
Conversation
nickruigrok
force-pushed
the
feat/store-cursors
branch
3 times, most recently
from
October 11, 2026 03:03
5e05afa to
0373533
Compare
nickruigrok
added a commit
that referenced
this pull request
Oct 11, 2026
…ast left-out values From the security review of #393: - A cursor's anchor is sealed with AES-GCM; two HMAC tags cover its version, sealed anchor and binding, one with the binding's generation (the App's version, the schema, the expressions' version) and one without. Both keys derive from the store's key with HKDF. Tags are checked before anything else is acted on: a cursor whose stable tag verifies under another generation is `data.cursor_expired`, anything else that doesn't verify `data.invalid_cursor`. Every part must be spelled canonically. - After an anchor whose field was left out, the seek is an index range (`>=` the null sentinel), never a walk over left-out entries; managed fields, never NULL, get no left-out step. Every seek's plan is tested. - The cursor key is cleared when the store is purged. - Tests: owner-only pages with another owner's records interleaved, the ID-only fallback descending through null and left-out values, the scan bound across seeks, more binding cases, and anchors changed or deleted between pages.
`paginate` now answers spec 9.2's page envelope: up to `pageSize` records, `hasMore`, and a `nextCursor` while more follow. A cursor is the store's own: an HMAC-SHA256 under a random key only its store holds, over its format version, the last record's anchor and the digest of everything it is bound to (store, App and version, schema, person and roles, table, index, range, filter and order), recomputed from each request; anything that doesn't verify is `data.invalid_cursor`, another format version `data.cursor_expired`. A cursor is at most 4 KiB: when the last record's order values wouldn't fit, it carries only the record's ID, and the next page looks the record up under the same access (`data.cursor_expired` once it is gone). A page after an anchor reads through a sequence of index seeks, one index range each, deepest first, through left-out and null values in both directions, so a page costs what it reads however deep it is, and the scan bounds apply across a page's seeks.
…ast left-out values From the security review of #393: - A cursor's anchor is sealed with AES-GCM; two HMAC tags cover its version, sealed anchor and binding, one with the binding's generation (the App's version, the schema, the expressions' version) and one without. Both keys derive from the store's key with HKDF. Tags are checked before anything else is acted on: a cursor whose stable tag verifies under another generation is `data.cursor_expired`, anything else that doesn't verify `data.invalid_cursor`. Every part must be spelled canonically. - After an anchor whose field was left out, the seek is an index range (`>=` the null sentinel), never a walk over left-out entries; managed fields, never NULL, get no left-out step. Every seek's plan is tested. - The cursor key is cleared when the store is purged. - Tests: owner-only pages with another owner's records interleaved, the ID-only fallback descending through null and left-out values, the scan bound across seeks, more binding cases, and anchors changed or deleted between pages.
nickruigrok
force-pushed
the
feat/store-cursors
branch
from
October 11, 2026 03:16
68357f7 to
67bc8e1
Compare
From the security re-check of #393: cursors of 4,092 to 4,096 characters were issued, then refused on the next page, because the pattern capped the sealed anchor at 4,000 characters, not at the cursor bound less its 90 fixed characters; an indexed value of the right length could block everyone's paging past its record. The pattern's bound now follows from the cursor's, and an issued cursor must match it, or the cursor seals the record's ID alone. Tested at exactly the longest cursor and one character past it. The seek plan test now requires the seek past a left-out value to be a constraint of the index search.
nickruigrok
marked this pull request as ready for review
October 11, 2026 03:23
Contributor
Author
|
@greptileai review |
5 similar comments
Contributor
Author
|
@greptileai review |
Contributor
Author
|
@greptileai review |
Contributor
Author
|
@greptileai review |
Contributor
Author
|
@greptileai review |
Contributor
Author
|
@greptileai review |
|
From the review of #393: the stable tag was checked only when the full tag failed, so a cursor whose stable tag alone was edited, or spelled another way, still paged, unlike what the threat model says. Both tags are now always checked, each in canonical base64url: both must verify for a cursor to be read; a stable tag that verifies beside a full one that doesn't is `data.cursor_expired`; anything else is `data.invalid_cursor`.
Contributor
Author
|
@greptileai review |
nickruigrok
added a commit
that referenced
this pull request
Oct 11, 2026
…up to three times (#394) Fifth and last stacked PR for GRA-243. **Stacked on #391 and #393**; this PR's own change is the last commit. ## What Spec 6.3: a multi-read query validates its dependency generations when it completes, reruns up to three times, then returns the retryable `query.concurrent_change`. It is never answered from an inconsistent read set. - **Table generations** (`sdk_tables.generation`, migration `0005`). Every commit that changes a table's records moves its generation on once. A commit that changes nothing, or changes other tables, doesn't. - **Reads answer their dependencies.** `StoreHost.read` returns `{ value, generations }`, the table's generation read in the same transaction as the answer. `StoreHost.generations` reports the current generations by name. - **`StoreReader`** (`apps/core/src/store-reader.ts`) is the host-side per-invocation reader from decision D1-A. It records the generation each read saw (two reads of one table at different generations make the set stale), and it records `get` revision assertions on found records as guards for GRA-244's commit. - **`readConsistently(store, scope, operation)`** runs the operation with a fresh reader and answers only once `isCurrent()` holds. It allows 1 run plus 3 reruns (`queryMaxReruns`), then throws `query.concurrent_change` with `details: { retryable: true }`. - **Invalidation:** a read that matched nothing still depends on its table, so an insert invalidates an empty query. - **RPC:** a read's result crosses RPC as JSON text. RPC types can't carry its documents nested beside the generations; `openStore` validates the shape. ## Boundary GRA-244 wraps query handlers in `readConsistently`, builds `ctx.db` with `databaseReader(schema, (request) => reader.read(request))` and hands `reader.guards()` to its commit. Live-query delivery of invalidations is a later slice. ## Tests `apps/core/test/store-reader.test.ts`, in workerd through `OpenStore` and the DO. Commits land between reads through the store, exactly as another caller's would. - One run when nothing changes. - A commit between two reads gives a second run, answered from a consistent state, never the first run's. - Persistent contention gives `query.concurrent_change`, retryable, after 4 runs. - An empty query goes stale after an insert. - Other-table and no-op commits keep reads current; a real change doesn't. - Guards are recorded only for found records that asserted a revision. Results: `vp check` is clean. `vp test` on store-reader, store-cursors, store-queries, store-indexes, store-expressions, the data-store suites, SDK database and shared store-queries passes 177 tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fourth of five stacked PRs for GRA-243. Security: needs human review before merge. Stacked on #391; this PR's own changes are the last two commits.
Threat model (written first; also in
apps/core/src/store-cursors.ts)A cursor is
<version>.<sealed anchor>.<tag>.<stable tag>, every part canonical base64url.sdk_store.cursor_key, migration0004). The key is minted by the store's first page and cleared at purge.data.invalid_cursor. Other stores' keys differ. Every part must be canonical base64url, so no malleable spellings.data.invalid_cursor.data.cursor_expired. That is only told apart once a tag verified, so it's no oracle for strangers. An ID-only cursor whose record is gone also expires.data.invalid_query. When the values don't fit, only the ID is sealed, and the next page looks the record up under the same access.>= sentinel(a range, never a walk over left-out entries). Managed NOT NULL fields get no left-out step. Seek terms are top-level conjuncts with the range's, and the 10,000-entry and 16 MiB bounds apply across all of a page's seeks.What
readPage,pagePlansandseeksAfterinstore-queries.ts. The planner gainsanchorsandseek.StoreHostopens the cursor, reads the page in one transaction and seals the next cursor. It answers spec 9.2's envelope.StoreHost.explainPageplans every seek, for tests only.data.invalid_cursoranddata.cursor_expired.Tests
apps/core/test/store-cursors.test.ts, in workerd throughOpenStoreand the DO:collect, in both orders, with the last page uncursored;scan_limit;invalid_cursor;invalid_cursor;cursor_expired, and so does a cursor with only its full tag edited.SEARCH … USING INDEXwith no SCAN or TEMP B-TREE. The scan bound applies across two seeks. Paging carries on from a full anchor whose record moved or whose next record was deleted.Results:
vp checkis clean.vp teston store-cursors (12 tests), store-queries and data-store-lifecycle passes 52 tests.