Repository navigation
feat(core): answer store reads through their index, under the pinned schema's access - #391
Merged
Merged
Conversation
nickruigrok
force-pushed
the
feat/store-query-rpc
branch
from
October 11, 2026 02:24
37c6fb8 to
ce65e92
Compare
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
force-pushed
the
feat/store-query-rpc
branch
from
October 11, 2026 02:35
14a459c to
e0e6806
Compare
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
force-pushed
the
feat/store-query-rpc
branch
from
October 11, 2026 02:42
e0e6806 to
2c070a4
Compare
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
force-pushed
the
feat/store-query-rpc
branch
from
October 11, 2026 02:55
c12f4c9 to
d90682e
Compare
nickruigrok
marked this pull request as ready for review
October 11, 2026 02:55
Contributor
Author
|
@greptileai review |
|
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.
Contributor
Author
|
@greptileai review |
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.
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.sdk_schemas), never from the request.nonerefuses.appMembers/appBuildersrefuse callers core didn't find to have the role (data.access_denied).ownerputsowner_id = principalin the one statement every terminal runs, so counts, aggregates,uniqueand 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.getlooks a record up by table and ID under the same predicate and answers null in every case.expectedRevisionis checked only on a visible record.INDEXED BYthe 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, thendata.scan_limit, never a partial answer.collectfails past 100 (data.result_limit);uniquepast one (data.not_unique).NOTkeeps null and missing.data.aggregate_overflow.data.unknown_schema) without creating one. An index SQLite doesn't hold isdata.index_required.ReadScope). An unknown schema hash isdata.unknown_schema.What this PR trusts, and who answers for it
ReadScopeis 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.appMemberstoownerin a newer schema, a read under the older hash still lets members read it. The security review probed this: v1appMembers, v2owner, 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 asDataStore.readover RPC andOpenStore.readfrom core, withreadScopeSchemain@grasp-os/shared/store-queries.apps/core/src/store-queries.ts: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;paginate(PR 4);data.unknown_schema,data.unknown_index,data.access_denied,data.scan_limit,data.result_limit,data.not_unique.Boundary
GRA-244 builds
ctx.dbwithdatabaseReader(schema, (request) => store.read(scope, request))and resolves the scope's role flags andoperationAccessnarrowing. PR 5 adds the host-side read set andreadConsistently.Tests
apps/core/test/store-queries.test.ts, run in workerd throughOpenStoreand the DataStore DO, with the SDK reader building the descriptors:uniquewith another owner's match, filters,get) answers from the caller's records only;expectedRevisionconflicts only on visible records.EXPLAIN QUERY PLANof the store's own statement (viaplanQuery) usessdk_ix_…with the owner and range terms, and the owner default-order index, with no TEMP B-TREE.nonerefuses everyone, by query and by ID.ltranges exclude null and missing, the index orders missing < null < value,NOTkeeps missing,neqis value-only.collect> 100 andunique> 1 are refused,take(100)andtake(0)work, and 10,001 entries tripdata.scan_limitfor count and for a filteredfirstwhile an early stop and an index range still answer; 140 × 120 KB documents trip the 16 MiB bound while an unfiltered count reads none.scan_limitnorresult_limitfor Ada;getwith another table's ID answers null;data.aggregate_overflow;data.index_required;StoreHost.explain);numberExpression), never booleans, and a missing index is logged asstore.index_missing.Results:
vp checkis clean.vp teston 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.