Skip to content

Commit d96f618

Browse files
feat(objectql): the ReadableIds leaf inside a composed read scope is composed too — the nested master chain (ADR-0139 D6 as amended) (#25072)
Fixes #25010 **Built on PR #25032 (#25009), which has landed.** This branch was cut from `claude/issue-25009-readable-ids-delegated-compose` at `2a8d0d5b4` (the delegated composition and the ADR-0139 revision), so the commits up to `2a8d0d5b4` are PR #25032's; PR #25032 merged into `main` as `f8c5fb396` and this branch then merged `origin/main` (one true merge, `70d95b3e2`, the one conflict in `engine.ts` resolved by keeping this card's hunks over the identical #25009 text `main` now carries). `git diff origin/main...HEAD` is this card's diff alone: five files under `packages/objectql`, `packages/plugins/plugin-security` and `.changeset/`; `docs/adr/**` is not in it. Every reading below is on the post-merge head unless it says otherwise. Engine card (b)(ii) of the maintainer's ruling B on #24932 (comment 6303721609): a `ReadableIds` leaf that sits INSIDE a composed read scope — the scope `getReadFilter(P)` answers for a parent whose own read scope is derived from ITS master, a two-level `controlled_by_parent` chain — is composed by the engine, recursively, instead of reaching the driver uncomposed. The plugin-security switch of `controlled_by_parent` (its call sites, the id-list pre-scan's retirement, ADR-0055's text) is card (c), #24932, and the consumers' probe routing is #25034; none of it is touched here. Clause-②: no (an engine-internal leaf, not a published accept set; the runtime read-scope change is the human floor's, decided by ruling 6303721609) ## What changed **`packages/objectql/src/engine.ts` — the ReadableIds leaf composition only.** `composeReadableIdsAtBoundary` keeps its five steps per parent and becomes the chain's driver: it resolves the security service once for the read and hands the sites to the new `composeReadableIdsChain`, which composes the leaves at one level and, FIRST, every leaf a scope answered for one of their parents carries — the same five steps, the same caller context at every level (a delegated context included, `onBehalfOf` intact), recursively. The parent's leaf is then composed with the REWRITTEN scope (copy-on-write; the `'policy'` provenance mark the scope was given survives the rewrite), so the driver receives one composed leaf whose scope holds composed leaves and compiles one statement with the subqueries nested. Per read, one `ReadableIdsChain` carries the object and verb, the security resolution, the context and the answers, so a parent is asked once at whichever level it first appears and its composed scope is reused by reference. Three refusals by name (`INTERNAL_ERROR` / 500, D5's envelope, before the service is asked about that parent): D5's two classes at a nested level (`refuseUncomposableReadableIdsClasses`, unchanged, judges the nested sites exactly as the top-level ones — the whole chain compiles into the read's one statement on the read's datasource); a chain that returns to a parent already on its branch (a cycle); a chain deeper than `READABLE_IDS_MAX_CHAIN_DEPTH` (8 scopes — the same cut as plugin-security's `CBP_MAX_CHAIN_DEPTH`: a detail with seven derived masters above it composes, an eighth is refused there and here; a cost ceiling, its derivation in the docblock). A nested leaf outside a `$in` comparand, or a composed leaf the engine did not compose, inside an answered scope is refused by the same walk that refuses it in a caller's `where`: the scope is the `where` of a read of its parent and is judged as one. `readableIdsScopeOf` and the closed doors are unchanged: `canReadObject` false, the deny sentinel, a throw on either half or an unregistered parent answer `$in: []` inside the composed scope, and the outer leaf is still composed. **`packages/objectql/src/filter-comparand-shape.ts`.** `collectComposableReadableIds` gains an optional `at` label (default: `path`) so the boundary can walk a composed scope with `path` `'where'` (the position judgment: a scope IS the `where` of a read of its parent) while its sites and refusals read `where.record_id.$in.scope.grand.$in` — the leaf, its scope, the field inside it. No caller changed; the door and the boundary pass what they passed. **Tests.** `packages/objectql/src/engine-readable-ids-nested-composition.test.ts` (new, beside the composition pins, the recording driver): each verb hands its driver one call whose composed leaf carries a composed leaf, the chain asked outermost first, each parent once, both halves with the caller context as it stands; a delegated context at every level; an undefined grandparent scope; a middleware-written leaf; the closed doors at the nested level answer `$in: []` inside the composed scope; the parent's own door closed leaves the grandparent unasked; D5.2 / D5.3 at the nested level refuse with code and status, no driver call, the grandparent unasked; a nested leaf at every other position and a forged composed leaf inside a scope refuse by name; two cycles refuse by name; a chain of eight scopes composes whole and a ninth is refused before its parent is asked; a parent at two levels is asked once and reused by reference; a control: a scope with no leaf composes by reference, one level asked. `packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts` (new, beside the delegated real-stack pins): `ObjectQL` over better-sqlite3, the real `SecurityPlugin` and `SharingService`, the chain child → PARENT (`controlled_by_parent`, master GRAND) → GRAND (`private`), the SAME rows read two ways — today's id-list path (a `controlled_by_parent` child, scoped by the middleware chain's pre-scan) and the leaf path (a child whose reads carry a leaf over PARENT, with the engine's security source wrapped so `getReadFilter(PARENT)` answers the real scope with its pre-resolved master id list replaced by `ReadableIds.of(GRAND)` — the spelling the plugin-security switch will answer; the wrapper stands in for that switch and touches nothing else). Pinned: a direct caller (two identities) and a delegated caller (the delegator intersection at both levels) get row-for-row the rows today's path serves, `find` and `count` agree; the wrapper replaced the id list once per leg; the composed read is ONE statement, the parent and the grandparent inside nested subqueries, with no pre-scan of the parent, against today's pre-scans of the grandparent and the parent and a child statement that binds a list; the closed doors: a caller the grandparent object does not admit, a delegator who does not exist, a delegator the grandparent object does not admit; a system control. **Changeset** `.changeset/25010-readable-ids-nested-compose.md`: `@objectstack/objectql` minor — a capability, levelled as PR #25032's and the earlier ADR-0139 composition cards were. Only `@objectstack/objectql`'s source changed; the plugin-security file is a test. ## Measured first (the PM's three hypotheses) - **H1 (reproduced on the base, `2a8d0d5b4`, before any engine edit):** with the engine's two files restored tree-only to the base (blobs `2879fdae7f2d` and `b5a9e1aa8d95` verified on disk, `composeReadableIdsChain` 0 occurrences) and the real-stack pins run, every leaf-path read of the chain — direct, delegated, and both closed-door cases — answered driver-sql's refusal: `[driver-sql] where.grand.$in holds a ReadableIds leaf, "the ids of 'qa_rid_nest_grand' this caller may read", UNCOMPOSED: it reached the driver without passing the engine's composition seam … The query was NOT run` (`INTERNAL_ERROR` / 500); `5 failed | 4 passed (9)`. Restore to HEAD proven (blobs equal HEAD's, `git diff HEAD` empty). - **H2 (holds):** the composition is recursive, the same context at every level; measured on the real stack, the composed statement is `select * from qa_rid_nest_child_leaf where parent in (select id from qa_rid_nest_parent where grand in (select id from qa_rid_nest_grand where ((owner_id = ?) or (id in (?)))))`. The cycle guard is real and measured (two cycle shapes refuse by name). - **H3 (statement count, measured):** the composed read is one statement per `find` and one per `count`. Today's path on the same rig: one `select id from GRAND`, one `select id from PARENT where grand in (?, …)`, then the child's statement binding the parent ids — three statements that read the chain per `find`, none a subquery. (On this rig the real `getReadFilter(PARENT)` still runs its own grandparent pre-scan while ANSWERING the scope the wrapper rewrites, and plugin-sharing reads the share rows; those are the security service's answering, not the read, and the pre-scan retires with card (c).) ## Verification (head `549f0bcc8`, the post-merge head; every heavy run through `scripts/pm/os-verify-lock.sh`, verdict read off its VERDICT line; a shared box, so the durations are not idle-box figures) - Build of the dependency closure, `pnpm exec turbo run build --filter='@objectstack/plugin-security...' --concurrency=2`: 19 tasks successful (15 cached), `VERDICT command-exit 0`. The closure includes `@objectstack/objectql`; the plugin-security suite reads its changed source through the package's vitest alias to `src/`, and the objectql suite imports `./engine.js`. - `pnpm --filter @objectstack/objectql test` (`vitest run --project local`): `Test Files 454 passed (454)`, `Tests 8545 passed (8545)`, 692 s, exit 0. - `pnpm --filter @objectstack/plugin-security test`: `Test Files 201 passed (201)`, `Tests 4133 passed | 45 skipped (4178)`, 259 s, exit 0. The ablation leg, the two suites and the two typechecks ran as one locked command: `VERDICT command-exit 0 · held the lock 1070s`. (On the pre-merge head `9cee19a5d` the same two suites read 452 / 8523 and 201 / 4133 + 45 skipped, both exit 0.) - `pnpm --filter @objectstack/objectql typecheck` exit 0 (tsc, and `check:test-typecheck: OK — 39 file(s) / 233 error(s) / 64 pinned signature(s) held`, the pre-existing ledger, no new file in it); `pnpm --filter @objectstack/plugin-security typecheck` exit 0 (`check:test-typecheck: OK — 0 file(s) / 0 error(s)`). - The two new files alone, run on the pre-merge head `9cee19a5d` (their only change since is the four-site cast move above): objectql nested + composition pins `Test Files 2 passed (2)`, `Tests 39 passed (39)`; the real-stack nested pins `Tests 9 passed (9)`. On the final head they ran inside the full suites above. - `test:repo` (the vitest `repo` project) not run locally: CI's `Test Core` runs it on the PR. ## Ablation (one behaviour changed, one leg; `scripts/ablation-replace.mjs`, mutation and restore proven on disk) **The recursion removed** (`packages/objectql/src/engine.ts`, anchor `: { scope: await this.composeReadableIdsChain(chain, answer.scope, nested, [...ancestors, parent]) });` → `: answer); /* ABLATION_25010_NO_RECURSION */`, so a scope that carries a leaf is handed through as answered — the base behaviour), on the post-merge head `549f0bcc8`: `ok mutation landed: anchor 1 → 0, blob 5139386 → 999f76cdee36` (on disk inside the leg: the recursion call 0 occurrences, the marker 1). Under it: `engine-readable-ids-nested-composition.test.ts` `9 failed | 30 passed (39)` with the composition pins beside it — every chain pin red (the composed chain, the delegated chain, the undefined grandparent scope, the middleware-written leaf, the nested closed doors, D5.2 / D5.3 at the nested level, the cycles, the bound, the parent at two levels; the three that stay green need no recursion: the parent's own door, the in-scope position refusals, the control); `readable-ids-nested-compose.test.ts` `5 failed | 14 passed (19)` with the delegated real-stack pins beside it — the direct parity, the delegated parity, the one-statement pin and both nested closed doors red on driver-sql's UNCOMPOSED refusal, as on the base. Restore: `ok restored: blob == HEAD (5139386) and git diff HEAD is empty`, the marker 0 occurrences after. (The same leg on the pre-merge head `9cee19a5d`, engine blob `455faa95a5fb`, gave the same two readings.) Both suites ran against `src/` (objectql's own test imports `./engine.js`; the plugin-security suite aliases `@objectstack/objectql` to `src/index.ts`), so no dist rebuild was on the resolution path; `git status --porcelain` empty after the leg. ## Gates (derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, on this head; exit codes captured to a file before any pipe; reconciled with `--ran`) `✓ dispatch-gates --ran: 75 derived famil(ies) accounted for — 72 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3), 0 UNRUN` on `549f0bcc8`. All 72 run families exit 0, including `check:query-options-erasure` (the ratchet holds at 236 test sites: an earlier revision of the objectql pins cast the options object `as any` at four call sites, which the ratchet counts; the cast moved to the context value, the sibling test's spelling), `check:changeset-gate-self-tests`, `check:empty-changeset`, `check:changeset-no-major`, `check:adr-0087-registration`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:where-matcher`, `check:objectql-double-limit`, `check:engine-double-contract`, `check:durability-log-level`, `check:swallow-census-controls`, `check:stack-collection-maps`, `check:type-check-coverage`, `check:nul-bytes`, `check:undeclared-dep-imports`. NOT MEASURED locally, each a refusal of its own prerequisite (exit 3), not a red: `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n` (both read every package's `dist/`; this tree built the plugin-security closure only — CI's `Build Core` and the lint legs carry them) and `pnpm check:type-check-debt` (it rebuilds the closure under the shared verify lock and its queue budget ran out behind this PR's own locked suite run, and a second attempt after that run timed out behind another seat's hold — `--re-measure cannot run: the closure build never ran`, exit 3 both times; CI's `Type Check · debt ledger` carries it). The derivation runs off the merge base with `origin/main`, which after the merge is `main`'s own head, so the union covers this PR's five files alone (75 families; the earlier derivation on the stacked base, before PR #25032 landed, covered its eleven files too and named 83). The full farm is CI's; read the PR's check-runs. ## Acceptance notes - **Driver side of the nested shape (ADR-0139 D3), measured, not changed here — carrier: #24932 (card (c)), noted, not filed.** driver-sql compiles a composed scope that holds a composed leaf through its own `applyFilters` (the nested subquery above), and driver-memory resolves it through `convertToMongoQuery`; both by construction, no edit. The Turso driver's two faces disagree: probed on this head with the spec's conformance rows and a composed leaf whose scope carries a composed leaf, the LOCAL face answers rows (`c1, c3, c6, c7`, the semi-join evaluated), the REMOTE face refuses `INTERNAL_ERROR` / 500 — `[driver-turso] where.id.$in holds a ReadableIds leaf … composed, and driver-turso does not compile it: the remote transport compiles it only as TursoDriver resolved it` — because `TursoDriver.toRemoteWhere` resolves only the top-level composed leaves (`replaceComposedReadableIds` does not descend into a composed leaf's scope) and hands the nested one to the transport through `toRemoteFilter`. The remedy is one line (resolve the scope through `toRemoteWhere` with the outer read's options) plus a nested case in the spec conformance table so every driver suite measures the shape. Not done here: no shipped producer writes a nested leaf until card (c) lands (nothing reaches it today), and the fix adds a driver verification surface this card does not own. It belongs to card (c)'s landing or a sub-card of it, before the switch makes the shape live on a Turso remote deployment. - Today's id-list path still pre-scans inside the real `getReadFilter(PARENT)` on this rig (the wrapper replaces its output); that is ADR-0055 §2 mechanism (b) standing until the switch, as the ruling orders — not a finding. - The ADR-0139 D2 text says the composed scope "is run through the same lowering P's own direct read runs"; the engine marks the scope `'policy'` and hands it through as the security service answered it (the service answers driver-ready filters), at the nested level exactly as at the top level. Source reading, unchanged here; carrier: none — noted. - AGENTS.md §2 (Multi-agent discipline) says a stacked series "is NOT a supported form" and names a trunk branch; the dispatch claim (6307546021) chose the stack explicitly so this card builds on #25009's composition in the same region. Recorded as the dispatch's decision; the landing order is in the first paragraph. ## 维护者速读(草稿) **改了什么。** 引擎的 `ReadableIds` 叶子组合现在是递归的:当某个父对象 P 的读范围(`getReadFilter(P)`)本身又带着一枚指向它自己主对象 G 的叶子时(两级主从链:子表 → P 为 controlled_by_parent → G),引擎先把 G 那枚叶子组合好,再用改写后的范围组合 P 的叶子,每一级都用调用者原样的上下文(代理调用者也一样)。驱动拿到的是一枚范围里嵌着另一枚已组合叶子的叶子,编译成一条嵌套子查询的 SQL。每个父对象每次读只问一次;环(范围绕回自己)与超过 8 层的链按名拒绝(500,D5 的信封),不半截组合。 **为什么改。** 这是维护者在 #24932 上裁决 B 的第 (b)(ii) 步,也是 #25009(PR #25032)的姊妹:两步合起来,引擎对每一种形态都能组合叶子,plugin-security 才能在卡 (c) 一次性把 controlled_by_parent 切到叶子、退役主表 id 列表预扫描(HotCRM 规模下实测 500 的那条路径)。实测:改之前,两级链上的嵌套叶子原样到达 driver-sql,被拒 500;改之后一条语句读完整条链,直接调用者与代理调用者的行集与今天的 id 列表路径逐行一致,`find` 与 `count` 一致。 **风险与代价(含回滚)。** 线上已发布面无行为变化:今天没有任何安全服务在读范围里写叶子(controlled_by_parent 仍是 id 列表,等卡 (c)),六个消费者的单层叶子按引用原样组合(对照 pin 已钉住)。新增的拒绝只在服务答出环或过深链时触发。真机测试:objectql 与 plugin-security 两包全量套件绿;消融(去掉递归)把嵌套 pin 打红后复原。一个已测量但未处理的点:Turso 远程面不编译嵌套叶子(本地面可以),在卡 (c) 让这种形态上线之前需要一行修复加一条一致性用例,见 Acceptance notes。回滚:revert 本 PR 即回到单层组合,无数据或 schema 变更;changeset 为 minor。 **席位意见。** **你要做的。** 本 PR 建立在已落地的 PR #25032 之上,分支已合入 `origin/main`,diff 只剩本卡的五个文件,不触及受管面(ADR 文本随 #25032 已落地);席位复核后入队落地即可。另请决定 Turso 远程面的嵌套叶子修复挂在卡 (c) 还是单开子卡。 --- _Generated by [Claude Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_ --------- Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent c0ae356 commit d96f618

5 files changed

Lines changed: 1079 additions & 13 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
feat(objectql): the engine composes a `ReadableIds` leaf that sits inside a composed read scope — the nested master chain (ADR-0139 D6 as amended)
6+
7+
Clause-②: no
8+
9+
**Before.** The executor boundary composed every `ReadableIds` leaf in a read's own `where` with the parent's read scope, and handed the driver that scope as answered. A scope that itself carried a leaf — a parent whose read scope is derived from ITS master, `{ master_fk: { $in: ReadableIds.of(G) } }`, the shape a two-level `controlled_by_parent` chain takes once its scope is spelled as a leaf — reached the driver uncomposed inside the parent's subquery, and the driver refused it, `INTERNAL_ERROR` / 500 (measured on driver-sql).
10+
11+
**Now.** Every leaf a composed scope carries is composed the same way, before the leaf that encloses it — the same five steps, the same caller context at every level (a delegated context included), recursively. The driver receives one composed leaf whose scope holds composed leaves and compiles one statement with the subqueries nested; a two-level chain serves exactly the rows today's id-list pre-scan serves, for a direct and for a delegated caller (measured on the real stack: `find` and `count` agree, one statement per read). Each parent is asked once per read, at whichever level it first appears. The closed doors hold at every level: `canReadObject` false, the deny sentinel, a throw on either half, or a parent the registry does not hold answer `$in: []` inside the composed scope. Refused by name, `INTERNAL_ERROR` / 500, before any driver is asked: D5's two classes at a nested level (a middleware-gated or federated master), a nested leaf outside a `$in` comparand or a composed leaf the engine did not compose inside a scope, a chain that returns to a parent already on its branch, and a chain deeper than 8 scopes (the same cut as the `controlled_by_parent` derivation's).
12+
13+
**What a reader sees.** No shipped security service answers a read scope carrying a leaf yet — `controlled_by_parent`'s scope is still the pre-resolved id list (ADR-0055 §2 mechanism (b)) until the plugin-security switch lands on its own card — so every shipped read is served as before. The six consumers' leaves compose exactly as they did: a scope with no leaf is composed by reference, one level asked.
14+
15+
**Unchanged.** `controlled_by_parent`'s own read scope and the id-list pre-scan, ADR-0055, every consumer's probe routing, and the drivers' compilation of the composed leaf (driver-sql and driver-memory already compile a scope that holds a composed leaf, through their own filter path).

0 commit comments

Comments
 (0)