Repository navigation
feat(objectql): the ReadableIds leaf inside a composed read scope is composed too — the nested master chain (ADR-0139 D6 as amended) - #25072
Conversation
…legated caller; getReadFilter threads the D10 intersection Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…9 revision draft, taken over unverified The delegated-compose pins beside the leaf tests (objectql) and the real-stack test in plugin-security, the three plugin-security pins respelled for a delegator who does not exist, and the ADR-0139 draft (O5 answered, D5.1 amended). Committed as found in the interrupted run's worktree; verified in the commits that follow. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…adable-ids-delegated-compose
…elegated caller with the delegator intersection; the changeset, the ADR E2 row and the dangling-delegator report line The missing-delegator refusal in computeDelegatedReadFilter carries the D10 invariant sentence the report-sink pin reads; the two bare-instance pins' docblocks say which refusal they now reach. The changeset for @objectstack/objectql and @objectstack/plugin-security (minor, as the earlier ADR-0139 composition cards). ADR-0139's E2 row notes D5.1's amendment beside its historical "three classes". Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…0 invariant sentence verbatim, as the report-sink pin reads it Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…ded D5.1; the ancestors docblock counts six call sites - Open items: O5 is marked answered, pointing at D6 where the ruling is recorded. - E2 row, Pins cell: a delegated caller composes (both halves asked with the link intact, the closed doors still `$in: []`); a middleware-gated or federated parent keeps the loud refusal, a delegated context included. - security-plugin.ts: computeControlledByParentFilter's `ancestors` docblock counts the six call sites (five with empty ancestors, the recursive hop the sixth). Comment only. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…sed too — the nested master chain (WIP: pins and real-stack measurement unrun) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
…sed read's own statement and spell an undefined grandparent scope explicitly Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
… (PR #25032, the stack base, landed as f8c5fb3) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
… options object (the query-options-erasure ratchet counts an erased options argument) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 540c91ac7da34c58686c9c0e2ac3f8af4d32c579 && git checkout 540c91ac7da34c58686c9c0e2ac3f8af4d32c579
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 688052e147f23a15e127070ff1bc7c1572de09ab 549f0bcc89068b9dcf81ff8efc403d64f35b43f8 && git checkout -B drift-repro 688052e147f23a15e127070ff1bc7c1572de09ab && git merge --no-ff 549f0bcc89068b9dcf81ff8efc403d64f35b43f8
node scripts/docs-audit/affected-docs.mjs --json 688052e147f23a15e127070ff1bc7c1572de09ab
|
席位核对Seat review ·
Generated by Claude Code |
…red expression replaces is refused, and nothing is written (#25086) Closes #25000 Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a stored expression replaces is refused instead of reported; it ships now under the amended 「甲」, #6015 `6303865025`) ## What changes `strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed (#24690, PR #24778 `7532ea3c08`), one reported drop was left out of the refusal: a caller value for a stored (non-`formula`) field that declares an `expression`, which the engine replaces with the computed value. Under strict such a write completed and was only reported. The amended ruling 「甲」 (#6015 `6303865025`) ships the refusal in 18.x as a BREAKING minor, and triage re-timed this card to 18.x (`6305854988`). - **Insert (single, batch, `insertMany`).** The strict check in `engine.insert` now judges `insertDrops`, the one event list that includes the stored-expression replacement, after the compute pass. Before, it judged only the strips' drops, ahead of that pass. A replaced value throws `ReadonlyFieldRejectedError` (`operation: 'insert'`) before validation and before any driver dispatch, and no listener fires. `insertMany` delegates to `insert`, so it refuses the whole call, as it does for the other strict drops. - **Update by id and predicate update.** The `stored_expression` report now goes through `emitDrop`, the channel every strip reports through, and no longer calls `notifyDrop` directly. On both branches the one `assertNoStrictDrops()` call now comes after the stored-expression pass. So under strict the write is refused before the statement and nothing is written. An object that declares no stored expression skips that pass, so its refusal happens exactly where it did before. - **One refusal names everything.** A payload that carries another strip's drop as well as a replaced value is now refused once, naming both, with `stored_expression` last. Before, it was refused for the other drop alone. - **Wording.** `REASON_PHRASE.stored_expression` (written by #24690 ahead of this refusal) is reused. The per-reason message gains a `stored_expression` remedy (leave the field out, or send the computed value). That remedy is spoken only when the reason is present, so every other refusal's message is unchanged byte for byte, by the same rule the `computed` remedy follows. - **Docs.** The TSDoc in `packages/spec/src/contracts/data-engine.ts` (`strictReadonlyWrites`) and `packages/spec/src/data/data-engine.zod.ts` (`DroppedFieldsEvent.reason`) no longer names `stored_expression` as the arm strict does not refuse. The objectql comments in `engine.ts` and `readonly-strict-errors.ts` follow. No `.describe()` text moved: `check:generated` reports all 14 artifacts current, so the generated reference docs have nothing to regenerate. **Unchanged:** a strict write that sends the computed value, or no value, is not refused. A stored expression field that is also `readonly` is still refused once, as `readonly`. Without strict, the write completes with the computed value and is reported. The error code is unchanged. ## Tests `packages/objectql/src/engine-stored-expression-dropped-fields.test.ts`: the strict block changes from "reported, not refused" to "refused, nothing written". It covers update by id, insert single, batch, `insertMany` and predicate update. Each refusal asserts the `code`, `drops` (and `operation`/`fields` where they matter), that the store is unchanged, and that the listener stayed silent. A non-strict control sits beside each refusal (the write succeeds and is reported), together with the strict controls for "computed value sent / no value". The union case is also covered (`readonly_when` + `stored_expression` in one refusal), and so is the `readonly`-only insert refusal. All runs were taken on the final code commit `a8e9d3611c`: - `pnpm --filter @objectstack/objectql test`: 455 files / 8569 tests passed. `pnpm --filter @objectstack/objectql typecheck`: `tsc --noEmit` clean, and `check:test-typecheck` OK (ledger held). - `pnpm --filter @objectstack/spec build && check:generated`: all 14 generated artifacts up to date. `spec test`: 705 files / 21424 passed (1 todo). `spec typecheck`: OK. `spec test:repo`: 72 files / 1237 passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 94 commands from merge base `688052e14`, and all 94 exited 0. On the first pass, `check:dual-build-cjs-loads` and `check:type-check-debt` printed PREREQUISITE NOT MET (exit 3, nothing measured). I re-ran `check:type-check-debt` under the verify lock: its closure build ran and it re-measured OK, `none above its recorded number`. That full build also produced the missing `dist/` trees, and `check:dual-build-cjs-loads` then passed (112 entry points across 69 packages load). `dispatch-gates --ran` reconciles 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. The changeset gates are among the 94: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`. **Ablation.** The fix was committed first. Each leg went through `scripts/ablation-replace.mjs`, with an anchor hit of 1 to 0 and the blob proven back to the HEAD blob `3e8c0ec2bd1a` with `git diff HEAD` empty. The suite imports `./engine.js` from source, so no build is involved. - A, update paths: `emitDrop` lets `stored_expression` past strict. Three tests go red (update by id, predicate update, the union case), and the other 22 stay green. - B, insert: the strict check ignores a `stored_expression`-only list. Three tests go red (insert single, batch, `insertMany`), and the other 22 stay green. ## Producers measured No non-test caller in this repository passes `strictReadonlyWrites`: the examples, `packages/qa` dogfood and the runtime and REST doors pass none. The option is also not on the serializable options bag, so no wire caller can set it. No in-repo producer moves. In the objectql suite, the in-package strict tests on stored-expression objects (`engine-stored-expression-write.test.ts`, `engine-write-formula-hydration.test.ts`, `engine-formula-write-strip.test.ts`) stay green unchanged. ## Changeset `.changeset/25000-strict-stored-expression-refusal.md`: `@objectstack/objectql` `minor`, with a BREAKING section that maps FROM (completed, reported) TO (refused, nothing written), gives the remedy, and carries the claim's `Clause-②` line and an ADR-0087 `not-required (no-migration-prescription)` marker (the precedent shape of `bb4f5cc005`). `@objectstack/spec` is not listed: only TSDoc comments moved there, and no `.describe()` text or export did. ## Acceptance notes - The release-owned 18.x upgrade checklist (`content/docs/releases/`) is not edited here. The changeset carries the text that release compilation reads. - The pending `.changeset/24690-stored-expression-dropped-fields.md` (in the open Version Packages PR) says strict "is unchanged" and that the refusal is deferred to 19.0. That file is outside this card's claimed file surface, so this PR leaves it alone. The new changeset says it supersedes that bullet. The report names the one-line amendment for the seat to decide. - `content/docs/data-modeling/validation-rules.mdx` (the `expression` on a stored field paragraph) still says a sent value is "not reported as dropped", which has been false since #24690. That is a hand-written doc outside this card's surface, and it is noted in the report. Open PRs touching the same files were checked: #25072 and #24893 edit `engine.ts` in disjoint hunks (lines 4628–6300 and 9043–12250). No open PR touches `data-engine.zod.ts`, `contracts/data-engine.ts` or `readonly-strict-errors.ts`. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #25010
Built on PR #25032 (#25009), which has landed. This branch was cut from
claude/issue-25009-readable-ids-delegated-composeat2a8d0d5b4(the delegated composition and the ADR-0139 revision), so the commits up to2a8d0d5b4are PR #25032's; PR #25032 merged intomainasf8c5fb396and this branch then mergedorigin/main(one true merge,70d95b3e2, the one conflict inengine.tsresolved by keeping this card's hunks over the identical #25009 textmainnow carries).git diff origin/main...HEADis this card's diff alone: five files underpackages/objectql,packages/plugins/plugin-securityand.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
ReadableIdsleaf that sits INSIDE a composed read scope — the scopegetReadFilter(P)answers for a parent whose own read scope is derived from ITS master, a two-levelcontrolled_by_parentchain — is composed by the engine, recursively, instead of reaching the driver uncomposed. The plugin-security switch ofcontrolled_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.composeReadableIdsAtBoundarykeeps 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 newcomposeReadableIdsChain, 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,onBehalfOfintact), 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, oneReadableIdsChaincarries 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 thanREADABLE_IDS_MAX_CHAIN_DEPTH(8 scopes — the same cut as plugin-security'sCBP_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$incomparand, 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'swhere: the scope is thewhereof a read of its parent and is judged as one.readableIdsScopeOfand the closed doors are unchanged:canReadObjectfalse, 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.collectComposableReadableIdsgains an optionalatlabel (default:path) so the boundary can walk a composed scope withpath'where'(the position judgment: a scope IS thewhereof a read of its parent) while its sites and refusals readwhere.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):ObjectQLover better-sqlite3, the realSecurityPluginandSharingService, the chain child → PARENT (controlled_by_parent, master GRAND) → GRAND (private), the SAME rows read two ways — today's id-list path (acontrolled_by_parentchild, 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 sogetReadFilter(PARENT)answers the real scope with its pre-resolved master id list replaced byReadableIds.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,findandcountagree; 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/objectqlminor — 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)
2a8d0d5b4, before any engine edit): with the engine's two files restored tree-only to the base (blobs2879fdae7f2dandb5a9e1aa8d95verified on disk,composeReadableIdsChain0 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 HEADempty).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).findand one percount. Today's path on the same rig: oneselect id from GRAND, oneselect id from PARENT where grand in (?, …), then the child's statement binding the parent ids — three statements that read the chain perfind, none a subquery. (On this rig the realgetReadFilter(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 throughscripts/pm/os-verify-lock.sh, verdict read off its VERDICT line; a shared box, so the durations are not idle-box figures)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 tosrc/, 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 head9cee19a5dthe same two suites read 452 / 8523 and 201 / 4133 + 45 skipped, both exit 0.)pnpm --filter @objectstack/objectql typecheckexit 0 (tsc, andcheck: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 typecheckexit 0 (check:test-typecheck: OK — 0 file(s) / 0 error(s)).9cee19a5d(their only change since is the four-site cast move above): objectql nested + composition pinsTest Files 2 passed (2),Tests 39 passed (39); the real-stack nested pinsTests 9 passed (9). On the final head they ran inside the full suites above.test:repo(the vitestrepoproject) not run locally: CI'sTest Coreruns 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 head549f0bcc8:ok mutation landed: anchor 1 → 0, blob 51393862e428 → 999f76cdee36(on disk inside the leg: the recursion call 0 occurrences, the marker 1). Under it:engine-readable-ids-nested-composition.test.ts9 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.ts5 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 (51393862e428) and git diff HEAD is empty, the marker 0 occurrences after. (The same leg on the pre-merge head9cee19a5d, engine blob455faa95a5fb, gave the same two readings.) Both suites ran againstsrc/(objectql's own test imports./engine.js; the plugin-security suite aliases@objectstack/objectqltosrc/index.ts), so no dist rebuild was on the resolution path;git status --porcelainempty 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 UNRUNon549f0bcc8. All 72 run families exit 0, includingcheck:query-options-erasure(the ratchet holds at 236 test sites: an earlier revision of the objectql pins cast the options objectas anyat 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-loadsandpnpm check:i18n(both read every package'sdist/; this tree built the plugin-security closure only — CI'sBuild Coreand the lint legs carry them) andpnpm 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'sType Check · debt ledgercarries it).The derivation runs off the merge base with
origin/main, which after the merge ismain'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
applyFilters(the nested subquery above), and driver-memory resolves it throughconvertToMongoQuery; 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 refusesINTERNAL_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— becauseTursoDriver.toRemoteWhereresolves only the top-level composed leaves (replaceComposedReadableIdsdoes not descend into a composed leaf's scope) and hands the nested one to the transport throughtoRemoteFilter. The remedy is one line (resolve the scope throughtoRemoteWherewith 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.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.'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.维护者速读(草稿)
改了什么。 引擎的
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