Repository navigation
Search select() returns stored nested JSON instead of running the outer table's resolvers on it - #3456
Conversation
Release cherry-pick
|
There was a problem hiding this comment.
Code Review
This pull request aligns search select projections with single-record projections across the Resource and Table modules. It introduces robust handling for prototype-chain properties (such as __proto__), prevents outer table resolvers from executing on nested plain JSON data, adds support for wildcard (*) selections, and ensures asynchronous array projections are properly settled. Comprehensive unit and integration tests have been added to validate these projection behaviors. There are no review comments, so I have no feedback to provide.
d8b3842 to
bc603f8
Compare
A plain column now projects only data directly; a function value takes the general path, so the plain branch can never diverge from how selectAttribute treats functions (open PR #3456 makes them absent). A test pins that both paths project every value kind identically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-rocks-index-scan-per-row-cost
Deferring function values to selectAttribute read a function-valued getter twice. The plain branch goes back to one read. The test that projects each value kind through both paths stays: it fails as soon as selectAttribute treats a value differently from a plain read, which is how a merge with #3456 (functions project as absent) surfaces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-rocks-index-scan-per-row-cost
…select (#3605) * Cut per-row work on RocksDB secondary-index range selects - RocksIndexStore.getRange always iterates keys only: index values are always null, and the entry is rebuilt from the composite key in place, so a scan no longer decodes a stored null or allocates a second entry. - transformEntryForSelect builds its load options (one sync, one async) and load callback once per query; loadLocalRecord's per-call closures become scope functions; plain columns are assigned directly in the select loop instead of through a callback per column. - PrimaryRocksDatabase.getEntry passes rocksdb-js only transaction plus expectedVersion/populateVersion instead of spreading the caller's options (HNSW passes a whole DatabaseTransaction). Refs #3251 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-rocks-index-scan-per-row-cost * Address pre-push review: design note scope, identity callback name, comments - DESIGN.md: the option literal applies to caching reads only; uncachedRead and cache-disabled reads pass the caller's object through, and the literals drop skipDecode. - Rename the module-level identity callback so it no longer shares a name with the resolvers' returnEntry parameter. - RocksIndexStore sets values on the copy translateIndexBounds already makes instead of spreading it again. - Drop comments that narrate the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-rocks-index-scan-per-row-cost * Keep resources/DESIGN.md under its line budget once merged with main main is already one line over the 1000-line budget, and this branch's new note adds four. The select-projection convention is pinned by nestedSelectMissingValues.test.js and relationship-and-status-contract.test.ts, so it shrinks to one line naming its contracts and those tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-rocks-index-scan-per-row-cost * Leave function-valued columns to selectAttribute in the select fast path A plain column now projects only data directly; a function value takes the general path, so the plain branch can never diverge from how selectAttribute treats functions (open PR #3456 makes them absent). A test pins that both paths project every value kind identically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-rocks-index-scan-per-row-cost * Read each plain column once; let the equivalence test guard #3456 Deferring function values to selectAttribute read a function-valued getter twice. The plain branch goes back to one read. The test that projects each value kind through both paths stays: it fails as soon as selectAttribute treats a value differently from a plain read, which is how a merge with #3456 (functions project as absent) surfaces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-rocks-index-scan-per-row-cost --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
d43f4ac to
6efa14b
Compare
…solvers Search select() (Table.transformEntryForSelect) now matches single-record projection (Resource.transformForSelect): only table records and @relationship targets go through a table's propertyResolvers. A plain JSON sub-object, or a directReturn (@computed) result, is projected resolver-free, so an outer resolver never runs against nested data that shares its name. - propertyResolvers has a null prototype, so no lookup finds an Object.prototype member; transform caches are keyed by selector descriptor. - `*` alone passes through; mixed with names it copies data keys (a tracked instance's from toJSON()); a selected __proto__ is read only as an own property; function values project as absent. - Async positional/array projections settle; forceNulls applies after a value settles; a resolved value's pending promise is returned to the row. Rebased onto main as one commit, reconciling with #3221 and #3605: - #3221's sub-select rules (`{name}` without a list and `*`-only are identity; sort/offset/limit on arrays) live in one nestedSelectTransform shared by both projectors. Its plain-JSON path, which projected through the outer table's transformEntryForSelect and cached by name, now uses the resolver-free transform keyed by descriptor. - readPropertyFromObject (#3221's no-closure reader, also used by nested sort keys) carries this PR's own-__proto__ and function-absent rules. - #3605's plain-column fast path treats a function value as absent and leaves a __proto__ select to the general path, matching selectAttribute. - A nested `{name}` selector without a list is now identity on both paths (#3221's contract), replacing this PR's earlier 400. Fixes #3168 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3456-rebase-after-3221
…ey claims Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3456-rebase-after-3221
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-3456-rebase-after-3221
6efa14b to
e1d3f33
Compare
v5.3 has neither #3221 (nested sort/offset/limit and `*` sub-selects) nor #3615 (relationship selections), so the conflicted regions are rebuilt from v5.3's code with only #3456's own delta applied: - search plain JSON and @computed sub-selects project through the resolver-free nestedSelectTransform instead of the outer table's resolvers (previously TableResource.transformEntryForSelect) - table propertyResolvers get a null prototype - selector-keyed transform caches, `__proto__` read only as own data, function values project as absent, async arrays and forceNulls settle - lone/mixed `*` handling in transformForSelect, so search `meta{*}` keeps returning the stored value after moving off transformEntryForSelect Dropped the nested sort/limit test, which needs #3221. DESIGN.md keeps v5.3's hard-wrapped conventions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBAAS5hcrcgN8zhS3jE7 Dispatch-Task: cherry-resolve-kriszyp_harper_3456-051c9c57
…rd state once per object An async @computed or relationship value whose sub-select projection was itself async pushed its continuation after Promise.all had snapshotted the row's promises, so the row resolved without that attribute. handleResolvedValue now returns the inner promise, as #3456 does on main. selectFromObject resolves getRecord()/getChanges() once per object again, as v5.3 did before the port, instead of once per selected property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBAAS5hcrcgN8zhS3jE7 Dispatch-Task: cherry-resolve-kriszyp_harper_3456-051c9c57
Plain JSON and directReturn sub-objects are now projected resolver-free (nestedSelectTransform(..., undefined)), so a sub-selection on a plain JSON attribute never invokes the containing table's @relationship resolvers. Remove authorizeJsonSelection and checkSelectPaths recursion into non-relationship sub-selections while keeping containing-attribute and related-table permission checks. Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd Co-Authored-By: Gemini <noreply@google.com>
…GraphQL and NoSQL operations (#3316) * Apply related-table role permissions to every @relationship read Selecting a relationship, or filtering or sorting by a path through one, is now authorized against the role's permissions on the related table on every read API, as REST select already was: - Table._readDenial walks an attribute path through relationships and returns the first table or attribute the role cannot read. - allowRead (REST, GraphQL) refuses conditions and sort paths through an unreadable related table or attribute. - verifyPerms (operations API) refuses get_attributes, conditions and sort that reach one, and drops such relationships from the non-super `*` rewrite. It now authorizes the `select` alias the search handlers read in place of get_attributes. - ResourceBridge narrows selected related records to the related table's readable attributes, through the same per-relationship step allowRead uses (Table._authorizeRelatedSelection). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Cover relationship permission checks in unit tests and DESIGN.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize the query that executes, including nested scope - REST QUERY admission sees the body's conditions and sort, which are the ones an instance resource executes. - A related selection's own sort is checked against the related table, and its checkPermission comes from the request being authorized, never from the selection. - Operations API search requests are normalized before authorization to what the handler executes: a condition's attribute over search_attribute, and no checkPermission inside selections. Nested sorts are checked too. - A live subscription with caller conditions or sort is rechecked on its own instead of sharing the per-principal verdict, since allowRead now depends on them. - _readDenial walks the path by index rather than copying suffixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Describe effective-query authorization in the relationship design note Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize JSON sub-selections and single-object conditions; keep the scalar subscription fast path - allowRead reads conditions through searchConditions, the normalizer Table.search executes, so a single condition object is authorized too. - A plain JSON sub-selection is projected with its table's resolvers, so a relationship named inside it is authorized in that table's scope, on REST, the operations API, and the bridge's narrowing. - A live subscription loses the shared verdict only for a caller select or a condition or sort through a relationship. - Operations API checks run only for search operations on tables with a relationship; the denial map is allocated on first denial and the bridge maps the selection only when it can reach a relationship. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize array QUERY bodies and scalar sub-selections; address review - QUERY admission takes an array body as the conditions it is. - A scalar `select` on a plain JSON or relationship selection is authorized like the array form, on REST and the operations API. - A one-shot conditions iterator is materialized once at admission, so search still sees the conditions. - A related selection that carries a sort but no sub-select keeps the sort. - Tables expose hasRelationships, so the operations API and the bridge skip relationship checks without scanning attributes. - Unit tests go through the exported verifyPerms instead of rewire; the design note fits the DESIGN.md budget; comments trimmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Keep a QUERY body's one-shot conditions iterator usable for execution Admission materializes the body's iterator onto the body itself, so the search that runs the body still sees its conditions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Leave full-text index names to search's own rejection on relationship paths A full-text index name is query-only and search rejects it through a relationship with a 400; checking it against attribute_permissions first turned that into a 403, as allowRead's select already avoids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize iterable QUERY bodies and skip the relationship walk for empty targets - A QUERY body that is any iterable (a Set from CBOR tag 258, for one) is materialized and authorized as the condition list it executes as. - allowRead skips the relationship walk when the target carries no conditions, sort or query entries, and its helpers live at factory scope instead of being recreated per call. - Comments trimmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Walk relationship paths without a per-request closure allowRead and _referencesRelationship pass a flag instead of a test function, so a read with no conditions or sort allocates nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Keep a scalar JSON sub-selection scalar when its relationship is narrowed Projection accepts a single selection object as the scalar form, like a string, so narrowing `{name: 'payload', select: 'dept'}` for an attribute-restricted role returns the narrowed Dept record in place rather than wrapping it in a `dept` property. A denied scalar sub-selection drops the property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Prove subscription revocation through a relationship; tighten controls - The relationship-filtered subscription test now revokes the related table and checks that subscription ends while a scalar-filtered one stays. - Array and CBOR set QUERY bodies have full-access controls that return the matching row; REST select omission for an Emp-only role is asserted exactly. - Operations API normalization walkers move to module scope; comments trimmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize REST property sets inside JSON selections; accept a selection object in single-record projection - A REST property set (`payload{dept}`) parses to a named array, which projection uses as its own sub-selection, so the JSON-scope check treats it the same way as `{name, select}`. - transformForSelect accepts a single selection object as the scalar form, matching transformEntryForSelect, so the narrowed scalar form works on the single-record path too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Return a selection object's value when it has no sub-selection - A selection object without `select` projects the property's value as is, on both projectors, instead of re-applying itself to that value. - allowRead does not read a relationship selection's property set (a named array) as conditions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Let a relationship's join key stand in for its name in attribute permissions A condition or sort through a relationship reveals no more than the join key on this side (`from`, or the primary key), so a role that can read that key may traverse the relationship even when its attribute permissions do not list the relationship by name. The full-text suite pins this: a role reading ProductOrder `id` and `productId` filters by `product.title`. Without either, the step is refused. Operations API condition roots that are relationships go through the same rule rather than the plain root-attribute check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Limit the join-key rule to forward relationships; keep property sets collapsing - Only a forward relationship (`from`, no `to`) passes on its readable foreign key. An inverse relationship's key lives in the related table, so the root's primary key says nothing about it; it needs its name. - A REST property set is a named array, whose inherited Array.prototype.sort made the sort-retention check keep it and let a second relationship hop resolve unchecked. Only an own sort object now keeps a selection. - A one-shot iterator passed as the search target is materialized once at admission. - The operations API builds its relationship check only for searches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize the entries of a sorted REST property set on a relationship A property set kept for its own sort is projected entry by entry, so its entries become the related allowRead's sub-selection instead of passing through unchecked. Unsorted property sets keep collapsing to the relationship name, as before. The operations API builds its condition check only when the request has conditions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Fold the relationship design note into one paragraph for the DESIGN.md budget main has grown resources/DESIGN.md since this branch began, so the merge ref ran past the 1000-line budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Separate the relationship design note from the next section's rule Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Check operations API relationship paths without per-request closures Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize a search_by_value attribute that goes through a relationship search_by_value runs one condition from its top-level search_attribute (or attribute), which can be a relationship path; it is now checked like a condition path, and its root attribute like a condition's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A Dispatch-Task: harper-relationship-related-table-permissions * Authorize @enumerable relationships on reads without a select A read without a select, or with '*', serializes @enumerable relationships. When the role cannot read every table they reach in full, allowRead now writes the select itself: '*' for the record's own attributes, plus each enumerable relationship narrowed by the related table's permissions, or left out when that table is unreadable. A cyclic edge back to a table being projected stops at the related record's key. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Check operations API relationship paths without allocating for scalar attributes getRecordAttributes takes the table class instead of a per-request predicate, and checkRelationshipReads builds a path array only for a relationship-rooted or nested attribute. The design note records the @enumerable default-read rule. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Authorize a relationship read through a property path `/Table/id.relationship` returns the related record, so allowRead authorizes it as that relationship's selection in scalar form: refused when the related table is unreadable, narrowed when it is restricted. The enumerable-closure walk keeps a module-level path instead of allocating a visited set. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Visit each table once in the @enumerable closure walk, and keep a property path's own select A walk mark per table replaces path tracking, so a table reached by two routes is checked once. `/Table/id.relationship?select(...)` authorizes the caller's select as the relationship's sub-selection instead of replacing it. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Reuse a proven-unreadable closure across one read admission Writing a restricted default projection calls each related table's allowRead, which re-walked the remaining @enumerable chain: quadratic in chain length. A table whose closure fails the check is now marked for the current admission, so nested checks return at once. Only failures are memoized; a cyclic success is provisional. Adds a CSV default-read regression and a lookup-count bound. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Refuse operations relationship reads in the root reference shape, and memoize proven closures Operations API: a related table or attribute the role cannot describe is now reported as not existing, as a root reference is, instead of naming it in unauthorized_access; one it can describe but not read keeps the structured permission report. Denials on the root table go to the root attribute check. A search with no selection, condition, sort or value predicate returns before allocating. Admission: a readable closure is memoized for the admission when its walk revisited no table still being walked, so sibling relationships sharing a readable subgraph check it once. A single-record '*' is expanded only in a select written by authorization; other '*' selects keep their behavior. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Authorize a selected $record like the record itself $record returned the stored record, whose @enumerable relationships serialize without the related-table check a default read now gets. It now carries this table as its definition, so a selection of it is authorized and projected like a read of the record; full-access roles get the same value as before. Operations API: a denied related attribute named __proto__ is reported (the accumulator has no prototype), and the non-super * rewrite filters relationships only on tables that have them. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Index explicitly selected names once when listing @enumerable relationships Each relationship scanned the growing selection for an explicit entry of the same name, quadratic in the number of relationships on wide schemas. The explicit names are now collected once (no allocation for a bare '*'). The __proto__ refusal test goes through verifyPerms with real tables instead of reaching into the module. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Hold proven @enumerable closures for a whole read admission The memo generation started whenever no closure walk or projection was active, which is also true between explicitly selected sibling relationships, so each sibling re-walked a shared readable chain. allowRead (and the operations bridge's selection loop) now open one admission scope for the read, and a nested check for a different permission source starts a new generation. The relationship-specific part of allowRead moves into a function so tables without relationships skip the scope. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Reuse a completed @enumerable closure walk, cycles included A walk that completes successfully proves every table it marked, cycle members included, so a later check in the same admission that reaches one of them records the proof instead of walking the cycle again. Adds a cyclic shared-closure bound and a test that a permission-source change within one admission does not reuse another principal's proofs. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Prove every provisionally readable table once its closure walk succeeds A table whose walk succeeded only because it reached a table still being walked is queued on a per-table link; when the outermost walk succeeds, every queued table is proven for the admission. A failed or thrown walk publishes nothing: the queue is reset when the next walk starts. This replaces the completed-walk stamp, which a later walk could overwrite before its tables were reached again. Tests: alternating cyclic and acyclic selections stay within a linear lookup bound; a walk that fails after reaching a provisional cycle proves none of it; cycle proofs stay with the principal and permission source that made them. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Check value-search fields only on search_by_value, and require a sort object A hash or conditions search ignores search_attribute/search_value, so a stray relationship path there no longer refuses the request. A property set with sort: null is no longer treated as sorted. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Note the admission-scoped closure memo and the uncovered read paths Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Use smaller graphs in the admission lookup-bound tests The resources (core) CI step runs close to its 8-minute cap; the chain and alternating-selection bound tests spent most of their time creating tables. 16- and 8-table graphs still fail against the code each bound guards. Dispatch-Task: harper-relationship-related-table-permissions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHGnpcz4RLmn8aGcGZa62A * Reconcile REST select projection and nested relationship constraints with relationship authorization Preserve wildcard select projection, braced sub-select fallback, and nested relationship conditions, sort, limit, and offset from #3221 and projection from #3316. Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd Co-Authored-By: Gemini <noreply@google.com> * Stop relationship authorization at plain JSON sub-selections after #3456 Plain JSON and directReturn sub-objects are now projected resolver-free (nestedSelectTransform(..., undefined)), so a sub-selection on a plain JSON attribute never invokes the containing table's @relationship resolvers. Remove authorizeJsonSelection and checkSelectPaths recursion into non-relationship sub-selections while keeping containing-attribute and related-table permission checks. Dispatch-Task: pr-maint-0743c90ad97aeaf93e5aa68fe961f8dd Co-Authored-By: Gemini <noreply@google.com> * Keep a source record's own __proto__ field when narrowing a default read On a caching table's first read, the record reaching _copyUnlistedAttributes is the source's raw object, which can carry an own '__proto__' key (JSON.parse creates one). Plain assignment ran Object.prototype's setter: the field was dropped and the response object's prototype swapped. Define it as a data property instead, as RecordEncoder does, and check enumerable attributes as own keys so an inherited name never skips one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d * Let an operations related '*' sub-select narrow instead of refusing checkSelectPaths checked a nested '*' as an attribute named '*', which any attribute-restricted related table denies, so get_attributes [{name:'dept', select:['*']}] was refused for a role that can read Dept. The bridge already narrows a related '*' to the readable attributes through the related table's allowRead, as REST does; skip it at admission. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d * Share the admission path rule with nested execution, and prepare single-object selections _assertNestedReadAccess kept its own permission walk, which refused a forward relationship step the role reads through its foreign key, so a related selection admitted by _readDenial threw during nested condition preparation. It now delegates to _readDenial. transformEntryForSelect prepared nested conditions only for array selections, so a single-object relationship selection skipped its conditions; it now visits that form too, as Resource.transformForSelect does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d * Copy a mutable instance's current view when narrowing its default read On a loadAsInstance + getReturnMutable table, transformForSelect passed the tracked instance to _copyUnlistedAttributes, whose own keys hold none of the stored fields, so a partly restricted reader's default read came back empty. Pass the toJSON() view the ordinary wildcard already computes, and skip relationship keys in the copy since that view carries resolved relationships. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d * Apply the shared path rule to REST relationship property reads The property-path gate in authorizeRead checked the relationship name in the role's attribute_permissions directly, so GET /Emp/e1.dept was refused for a role that reads the relationship through its foreign key, while the same traversal as a select, condition or sort was admitted. It now asks _readDenial, like every other relationship path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d * Pin operations wildcard placement against @enumerable relationship reads Over HTTP, a '*' anywhere in get_attributes never returns an @enumerable relationship the role cannot read, and narrows one it reads partly: assert that for search_by_hash, search_by_conditions and search_by_value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d * Keep the direct property read for a single string selection Accepting a selection object in transformForSelect routed plain string selections through the selectFromObject/handleProperty closures built per record. Give the string form back its direct readPropertyFromObject call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3316-c07bc1d7cadb-c4f54c7d --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Gemini <noreply@google.com>
⊙ Problem
Search and filtered REST reads with
select(...)(GET /Article/?id=a1&select(id,meta{summary})) run nested JSON through the outer table's resolvers. A nested key named like an outer@computed,@relationshipor$idreturns that resolver's output instead: another table's record, a promise, or an error row. SelectingtoStringerrors the row.🤔 Is search/single-record parity the right contract?
Yes, as specified: both read the same stored data, and single-record reads already return it.
💡 Solution
computedMeta{publicSummary}returns onlypublicSummary.{name}and*as identity) now apply to plain JSON without the outer resolvers.🤔 Narrowed
@computedresults are client-visibleA client that relied on getting the full object back from
computed{field}now gets onlyfield. That is the documented meaning of a sub-select and matches single-record reads.🤔 A nested
{name}with no list now returns the value, not a 400This PR first made that shape a 400. #3221, now on
main, made it an identity transform, whichchildren[sort(-id)]needs. This PR keepsmain's contract on both paths and tests it for parity.⚖️ Alternatives
Two rejected approaches, and why the null-prototype resolver map breaks no index
Object.hasOwnat the one search lookup — leaves the otherpropertyResolvers[x]readers (index maintenance, search, derived indexes) exposed; the null prototype fixes them all at construction.Resource.transformForSelect— loses entry-level work it does not do (lazy loads, source revalidation, row filters, per-table read transactions).Does the null-prototype resolver map change existing indexes?
No: on
mainan indexed field namedhasOwnProperty,valueOfor__proto__made every write throw, andtoStringindexed every row as"[object Undefined]". No correct index exists to break. The plan review's other point, a selector-keyed cache instead of a name-keyed one, is adopted.Framing-Verdict: better-alternative-exists (5a39ffcdb4ea)
🔧 Changes
resources/Table.tsprojectPlainValue, both stored sub-objects anddirectReturn(@computed) results. Look hardest here: this is the boundary that keeps outer resolvers off nested data.Mapkeyed by selector also caches relationship transforms.propertyResolversgets a null prototype.[*]keeps its shape, and the*copy skips functions and keeps__proto__as data.__proto__select to the general path, so both paths project alike.conditions/sort/offset/limitstill dispatch to_prepareRelationshipSelectionfirst in both projectors; cancellation walks theMapcache's values so cached child transforms still stop.resources/Resource.ts__proto__is read only as an own property.*alone passes the value through. With names, it copies data keys: fromtoJSON()for a tracked instance, none from a Buffer, Blob or Date.search.forceNullsapplies after settlement.nestedSelectTransformbuilds every{ name, select?, sort?, offset?, limit? }transform for both projectors; plain JSON gets no target table, so no resolvers.ClientError.resources/DESIGN.md— the projection boundary and its invariants.✅ Verification
searchSelectProjection.test.js, 38 cases. Cases assert explicit expected values; most also assert that the search row equals the single-record projection. 16 of the first 19 cases fail onmain. Passes on RocksDB and LMDB.relationship-and-status-contract.test.tsadds a filtered-collection REST case over JSON, CBOR and MessagePack. It fails onmainand passes on both engines. The stored-__proto__expectation now gets no data instead of{}.relationship-and-status-contract24/24,nested-relationship-docs14/14.Earlier runs
test:unit:resources4597 passing,test:unit:main7485 passing,test:integration:all2590 passing / 0 failing.bench-runA/B: the new__proto__and pending checks cost ≤2% on 1M projections.Follow-ups filed from review
Fixes #3168
🤖 Generated by Claude Opus 5.5 via Claude Code (dev-agent); posted via @kriszyp.
Release target: v5.3 — fixes code from #3171, a backport to v5.3
Related PRs: #3221 overlaps (merged; reconciled in nestedSelectTransform), #3605 overlaps (merged; fast path matches selectAttribute), #3279 overlaps (merged; its constrained-relationship dispatch runs first), #3316 overlaps (its JSON authorization walk assumes nested JSON resolves outer relationships), #3615 overlaps (merged; its try/catch wraps this PR's projection body), #3430 overlaps (resources/DESIGN.md Conventions text), #3651 overlaps (selector reuse; prepared relationship selections stay name-keyed), #3619 overlaps (target-table read transaction in the relationship branch), 12 others independent
Complexity: complicated
Deferred findings
93da77d0a041f5d1minor correctness —resources/tracked.ts— tracked toJSON spreads a lazy typed-struct record, dropping stored fields from * and from the unprojected response once the instance has cha — filed Tracked toJSON drops a typed-struct record's stored fields once an instance has changes #345586e58afed4fe8f06minor correctness —resources/Table.ts— prepared relationship selections keyed by name; repeated relationship with nested options shares one projection — NOT FILEDe59c89793ef3bdd6minor correctness —resources/Resource.ts— lone ['*'] on resource-backed get copies via for-in while search returns record unchanged — NOT FILED85b4936e675f4c82nit correctness —resources/Resource.ts— non-Array results with .map are not settled by settleAll in static get/search — filed Resource select projection: inherited names shadow stored data on a changed instance; iterable results not settled #345463a46ea003a80572nit correctness —resources/Resource.ts— getRecord without toJSON falls back to getRecord() only, omitting changes and own fields from * — filed Tracked toJSON drops a typed-struct record's stored fields once an instance has changes #345569cad8b03477444cnit correctness —resources/Resource.ts— *-only pass-through returns an array of pending promises unsettled — NOT FILEDe2c2ba2c8fa72c59nit reliability —resources/Table.ts— child transform created after return() in async resolver continuation is never stopped — NOT FILEDdd77c6298b704852nit maintainability —resources/Table.ts— truthy loadingFromSource?.then check inconsistent with typeof-function guards — NOT FILEDOrigin — the dispatch brief this PR was written from
Fix collection/search
select(...)projection in Table.transformEntryForSelect (resources/Table.ts ~6644-6890 on main). (1) The plain-JSON sub-select branch (~6833-6851) builds the nested projector from the same table class, so nested keys that share a name with an outer @computed/@relationship/$id/$score/$updatedtime resolver are resolved against the nested object (a nested plain value can be replaced by a related-table record). Project plain JSON sub-objects with the resolver-free transformForSelect (already imported), as the single-record Resource path does. (2)propertyResolvers?.[attribute_name](~6749) has no own-property guard and propertyResolvers is a plain object literal (~8218), so selecting hasOwnProperty/valueOf/toString/constructor/proto calls an Object.prototype member and turns the row into an inline error. Build propertyResolvers with a null prototype (this also hardens the other propertyResolvers lookups). (3) For an object-returning @computed with directReturn, the sub-select is not applied, so unselected sub-fields leak; apply the sub-select before returning. Update the resources/DESIGN.md sentence that says transformEntryForSelect projects undeclared JSON sub-objects through the same form.Acceptance
Tests on RocksDB and LMDB: nested keys named like an outer @computed (sync and async), @relationship(from/to), $id, $score, $updatedtime project the stored nested value; selectors hasOwnProperty/valueOf/toString/constructor/proto on a filtered query return the stored value or undefined, never an error row; an object-returning @computed with a sub-select omits unselected fields; parity: Table.get(id) with select equals the matching Table.search row for every case. Shown failing on origin/main.
Dispatch: task
harper-select-projection-nested-resolvers· queued by finding-triage/wave-38 · ran by claude/opus/high · worker kzyp-xps-1Late Grok findings
7 surviving (highest minor) — Grok reviewed e1d3f33
resources/Table.ts:7638— if one relationship is selected twice and either selector carriesconditions,sort,offsetorlimit, both slots get one projection, because prepared selections are keyed by name here and atresources/Resource.ts:1242resources/Resource.ts:1233— a lone['*']select on a resource-backedgetcopies the record with for-in atresources/Resource.ts:1304and drops surfaced@computedfields;searchreturns the record unchanged atresources/Table.ts:7717, so the two return different fieldsresources/Resource.ts:1281— a nested plain object whosethenis a function is treated as a promise (also atresources/Resource.ts:1252,:1262and:1329), so its sub-select is skipped, or the row hangs ifthennever calls back; only trusted@computedor caching-source code can build such an objectresources/Resource.ts:1236— a*-only select passes an array of pending promises through unsettled (same atresources/Resource.ts:1366-1368), whilechildren{name}settles themresources/Table.ts:7824— a child transform created in an async resolver continuation aftertransform.return()never gets its ownreturn()call, so it reads related records after the query was cancelled; bounded to rows already in flightresources/Table.ts:7706—loadingFromSource?.thenis a truthiness check, unlike the surroundingtypeof … === 'function'guards; no normal producer of the failing shape (thenset to a non-function) existsresources/Resource.ts:1284— added comments atresources/Resource.ts:1179and:1284partly restate the codeNot standing: none. Grok ran 26m (26m from launch to report); the fold adjudication (
cursor-grok@e1d3f3395daa-da870c61, claude) took 1m.Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=22; full=6 @ e1d3f33
Review-Attention: deep ~50m (critical: Table.ts; hot: Resource.ts; decisions: do-less-alternative, computed-results-project-plain, proto-select-json-shape, invalid-select-clienterror, null-proto-resolvers) @ e1d3f33