Skip to content

Isolate authorization for reusable queries and relationship selectors - #3651

Draft
kylebernhardy wants to merge 31 commits into
mainfrom
codex/isolate-selector-permissions
Draft

kylebernhardy wants to merge 31 commits into
mainfrom
codex/isolate-selector-permissions

Conversation

@kylebernhardy

@kylebernhardy kylebernhardy commented Oct 27, 2026 •

Copy link
Copy Markdown
Member
Felt Captain America Kyle with Parker and Kato

⊙ Problem

Applications reuse query objects and nested relationship selectors across requests. Authorization narrowed those objects in place, so later principals could lose allowed fields or inherit stale permission state.

🤔 Own templates at the authorization boundary?

Yes. Application templates must survive requests. Requiring every delegate to clone before calling a table is brittle across MCP, REST and direct APIs; copy where authorization writes while preserving transport identity.

💡 Solution

Authorize an owned root and copy each named relationship selector before narrowing it. Transport-owned roots retain dispatch state; application templates stay reusable. Nested reads use the current request's authority, including main's predicate, sort and enumerable-relationship checks.

⚖️ Alternatives

  • Clone only at transport entry — direct table/operations callers bypass it; applications can attach selectors afterward.
  • Replace permission flags alone — a previous caller's narrowed projection still loses fields.

🤔 Keep the named-array shell copy?

Yes. Isolating the inner projection preserves outer metadata; sharing it breaks existing cases. The authorization setup measurements quantify the per-relationship compatibility cost; they make no end-to-end throughput claim.

🤔 Breaking compatibility: narrowed targets

Reauthorizing an already-narrowed root now returns 500, including same-principal reuse and spreads of transport targets. A delegate that spreads its admitted target and sets checkPermission: true must forward the transport target itself or build a fresh application template instead. Context-based arming persists on that context. Decide authorization-reuse compatibility before release tracks the release decision.

🤔 Continue review after three full passes?

Keep the combined fix; closing review is complete. Three reviews found class/iterator/filter defects and added roughly 300 lines across 18 files to enforce one ownership invariant.

🔧 Changes

Ownership and regression tour

Product and architecture tour

Root and relationship ownership

RequestTarget preserves query controls, lazy accessors and one-shot predicates on owned copies; narrowed unowned roots are refused. transactional admission applies ownership after body resolution, retaining context arming and caller cache status.

Table authorization copies named selectors in _authorizeRelatedSelection before related allowRead runs. Read/subscription gates own their roots; private delete scans retain hidden filters. Current request authority replaces stale nested permissions.

Resource design notes record the invariants within the unchanged byte ceiling. Make each relationship condition existential, and add relationship[...] for conditions one related record must meet edits the same relationship note; whichever PR lands second reconciles that wording. Reconcile relationship design notes across selector and existential-condition changes tracks the documentation audit.

Keep transport dispatch state observable

REST and WebSocket dispatch, durable subscriptions and MQTT publication, and MCP resource subscriptions mark targets they create for one dispatch. Copies lose that ownership.

The root-query half includes #3779 — Authorize a copy of a delegated query object, so one module-level query serves every principal, merged into this PR branch, not main.

Regression coverage

  • Selector authorization covers both principal orders, object/parser/array forms, frozen constrained selectors, private-field class and array-subclass predicates, current authority, symbols, metadata, denied tables and transport-owned roots.
  • Delegated-query tests cover root reuse, class accessors/iterators, one-shot predicate replay and failed-iterator denial, non-enumerable controls and filtered deletion, copies of transport targets, concurrent principals, reauthorization refusal and cache disposition.
  • Relationship admission uses fresh dispatch targets after its separate direct allowRead assertion, retaining the omitted-primary-key predicate oracle.
  • Native full-text tests exercise supported root full-text searches with nested projections, both principal orders, denial and no-hit controls; teardown closes both runtimes.
  • Write-admission tests observe authorization on owned copies while preserving per-dispatch write verdicts.
  • Subscription-admission tests retain the caller target and inspect the distinct authorized copy.
  • Live REST/MCP tests exercise permission-object and true modes, cloned controls, both orders, symbol isolation and cache headers.
  • Worker-routing test checks restricted requests on each broad-used worker; the shared capability policy skips macOS, Windows and Bun, where full worker routing is unavailable; Linux still requires multiple observed workers.
  • Fixture resources share application queries/selectors across delegates and expose MCP reads under the ambient session transaction.
  • Fixture schema defines employee, related department and cached-label tables.
  • Fixture configuration loads those schema and resource files through ordinary application routing.

Measured copy cost

Ordinary named-array copy medians (Node 24.16.0/macOS; five 30,000-call samples after 5,000 warmups): 3/32/128 selected fields cost 0.90/2.31/7.17 microseconds per authorization versus 0.42/0.87/2.31 when removing only the shell copy. This real-Table.allowRead harness measures setup, not end-to-end throughput. Wide selectors cost more; copying preserves compatibility and prevents caller-state corruption. The key scan retains metadata even when a proxy reorders own keys.

✅ Verification

  • Build, lint, full-tree formatting, design-doc budgets and unit-partition checks pass.
  • Selector/delegation regressions: 150 selector/delegation cases plus 17 write-admission cases pass on each engine; source mode passes all 150 selector/delegation cases.
  • Live MCP/HTTP integration: 34 passing. The status diagnostic still reports three out-of-scope message exposures.
  • Full local main/resources/integration gates remained incomplete at 600 seconds; fresh CI is authoritative.
Baseline checks and local gate limits
  • Exact-main reproduction: 20 selected reuse/frozen cases and both MCP cases fail on main, then pass on this branch. Broad local main/resources/integration gates each hit 600 seconds without final summaries; they are incomplete, with unclassified failures. The final resource rerun also timed out at 600 seconds: five isolated cases pass on both branches, and repeated stale-refresh failures occur on exact main too. Fresh CI is running.

PR QA: skipped @ 80d44e8 — dispatch unavailable (CLI and token unavailable in this session)

Historical reviews cover earlier commits. Active trackers follow; filing labels may lag linked issues.

Scope and follow-ups

Fixes #3667 — Decide prototype compatibility for copied nested relationship selectors. Additional pre-existing follow-up: Normalize iterable nested predicates before permission analysis. Releases/backports remain untested. Nested full-text predicates remain unsupported.

No documentation companion: migration guidance for this draft is tracked in Decide authorization-reuse compatibility before release.

Framing-Verdict: chosen-approach-sound (5303aecd7abe)

Comment generated by kAIle (GPT-6)

Release target: main — release-branch backports need a maintainer decision
Related PRs: #3651 overlaps (this PR, retained by the historical scan; Isolate authorization for reusable queries and relationship selectors), #3779 overlaps (Authorize a copy of a delegated query object, so one module-level query serves every principal), #3847 overlaps (Add full-text boosts and optional scoring conditions), #3927 overlaps (Make each relationship condition existential, and add relationship[...] for conditions one related record must meet), #3997 overlaps (Hand off a startTime subscription replay to live delivery by drain, so a late lower-key commit is delivered once), 6 others independent
Complexity: complicated

Deferred findings

🤔 defer this PR's major correctness finding b3220cbb6f4c7256 (Role-dependent refusal breaks spread-forwarded delegated reads) to #4007 instead of fixing it here?

Late Grok findings

5 surviving (highest major) — Grok reviewed cddd4d0
  • major — resources/Table.ts:12218 — reusing a one-shot conditions iterator consumes the authorization filter, so a retry can return unfiltered readable rows
  • major — resources/Table.ts:2391 — spreading nested class selectors drops prototype-backed restrictions and can expose related fields
  • major — resources/RequestTarget.ts:227 — root class-query copies preserve prototypes without private brands and retain frozen descriptors, causing authorized requests to fail
  • minor — resources/Table.ts:2406 — an async related-table allowRead denial is treated as truthy and admits the related projection
  • nit — integrationTests/security/fixtures/delegated-query-reuse/resources.js:3 — three comments narrate adjacent fixture setup rather than preserving an invariant

Not standing: 2 accepted-known, 1 dropped-factually-wrong. Grok ran 17m (22m from launch to report); the fold adjudication (cursor-grok@cddd4d0eb216-fb2aa67c, codex) took 4m.

Late Grok findings

4 surviving (highest major) — Grok reviewed c424916, ruled against ea1a399
  • major — resources/RequestTarget.ts:283 — fast query copies eagerly invoke unused getters and discard their descriptors, so a throwing getter aborts authorization
  • major — resources/Table.ts:2391 — every named relationship selector now incurs unmeasured copying and allocation during read admission
  • major — resources/RequestTarget.ts:300 — the narrowed-target guard rejects a previously served delegate pattern only for roles whose projection required narrowing
  • nit — resources/DESIGN.md:7 — unrelated design-note condensation remains in this authorization-focused change

Not standing: 5 resolved. Grok ran 15m (16m from launch to report); the fold adjudication (cursor-grok@c424916c6c78-33005d9e, codex) took 4m.

Late Grok findings

2 surviving (highest major) — Grok reviewed 80d44e8
  • major — resources/Resource.ts:139 — records with a truthy then field are treated as thenables, so GET throws, returns the field’s result, or never settles
  • major — resources/RequestTarget.ts:15 — identity-based iterator replay can consume away an authorization predicate or pin a stale predicate, widening or misdirecting reused delegated reads

Not standing: none. Grok ran 23m (23m from launch to report); the fold adjudication (cursor-grok@80d44e80faec-ab073700, codex) took 4m.

Review-Coverage: authored=codex; ran=cursor-composer,claude,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; blocked=gemini(auth); declined=muse; rounds=7; full=4 @ 80d44e8

Review-Attention: deep ~35m (critical: Table.ts; hot: Resource.ts, DurableSubscriptionsSession.ts +1; decisions: do-less-alternative, transport-root-ownership, stale-target-refusal, persistent-context-arming, transport-owned-brand, stale-target-policy, deferred-b3220cbb; raised: degraded review) @ 80d44e8

@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 ensures that nested relationship selectors are cloned before authorization checks or projection narrowing in Table.ts, preventing cross-principal reuse pollution where reused selectors could lose requested fields. It also updates DESIGN.md to document this behavior and adds a comprehensive test suite in selectorAuthorization.test.js to verify selector isolation. I have no feedback to provide as the implementation is correct and well-tested.

@kylebernhardy

Copy link
Copy Markdown
Member Author

The current CI failures have matching baseline evidence. This supports treating them as existing failures rather than regressions from the selector-ownership repair; the PR checks remain red.

Failed PR check Baseline evidence
Node 22 and 24 resource tests Both exceed the same eight-minute step limit on current main: Node 22, Node 24. Tests continue completing immediately before cancellation, consistent with a suite-budget problem rather than a demonstrated hang.
Node 26 MQTT tests in the LMDB step Earlier main run fails the exact same QoS 1 and QoS 2 assertions at durableSessionResume.test.js:2380: actual ['a1', 'a2', 'marker'], expected ['a2', 'marker']. The MQTT implementation, test, workflow and dependency manifests are unchanged between that main revision and current main. Current main's Node 26 job passes, consistent with an intermittent baseline failure.
Lint The failing command is the design-document budget check: root DESIGN.md has 251 lines against a limit of 250. That file is unchanged by this PR; current main fails the same check.

Observed on the current PR head: all integration shards, native unit jobs, Windows unit tests, formatting and review coverage pass. These results do not clear the failed checks or complete the timed-out resource suites.

Remaining review limits: full-text-specific selector reuse and release-line runtimes remain untested; root targets remain single-use by contract. No human approval is recorded yet. The separate local HTTP error-message diagnostics disclosed in the description remain outside this repair. The PR remains draft.

Comment generated by kAIle (GPT-6)

@kylebernhardy

Copy link
Copy Markdown
Member Author

Added four published-native full-text regression cases: object/braced nested selectors, both principal orders, a full-text-enabled related table, source-field denial and a no-hit control. All four fail on unchanged main and pass with the selector copy. Two additional parsed-predicate cases also fail on main and pass with the fix. The selector suite now has 26 cases. Full-text predicates inside relationship selections remain unsupported; the native tests exercise supported root full-text searches with nested projections.

Completed the resource file list on both revisions and engines in 15 batches each, without timeouts:

Revision Engine Passing Pending Failing
PR RocksDB 5,332 72 7
Main RocksDB 5,304 72 5
PR LMDB 4,115 877 1
Main LMDB 4,090 873 0

Main is the current base. The difference in scheduled tests is 26 selector cases plus four native cases, the latter skipped on LMDB. One models batch initially failed to load on both revisions; loading the database module first, as earlier resource files normally do, completed that batch on both engines. These totals use that completed batch and the final rerun of the batch containing the added parser tests, without double-counting earlier attempts. Batching changes process/module ordering and does not make the canonical single-process gate green.

The failures are retained:

  • Five replicated-delete assertions fail on both RocksDB revisions.
  • The PR's two-second subscription replay timeout also reproduces in the exact same test on main.
  • The PR's HNSW batch failure varies across reruns. A legacy-reindex assertion fails with both main's and the fixed Table artifacts at the same PR checkout path; the initial descriptor-round-trip assertion was not independently reproduced on main. The separate main checkout passes that file, so this remains checkout/environment-sensitive evidence, not a resolved HNSW diagnosis.
  • The PR's LMDB drop-table logging assertion passes with both Table artifacts on rerun. Its original failure remains unclassified.

No production code changed in this follow-up. The targeted native full-text and parsed-predicate tests close those coverage gaps; the failed resource assertions, release-line qualification and human approval remain outstanding. The PR remains draft.

Comment generated by kAIle (GPT-6)

@kylebernhardy
kylebernhardy marked this pull request as ready for review October 27, 2026 20:32
@kylebernhardy
kylebernhardy requested a review from kriszyp October 27, 2026 20:32
kylebernhardy and others added 8 commits October 28, 2026 10:35
Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Keep the array shell copy to preserve the existing named-array shape and metadata seen by related allowRead overrides. The pre-existing Array.from creates a separate projection array without asArray/forceNulls metadata, so sharing the original array would change projection semantics.

Address the performance measurement request: a real Table.allowRead harness on Node v26.2.0 / Linux, 2026-10-28, used five 30,000-call samples after warmup, fresh root/selector objects and result checks. Median baseline/copy times were 0.86/1.80 us at width 3, 1.44/5.60 us at width 32, and 3.67/20.39 us at width 128. This is per-request authorization setup, not per-row work or an end-to-end throughput measurement. Retain the measured cost to preserve caller ownership and the existing array contract; broader optimization is outside this rebase.

Evidence: node_modules/.cache/task-3651/auth-bench-{baseline,copy}.log (local scratch artifacts).

Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Exercise both restricted and broad principals when a nested named array carries asArray/forceNulls. This rejects sharing the original array as the inner select: an unrestricted leaf otherwise becomes a positional array instead of its existing object projection. Keep the source clone unchanged.

Dispatch-Task: harper-3651-rebase-after-3456
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp
kriszyp force-pushed the codex/isolate-selector-permissions branch from dd5267a to 8c59c0a Compare October 28, 2026 17:28
@kriszyp
kriszyp marked this pull request as draft October 28, 2026 17:28
@kriszyp

kriszyp commented Oct 28, 2026

Copy link
Copy Markdown
Member

Rebased dd5267a2a3d0 → 8c59c0ab25cb onto main at d0e3d5beebc5. Only resources/DESIGN.md conflicted. Both the per-role selector isolation and #3456’s resolver-free stored-JSON projection remain intact. GitHub reports the branch mergeable; it remains a draft.

The failing coverage job was a review-metadata check, not test instrumentation: it required a Framing-Verdict. Planning review cleared chosen-approach-sound; the description refresh records it with current review footers.

I retained the additional named-array shell copy. A controlled shortcut that shares the original array as .select makes both GET/search return leaf: ["l1", "leaf value"] instead of the existing object projection when nested asArray metadata meets a broad role. The retained metadata regression rejects this shortcut. Copying only non-index metadata would also change the array contents received by related authorization overrides, broadening this rebase’s compatibility scope.

Measured medians from a local real-Table.allowRead harness on Node v26.2.0 / Linux (2026-10-28):

Selected fields Baseline, µs With shell copy, µs
3 0.86 1.80
32 1.44 5.60
128 3.67 20.39

Five 30,000-call samples followed 5,000 warmups per width. Each iteration creates fresh selectors/root targets and checks the restricted result. Baseline removes only the compiled shell-copy statement. These measure authorization setup with harness overhead, not end-to-end throughput. The cost is per relationship per request, not per returned row. I treat it as a measured compatibility trade-off; the review artifact still raises the earlier “unmeasured copy” concern because the diff-only reviewers did not receive this data.

The affected suites pass 262 tests on RocksDB and 238 on LMDB; the selector file has 30 cases. The live projection/status integration file passes 24 tests and is a projection regression guard, not proof of cross-request selector reuse. Full-gate limits remain explicit in the description.

Measurement harness

Executed with npx mocha node_modules/.cache/task-3651/authorization-bench.cjs under the repository’s Mocha setup. The baseline uses the same harness and fixtures after temporarily removing only the emitted else property = Array.isArray(...) ... shell-copy statement in dist/resources/Table.js; the compiled file was restored in finally.

const rootRequire = require('node:module').createRequire(process.cwd() + '/package.json');
const { setupTestDBPath } = rootRequire('./unitTests/testUtils.js');
const { table } = rootRequire('#src/resources/databases');
const { getWorkerIndex, setMainIsWorker } = rootRequire('#src/server/threads/manageThreads');
const assert = require('node:assert');
describe('authorization shell-copy measurement', function () {
 let Parent, previous;
 const fields = Array.from({ length: 128 }, (_, i) => 'field' + i);
 const permission = { selector_copy_bench: { tables: { Parent: { read: true }, Child: { read: true, attribute_permissions: [{ attribute_name: 'id', read: true }] } } } };
 const user = { role: { permission } };
 const context = { user };
 before(function () {
  setupTestDBPath();
  previous = getWorkerIndex() === 0;
  setMainIsWorker(true);
  const Child = table({ database: 'selector_copy_bench', table: 'Child', attributes: [{ name: 'id', isPrimaryKey: true }, ...fields.map(name => ({ name, type: 'String' }))] });
  Parent = table({ database: 'selector_copy_bench', table: 'Parent', attributes: [{ name: 'id', isPrimaryKey: true }, { name: 'childId' }, { name: 'child', relationship: { from: 'childId' }, type: 'Child', definition: { tableClass: Child } }] });
 });
 after(function () { setMainIsWorker(previous); });
 for (const width of [3, 32, 128]) it('width ' + width, function () {
  const instance = new Parent(undefined, context);
  const columns = ['id', ...fields.slice(0, width - 1)];
  const count = 30000;
  const run = n => {
   const start = process.hrtime.bigint();
   for (let i = 0; i < n; i++) {
    const selector = Object.assign(columns.slice(), { name: 'child' });
    const target = { select: [selector], checkPermission: permission };
    if (!instance.allowRead(user, target, context)) throw new Error('authorization unexpectedly denied');
    assert.deepStrictEqual(target.select[0].select, ['id']);
   }
   return Number(process.hrtime.bigint() - start) / n / 1000;
  };
  run(5000);
  const samples = Array.from({ length: 5 }, () => run(count)).sort((a,b) => a-b);
  process.stdout.write('MEASUREMENT ' + JSON.stringify({ width, unit: 'microseconds/authorization', median: samples[2], samples }) + '\n');
 });
});

🤖 Generated by OpenAI Codex; posted via @kriszyp.

@kriszyp

kriszyp commented Oct 29, 2026

Copy link
Copy Markdown
Member

Heads-up: #3779 — Authorize a copy of a delegated query object, so one module-level query serves every principal is stacked on this branch. It builds on the nested-selector copies here and adds the root-level half: every authorization gate now works on a copy of a caller-supplied query object (transport-minted targets are still authorized in place), so a module-level root no longer needs to be single-use.

Two things that touch this PR:

  • Authorize a copy of a delegated query object, so one module-level query serves every principal #3779 replaces the resources/DESIGN.md sentence "Root targets are narrowed in place … use a fresh root per dispatch" with a pointer to the new ownership rule, and this PR's body says the same ("Root targets remain mutable and single-use"). Once both land, that guidance no longer holds; expect a one-line text conflict in that bullet if this branch is reworded again.
  • The staged QA spec SP-79 needs both PRs: arm R2 (restricted then broad) stays red without this PR's nested copy.

Nice work on the selector isolation — it made the root fix a clean addition rather than an overlap.

🤖 Generated by Anthropic Claude (Claude Opus 5.5, Claude Code); posted via @kriszyp.

…ry serves every principal (#3779)

* Authorize a copy of a delegated query object, so one module-level query serves every principal

A delegated tables.X.search()/get() with checkPermission authorized the caller's
own query object in place: the gate consumed checkPermission, Table.allowRead
replaced select and stamped AUTHORIZATION_SELECT. A query object reused across
requests was then a trusted read carrying its first principal's projection, so
a restricted principal received attributes it may not read.

Every authorization gate (transactional(), Table.search, false-mode Table.get
and Table.subscribe) now passes its target through dispatchTarget(): a
RequestTarget a transport minted and branded with markDispatchTarget (REST,
WebSocket, MQTT, MCP) is authorized in place as before, anything else is
shallow-copied first. The brand is a private field, so no spread, assign or
deep clone of a transport target inherits it. Authorization requested on an
unbranded target already stamped AUTHORIZATION_SELECT is refused: its select is
an earlier principal's projection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Arm reused query-delete targets the way their callers do

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Own a gate's RequestTarget copy, keep accessor writes off caller classes, arm once

- dispatchTarget brands the RequestTarget copies it makes, so a later gate in the
  same dispatch authorizes that copy instead of copying it again.
- A copy of a class-instance query gets own checkPermission/select data
  properties: the gate's writes no longer reach accessors on the caller's class.
- In false mode the wrapper copies only to arm a target that is not armed yet, so
  a delegate's own armed query keeps receiving loadedFromSource.
- The cache-disposition fixture now arms the forwarded read and hides a field
  from the restricted role, and readiness probes are bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Keep context.authorize arming on an unarmed target, copy only to replace a permission

context.authorize arms one dispatch, and base left that arming on the caller's
target, so a target reused under the consumed context stayed gated. Arming only
adds a check, so it stays in place again; only replacing an explicit permission
object with `true` goes to a copy. False mode therefore no longer copies in the
wrapper for an unarmed target, and its get keeps reporting loadedFromSource on
the caller's object. A target the wrapper built from an id is not copied, and a
copy of a frozen class instance no longer redefines a non-configurable property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Arm after the body settles, own wrapper-built targets, and check worker spread

- Arming through context.authorize moves after resolveBody, so a write that
  throws (a frozen target) no longer leaves a pending body unobserved.
- A RequestTarget the wrapper built from an id is branded when authorization
  is requested, so a false-mode gate authorizes it in place instead of copying;
  only a native array slice counts as wrapper-built.
- The worker spec asserts broad requests reached more than one worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Arm a caller's unarmed target synchronously again, observing a pending body if that throws

Arming after the body settled left a window: context.authorize was already
consumed while the caller's target was not yet armed, so a reuse of the target
during a pending body, or after the body rejected, skipped the gate. Arming is
synchronous again, as on base; a write that throws (a frozen target) first
attaches a handler to the pending body. A sliced iterable query is no longer
treated as wrapper-built, since a caller's slice() may return shared state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Trim comments that restate identifiers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Own the RequestTarget the wrapper builds for an empty id tuple

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Do not require connection spread across workers; skip undefined fields in a class copy

Bun and Windows route every new connection to one worker, so the worker spec only
logs the spread. A class-instance copy defines checkPermission/select only when the
caller's object has them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Require worker spread only where connections are spread

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Keep a RequestTarget subclass's prototype on the gate's copy

A copy built as a base RequestTarget lost a subclass's conditions/select getters,
so a search could run without the caller's conditions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

* Give a subclass copy own checkPermission/select before restoring its prototype

Restoring a RequestTarget subclass's prototype also restored any accessors it
defines for checkPermission or select, so the gate's writes could reach the
caller's state again. Those two fields become own data properties first, as for
any other class-instance copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-delegated-read-copies-caller-query

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@kriszyp

kriszyp commented Oct 30, 2026

Copy link
Copy Markdown
Member

QA re-measured this on main 9c43c7790 and on this PR's head 7ad41f64: main still narrows a shared nested selector in place in Table.ts allowRead (~4097-4160; property.select = Array.from(property), property.checkPermission = target.checkPermission), so after one restricted request a trusted tables.X.get({id, select}) with no context returns the narrowed projection; the head passes every arm, including the trusted-call leg that the PR's own "a trusted read sharing a nested selector keeps its projection" test covers.

One gap in the PR's tests: the MCP surface. QA has two arms ready (permission object vs true; shared vs cloned selector, over MCP) that stay red on main and green on the head, and can contribute them to this PR or a follow-up.

— Claude Mythos 5.1 (finding triage)

@kylebernhardy kylebernhardy changed the title Isolate permissions when reusing nested relationship selectors Isolate authorization for reusable queries and relationship selectors Nov 4, 2026

This branch has not been deployed

No deployments
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.

Decide prototype compatibility for copied nested relationship selectors

2 participants