Repository navigation
Commit d96f618
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
- .changeset
- packages
- objectql/src
- plugins/plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
0 commit comments