Skip to content

feat(core): page store queries under signed, version-bound cursors - #393

Merged
nickruigrok merged 5 commits into
mainfrom
feat/store-cursors
Oct 11, 2026
Merged

nickruigrok merged 5 commits into
mainfrom
feat/store-cursors

Conversation

@nickruigrok

@nickruigrok nickruigrok commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • The anchor (the last record's ID and order values) is sealed with AES-GCM.
  • Both tags are HMAC-SHA256 over the version, the sealed anchor and a digest of the binding.
  • The tag and seal keys are derived with HKDF (separate labels) from one random key kept in the store's own SQLite (sdk_store.cursor_key, migration 0004). The key is minted by the store's first page and cleared at purge.
Threat How it's closed
Forging or editing a cursor Both tags are checked in constant time before anything is acted on, the version included. The anchor is decrypted only after. Otherwise: data.invalid_cursor. Other stores' keys differ. Every part must be canonical base64url, so no malleable spellings.
Reusing a cursor for another query or caller The tags cover a digest of store, App, person, roles, table, index, range, filter and order, recomputed from the request and the core-resolved scope and never read from the cursor. A mismatch is data.invalid_cursor.
A cursor outliving its generation (spec 9.2) The stable tag covers the binding without its generation (App version, schema hash, emitter version). If the stable tag verifies but the full tag doesn't, or the format version differs, the answer is 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.
Leaking data through a cursor Cursors are opaque: record IDs and values are encrypted (decision recorded on GRA-243).
Unbounded size At most 4 KiB; a longer cursor is refused at the boundary as 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.
Deep pages scanning without bound Each page reads through index seeks, one index range each, deepest first. After a left-out anchor value the seek is >= 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, pagePlans and seeksAfter in store-queries.ts. The planner gains anchors and seek.
  • StoreHost opens the cursor, reads the page in one transaction and seals the next cursor. It answers spec 9.2's envelope.
  • StoreHost.explainPage plans every seek, for tests only.
  • New errors: data.invalid_cursor and data.cursor_expired.

Tests

apps/core/test/store-cursors.test.ts, in workerd through OpenStore and the DO:

  • Pages add up:
    • to collect, in both orders, with the last page uncursored;
    • along an index through missing, null and values, with page sizes 1, 2 and 3 in both directions;
    • across 10,050 records in 101 pages with no scan_limit;
    • with a record inserted between pages appearing once.
  • Opacity: the cursor text, decoded part by part, contains neither record IDs nor values.
  • Refusal and expiry:
    • the same cursor answers the right page;
    • a forged cursor, an edited seal, both tags edited, a non-canonical version spelling and a later format version all give invalid_cursor;
    • another order, index, range, table, filter, person, role, App or store gives invalid_cursor;
    • another App version or schema hash gives cursor_expired, and so does a cursor with only its full tag edited.
  • ID-only fallback: long values give an ID-only cursor that pages correctly and expires when its record is deleted.
  • Owner-only tables: pages hold only the caller's records with another owner's interleaved, in both directions; descending ID-only pages work through null and left-out values.
  • Seeks: EXPLAIN of every seek statement (asc after missing, null and value; desc after value and null; the default order) shows SEARCH … USING INDEX with 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 check is clean. vp test on store-cursors (12 tests), store-queries and data-store-lifecycle passes 52 tests.

@nickruigrok
nickruigrok force-pushed the feat/store-cursors branch 3 times, most recently from 5e05afa to 0373533 Compare October 11, 2026 03:03
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.
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
nickruigrok marked this pull request as ready for review October 11, 2026 03:23
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

5 similar comments
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Critical impact] The reviewed changes appear safe to merge.

Summary

The PR adds paged store queries with encrypted, signed cursors tied to the caller, query, and version.

  • Store queries return results one page at a time.
  • Page cursors hide their anchors and stay tied to one query.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Read cursor] --> B[Check both tags]
  B --> C{Stable tag verifies?}
  C -->|No| D[data.invalid_cursor]
  C -->|Yes| E{Full tag and format match?}
  E -->|No| F[data.cursor_expired]
  E -->|Yes| G[Decrypt and check anchor]
  G --> H[Read next page]
Loading

Reviews (2) · Last reviewed commit: "fix(core): verify both of a cursor's tag..." · Reviewed by Greptile

Comment thread apps/core/src/store-cursors.ts
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`.
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok
nickruigrok merged commit d1ffc08 into main Oct 11, 2026
9 checks passed
@nickruigrok
nickruigrok deleted the feat/store-cursors branch October 11, 2026 11:17
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant