Skip to content

Search select() returns stored nested JSON instead of running the outer table's resolvers on it - #3456

Merged
kriszyp merged 3 commits into
mainfrom
fix/search-select-projection-resolvers
Oct 28, 2026
Merged

kriszyp merged 3 commits into
mainfrom
fix/search-select-projection-resolvers

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 22, 2026 •

Copy link
Copy Markdown
Member
A gear robot mistaking a curly-brace butterfly for one of its own parts

⊙ 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, @relationship or $id returns that resolver's output instead: another table's record, a promise, or an error row. Selecting toString errors 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

🤔 Narrowed @computed results are client-visible

A client that relied on getting the full object back from computed{field} now gets only field. 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 400

This PR first made that shape a 400. #3221, now on main, made it an identity transform, which children[sort(-id)] needs. This PR keeps main'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.hasOwn at the one search lookup — leaves the other propertyResolvers[x] readers (index maintenance, search, derived indexes) exposed; the null prototype fixes them all at construction.
  • Route all search projection through 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 main an indexed field named hasOwnProperty, valueOf or __proto__ made every write throw, and toString indexed 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

✅ Verification

Earlier runs
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

  • 93da77d0a041f5d1 minor 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 #3455
  • 86e58afed4fe8f06 minor correctness — resources/Table.ts — prepared relationship selections keyed by name; repeated relationship with nested options shares one projection — NOT FILED
  • e59c89793ef3bdd6 minor correctness — resources/Resource.ts — lone ['*'] on resource-backed get copies via for-in while search returns record unchanged — NOT FILED
  • 85b4936e675f4c82 nit 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 #3454
  • 63a46ea003a80572 nit 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 #3455
  • 69cad8b03477444c nit correctness — resources/Resource.ts — *-only pass-through returns an array of pending promises unsettled — NOT FILED
  • e2c2ba2c8fa72c59 nit reliability — resources/Table.ts — child transform created after return() in async resolver continuation is never stopped — NOT FILED
  • dd77c6298b704852 nit maintainability — resources/Table.ts — truthy loadingFromSource?.then check inconsistent with typeof-function guards — NOT FILED
Origin — 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-1

Late Grok findings

7 surviving (highest minor) — Grok reviewed e1d3f33
  • minor — resources/Table.ts:7638 — if one relationship is selected twice and either selector carries conditions, sort, offset or limit, both slots get one projection, because prepared selections are keyed by name here and at resources/Resource.ts:1242
  • minor — resources/Resource.ts:1233 — a lone ['*'] select on a resource-backed get copies the record with for-in at resources/Resource.ts:1304 and drops surfaced @computed fields; search returns the record unchanged at resources/Table.ts:7717, so the two return different fields
  • nit — resources/Resource.ts:1281 — a nested plain object whose then is a function is treated as a promise (also at resources/Resource.ts:1252, :1262 and :1329), so its sub-select is skipped, or the row hangs if then never calls back; only trusted @computed or caching-source code can build such an object
  • nit — resources/Resource.ts:1236 — a *-only select passes an array of pending promises through unsettled (same at resources/Resource.ts:1366-1368), while children{name} settles them
  • nit — resources/Table.ts:7824 — a child transform created in an async resolver continuation after transform.return() never gets its own return() call, so it reads related records after the query was cancelled; bounded to rows already in flight
  • nit — resources/Table.ts:7706 — loadingFromSource?.then is a truthiness check, unlike the surrounding typeof … === 'function' guards; no normal producer of the failing shape (then set to a non-function) exists
  • nit — resources/Resource.ts:1284 — added comments at resources/Resource.ts:1179 and :1284 partly restate the code

Not 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

@kriszyp kriszyp added this to the v5.3 milestone Oct 22, 2026
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Release cherry-pick v5.3: conflict

Cherry-pick onto v5.3 produced conflicts on commit(s): 0ab28f8b6be13eeb817a5c4716595af2ceb17858 e1d3f3395daa60d76e9321ed0fca6265f4b33a07

The conflict markers are committed on branch cherry-pick/v5.3/pr-3456.
A pull request has been opened to land this patch: #3710

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kriszyp
kriszyp force-pushed the fix/search-select-projection-resolvers branch from d8b3842 to bc603f8 Compare October 23, 2026 06:57
kriszyp added a commit that referenced this pull request Oct 26, 2026
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
kriszyp added a commit that referenced this pull request Oct 26, 2026
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
kriszyp added a commit that referenced this pull request Oct 27, 2026
…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>
@kriszyp
kriszyp marked this pull request as ready for review October 27, 2026 03:22
@kriszyp
kriszyp force-pushed the fix/search-select-projection-resolvers branch 2 times, most recently from d43f4ac to 6efa14b Compare October 27, 2026 18:46
kriszyp and others added 3 commits October 27, 2026 23:37
…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
@kriszyp
kriszyp force-pushed the fix/search-select-projection-resolvers branch from 6efa14b to e1d3f33 Compare October 28, 2026 05:54
@kriszyp
kriszyp merged commit c10c595 into main Oct 28, 2026
71 of 75 checks passed
@kriszyp
kriszyp deleted the fix/search-select-projection-resolvers branch October 28, 2026 11:36
kriszyp added a commit that referenced this pull request Oct 28, 2026
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
kriszyp added a commit that referenced this pull request Oct 28, 2026
…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
kriszyp added a commit that referenced this pull request Oct 30, 2026


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>
kriszyp added a commit that referenced this pull request Oct 30, 2026
resources/DESIGN.md: keep this branch's select-projection paragraph
(#3456's resolver-free nested projection; #3168 is fixed by it) over
v5.3's rewording of the pre-#3456 text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-3710-pick-v53-conflicts-ci
kriszyp added a commit that referenced this pull request Oct 30, 2026
…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>
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.

Nested Resource projection fails to settle asynchronous arrays and forceNulls

1 participant