Skip to content

feat(core): answer store reads through their index, under the pinned schema's access - #391

Merged
nickruigrok merged 4 commits into
mainfrom
feat/store-query-rpc
Oct 11, 2026
Merged

nickruigrok merged 4 commits into
mainfrom
feat/store-query-rpc

Conversation

@nickruigrok

@nickruigrok nickruigrok commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Third of five stacked PRs for GRA-243. Security: needs human review before merge. Stacked on #386 and #388; this PR's own changes are the last three commits.

Threat model (written first; also in apps/core/src/store-queries.ts)

Who reads: App code, through core, which resolves the caller and hands the store a ReadScope. The descriptor is whatever App code built and is trusted for nothing.

Threat How it's closed
A caller reads records it may not see, through any terminal The read mode comes from the store's own record of the pinned schema (sdk_schemas), never from the request. none refuses. appMembers/appBuilders refuse callers core didn't find to have the role (data.access_denied). owner puts owner_id = principal in the one statement every terminal runs, so counts, aggregates, unique and results cover the caller's records only. The owner leads the index, so other owners' records aren't even scanned. Nothing is filtered after the fact.
A record ID discloses another table's or owner's record get looks a record up by table and ID under the same predicate and answers null in every case. expectedRevision is checked only on a visible record.
A descriptor smuggles SQL or names what it shouldn't Names resolve through the pinned manifest. Table IDs and index names come from the store's catalog and planner. Field names reach SQL only through the shared emitter. Values are bound.
A query scans without bound INDEXED BY the named index, so SQLite fails rather than scans. The range is checked against the index's fields in order. At most 10,000 entries and 16 MiB of documents per terminal, then data.scan_limit, never a partial answer. collect fails past 100 (data.result_limit); unique past one (data.not_unique).
A descriptor overruns SQLite Filter depth, nodes and lists, plus 64 bound values, are checked at the boundary (#386). The emitter's SQL is flat (#388).
A condition drops a record through SQL NULL Conditions are two-valued (#388), so NOT keeps null and missing.
A pinned consumer sees fields it doesn't know, or misses defaults Documents are projected to the pinned schema at every depth: objects keep only declared keys, with defaults; records, arrays and unions project what they hold (spec 32.7).
An aggregate overflows A non-finite result fails with data.aggregate_overflow.
A read creates state, or a missing index makes it scan A read refuses a store with no row (data.unknown_schema) without creating one. An index SQLite doesn't hold is data.index_required.
A caller claims a schema or a role Both come from core (ReadScope). An unknown schema hash is data.unknown_schema.

What this PR trusts, and who answers for it

  • ReadScope is taken as given. That covers the principal, roles, schema hash and consumer. Only core holds the store binding, and core must build the scope per invocation from its own records. This is a hard requirement on GRA-244.
  • An older schema hash keeps its own access mode. If a table goes from appMembers to owner in a newer schema, a read under the older hash still lets members read it. The security review probed this: v1 appMembers, v2 owner, and a read under v1's hash returns another owner's records. It is closed by two hard requirements: GRA-244 takes the hash from the consumer's own pinned manifest, and GRA-246 retires superseded hashes once nothing is pinned to them. No code change here.

What

  • StoreHost.read(storeId, scope, request), exposed as DataStore.read over RPC and OpenStore.read from core, with readScopeSchema in @grasp-os/shared/store-queries.
  • apps/core/src/store-queries.ts:
    • access resolution;
    • range-to-index checking;
    • planQuery, one statement with the filter as a computed column, ordered by the index columns past the equality prefix and limited to one entry past the scan bound;
    • a bounded match iterator;
    • every terminal except paginate (PR 4);
    • projection.
  • The store caches each pinned schema's manifest and index plan per object; neither changes for a hash.
  • New errors: data.unknown_schema, data.unknown_index, data.access_denied, data.scan_limit, data.result_limit, data.not_unique.
  • The store doesn't apply defaults on write; that's GRA-244's operation boundary.
  • Aggregates take declared numeric fields only, never managed ones (D3).

Boundary

GRA-244 builds ctx.db with databaseReader(schema, (request) => store.read(scope, request)) and resolves the scope's role flags and operationAccess narrowing. PR 5 adds the host-side read set and readConsistently.

Tests

apps/core/test/store-queries.test.ts, run in workerd through OpenStore and the DataStore DO, with the SDK reader building the descriptors:

  • Owner-only tables: every terminal (collect, count, aggregates, unique with another owner's match, filters, get) answers from the caller's records only; expectedRevision conflicts only on visible records.
  • Index plans: real EXPLAIN QUERY PLAN of the store's own statement (via planQuery) uses sdk_ix_… with the owner and range terms, and the owner default-order index, with no TEMP B-TREE.
  • Roles: member and builder modes admit and refuse by role; none refuses everyone, by query and by ID.
  • Names: unknown schema, table, another schema's table, unknown index or field, out-of-order ranges, a range without an index, and managed aggregate fields are all refused.
  • Injection: quotes in values match literally; injection in a field name and raw SQL keys are refused.
  • D3 through the store: lt ranges exclude null and missing, the index orders missing < null < value, NOT keeps missing, neq is value-only.
  • Bounds: collect > 100 and unique > 1 are refused, take(100) and take(0) work, and 10,001 entries trip data.scan_limit for count and for a filtered first while an early stop and an index range still answer; 140 × 120 KB documents trip the 16 MiB bound while an unfiltered count reads none.
  • Projection: records are projected to an older pinned schema (a newer field omitted) and a newer one (defaults filled in). Undeclared nested keys in objects, arrays of objects and records of objects are left out.
  • Since the security review:
    • another owner's 10,005 records cause neither scan_limit nor result_limit for Ada;
    • a get with another table's ID answers null;
    • exactly 10,000 entries are answered;
    • min/max over nothing are null and sum over nothing is 0;
    • 1e308 + 1e308 gives data.aggregate_overflow;
    • a sum on a declared string field is refused;
    • a read creates no store row;
    • a dropped index gives data.index_required;
    • plan tests now explain the host's own statement (StoreHost.explain);
    • a union is projected to the member the stored value validates against, not a smaller earlier one; the first member whose projection validates is used only when none does, and validators are cached per descriptor (tested: second member, first member, and a later key);
    • aggregates read JSON numbers only (numberExpression), never booleans, and a missing index is logged as store.index_missing.

Results: vp check is clean. vp test on store-queries, store-expressions, store-indexes, the data-store suites, the SDK database and schema suites and shared store-queries passes 200 tests. After the review fixes, the store and SDK suites pass 154 tests.

nickruigrok added a commit that referenced this pull request Oct 11, 2026
…past finite

From the security review of #391:
- Records are projected to the pinned schema at every depth: objects keep
  only their declared keys (with defaults), and records, arrays and
  unions project what they hold, so a later schema's nested keys never
  reach an older consumer.
- An aggregate that isn't finite fails with `data.aggregate_overflow`.
  Aggregates read their field with the shared expression, and counting
  with a filter no longer brings documents into JavaScript.
- A read creates nothing: a store no schema was handed answers
  `data.unknown_schema` without a row. An index SQLite doesn't hold is
  `data.index_required`, never a scan.
- The threat model names what is trusted and who answers for it: the
  scope is core's (GRA-244), and an older hash's access applies under it
  until superseded hashes are retired (GRA-246).
- Plan tests read the plan of the statement the store's host runs.
nickruigrok added a commit that referenced this pull request Oct 11, 2026
…past finite

From the security review of #391:
- Records are projected to the pinned schema at every depth: objects keep
  only their declared keys (with defaults), and records, arrays and
  unions project what they hold, so a later schema's nested keys never
  reach an older consumer.
- An aggregate that isn't finite fails with `data.aggregate_overflow`.
  Aggregates read their field with the shared expression, and counting
  with a filter no longer brings documents into JavaScript.
- A read creates nothing: a store no schema was handed answers
  `data.unknown_schema` without a row. An index SQLite doesn't hold is
  `data.index_required`, never a scan.
- The threat model names what is trusted and who answers for it: the
  scope is core's (GRA-244), and an older hash's access applies under it
  until superseded hashes are retired (GRA-246).
- Plan tests read the plan of the statement the store's host runs.
nickruigrok added a commit that referenced this pull request Oct 11, 2026
…past finite

From the security review of #391:
- Records are projected to the pinned schema at every depth: objects keep
  only their declared keys (with defaults), and records, arrays and
  unions project what they hold, so a later schema's nested keys never
  reach an older consumer.
- An aggregate that isn't finite fails with `data.aggregate_overflow`.
  Aggregates read their field with the shared expression, and counting
  with a filter no longer brings documents into JavaScript.
- A read creates nothing: a store no schema was handed answers
  `data.unknown_schema` without a row. An index SQLite doesn't hold is
  `data.index_required`, never a scan.
- The threat model names what is trusted and who answers for it: the
  scope is core's (GRA-244), and an older hash's access applies under it
  until superseded hashes are retired (GRA-246).
- Plan tests read the plan of the statement the store's host runs.
nickruigrok added a commit that referenced this pull request Oct 11, 2026
From the security review of #391: a union was projected to the first
member whose projection validated, so a smaller, earlier object member
won and dropped fields of the one the value was stored as. It now takes
the first member the stored value itself validates against, as the
validator picks it, and only failing that the first whose projection
validates; validators are made once per descriptor. Aggregates read
JSON numbers only, never a boolean, and a missing index is logged when
it is reported as `data.index_required`.
…schema's access

A business store now answers `ctx.db` reads (`StoreHost.read`, over RPC
as `DataStore.read`, from core as `OpenStore.read`): a record by ID, or a
query's descriptor, for a `ReadScope` core resolves (the pinned schema,
the person, their App roles and the consuming App). The descriptor is
checked whole; tables, indexes and fields resolve through the store's own
record of the pinned schema. Each terminal runs one statement through the
index it names (`INDEXED BY`), with the owner predicate of an owner-only
table in its WHERE, so counts, aggregates, `unique` and results are the
caller's records only; `appMembers` and `appBuilders` tables refuse
callers without the role, and `none` refuses everyone. A terminal scans
at most 10,000 entries and 16 MiB of documents (`data.scan_limit`),
`collect` fails past 100 records and `unique` past one, and aggregates
take declared numeric fields only. Records are projected to the pinned
schema's fields, with defaults where a stored record lacks a defaulted
field. Pagination comes with cursors.
…past finite

From the security review of #391:
- Records are projected to the pinned schema at every depth: objects keep
  only their declared keys (with defaults), and records, arrays and
  unions project what they hold, so a later schema's nested keys never
  reach an older consumer.
- An aggregate that isn't finite fails with `data.aggregate_overflow`.
  Aggregates read their field with the shared expression, and counting
  with a filter no longer brings documents into JavaScript.
- A read creates nothing: a store no schema was handed answers
  `data.unknown_schema` without a row. An index SQLite doesn't hold is
  `data.index_required`, never a scan.
- The threat model names what is trusted and who answers for it: the
  scope is core's (GRA-244), and an older hash's access applies under it
  until superseded hashes are retired (GRA-246).
- Plan tests read the plan of the statement the store's host runs.
From the security review of #391: a union was projected to the first
member whose projection validated, so a smaller, earlier object member
won and dropped fields of the one the value was stored as. It now takes
the first member the stored value itself validates against, as the
validator picks it, and only failing that the first whose projection
validates; validators are made once per descriptor. Aggregates read
JSON numbers only, never a boolean, and a missing index is logged when
it is reported as `data.index_required`.
@nickruigrok
nickruigrok marked this pull request as ready for review October 11, 2026 02:55
@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, subject to the human security review requested by the PR.

Summary

The PR adds store reads through the pinned schema’s access rules and indexes.

  • Store reads follow the caller’s pinned schema and access.
  • Queries stop when their scan or result bounds are reached.
  • Returned records match the caller’s pinned schema.

No new actionable issue was found in the changes since the previous review. This review checked code and tests; it did not run the test suites.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["Core supplies ReadScope"] --> B["StoreHost.read checks the request"]
  B --> C["Load the pinned schema"]
  C --> D["Check table access"]
  D --> E{"Read kind"}
  E -->|get| F["Find the visible record by table and ID"]
  E -->|query| G["Read through the selected index"]
  G --> H["Enforce scan and result limits"]
  F --> I["Project fields to the pinned schema"]
  H --> I
  I --> J["Return the answer"]
Loading

Reviews (2) · Last reviewed commit: "fix(core): choose a union's member by it..." · Reviewed by Greptile

Comment thread apps/core/src/store-queries.ts Outdated
From the review of #391: a union's members were pre-filtered by a
hand-written shape test that took money, files, schedules and nested
unions for scalars, so a stored money value projected to `{}`, and a
value no member fit came back as stored, undeclared keys and all. The
member is now the first whose cached validator accepts the stored value
(every kind, nested unions included), then the first whose projection
validates; otherwise the read fails with `data.incompatible_record`.
Money, files and schedules keep only the keys their kinds hold, and an
object or list where the pinned type holds neither fails the same way.
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok
nickruigrok merged commit 80676b9 into main Oct 11, 2026
9 checks passed
@nickruigrok
nickruigrok deleted the feat/store-query-rpc branch October 11, 2026 03:15
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