Repository navigation
fix(plugin-security): a controlled_by_parent detail's read scope is the engine's ReadableIds leaf over its master (ADR-0139 D6, seventh consumer); Q2 A in service-analytics; the Turso remote nested leaf - #25198
Conversation
…af (ADR-0139 D6, seventh consumer)
The read derivation answers `{ masterFK: { $in: ReadableIds.of(master) } }`
for every controlled_by_parent object on a read verb, nothing on a write
verb's predicate (ADR-0139 D1); the cycle and depth guards become a
registry-only walk that pre-empts the engine's own refusals. In-memory
composer testkit for the fake-engine suites, and the real-stack pin suite.
Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…c row and docblock, the ADR amendments, the changeset Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…-stack rigs, compose the leaf in the fake-engine doubles, flip the pins the switch moves Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…fiers; a caller's scope is read under the caller's context; the delegated detail follows its master's delegated read Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 150 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 34086270535c5be27c6daa03a8ac97ca0cf0781c && git checkout 34086270535c5be27c6daa03a8ac97ca0cf0781c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2919ddb875cd0daa38ef452d741bcba04cf366af 35c4c3951de7696308d6ef6acc49a8eda6647100 && git checkout -B drift-repro 2919ddb875cd0daa38ef452d741bcba04cf366af && git merge --no-ff 35c4c3951de7696308d6ef6acc49a8eda6647100
node scripts/docs-audit/affected-docs.mjs --json 2919ddb875cd0daa38ef452d741bcba04cf366af
|
…n and the read scope as injected predicates, naming no service member The repo pin row-serving-door-exposure (packages/core, Test Core shard 5) reads any non-test source that calls `.canReadObject(` as a row-serving door to classify; the testkit is no door. It now receives the engine's two halves (ADR-0139 D2 steps 1 and 5) from the suite that wires the real service, and names neither member itself. Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed at ① Derived judgments(a) The four call sites answer the leaf — right. (b) Cycle and over-depth — right, and the cut is the engine's. (c) A write verb's predicate carries no leaf (ADR-0139 D1) — right; no caller-addressable write path loses the master check. (d) Q2 A in (e) Turso — right, nothing else moved. Public surface. No metadata key, accepted value, export or default moves: ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对
Generated by Claude Code |
|
Merge-queue ejection, read · Failing check: Why it is not this PR's:
Fix: none is needed in the PR. Its head stays Generated by Claude Code |
…table-master suite after #25198 The detail read is now the engine's ReadableIds leaf over its master, so the rig fills the engine's security-source slot as a kernel does. The read-access sentence in the error docblock and the changeset names the security plugin, not the write gate. Claude-Session: https://claude.ai/code/session_01LQG7z3CxDLtiMaHLbTFSeB Co-authored-by: Claude <noreply@anthropic.com>
Fixes #24932
Clause-②: no
Metadata-protocol: none
What
controlled_by_parentbecomes the seventh consumer of the engine'sReadableIdsleaf (ADR-0139 D6; ruling B6303721609; built as ruling Q1 C / Q2 A6329027469binds it, after #25144 / PR #25166 landed the boot-time refusal of the two master shapes the leaf cannot serve).computeControlledByParentFilteranswers{ masterFK: { $in: ReadableIds.of(master) } }for EVERYcontrolled_by_parentobject on a read verb, with no up-front routing to an id list. The engine composes the leaf at its executor boundary from this plugin's owncanReadObject/getReadFilterof the master, recursively down a master chain, and the driver runs ONE statement with the master inside a subquery. The four call sites (the middleware's principal and delegator halves,getReadFilter,computeDelegatedReadFilter) answer the leaf. The cycle guard and the depth bound stay and become a registry-only walk (controlledByParentChainRefusal) made BEFORE the leaf is written: a metadata cycle and a chain pastCBP_MAX_CHAIN_DEPTHkeep answering zero rows ($in: []), and the cut is the engine's own (READABLE_IDS_MAX_CHAIN_DEPTH), so the engine's refusals are never reached for a declared chain. A write verb's predicate carries no leaf (ADR-0139 D1): the derivation answersnullforupdate/delete; a predicate write of a detail is narrowed to the rows the read door returns for the caller's own predicate (security(data): a predicate-scoped update or delete is refused when its predicate matches only rows the caller cannot read, and succeeds with zero rows when it matches nothing: an existence signal the read door withholds #21829, where the leaf composes) and judged per master by the 2.8 gate (plugin-security: one write path to a controlled_by_parent detail does not ask for edit access on its master, where ADR-0055 §3 requires it (reach measured; detail withheld under the disclosure ruling) #25012, a verdict, never a scope). The delegator half writes the same leaf, so a delegated read carries one subquery twice: correct rows, left as the ruling's execution parameter says.ObjectQLStrategy.withReadScope,resolveFkAttr, and the plugin's record-label pass (the third engine-bound merge of that family, the structural twinresolveFkAttr's own comment names) — passadmitReadableIds: truetoassertReadScopeComparandsRunnable.NativeSQLStrategy.canHandledeclines a query whose base or joined object's read scope carries a leaf (readScopeCarriesReadableIds, the content-based decline the 2026-08-12 ruling on [spec]service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 accepted), so the query routes to the ObjectQL strategy.compileScopedFilterToSqlrefuses a leaf-carrying scope first and by name, in its existing withheldREAD_SCOPE_COMPILE_FAILED/ 500 envelope.toRemoteWhereresolves a composed leaf's scope through itself with the outer read's options, so a nested composed leaf (a two-level chain) resolves on the remote face instead of reaching the transport unresolved.READABLE_IDS_CASES(every driver suite measures the shape), and the comparand-shape door's docblock names both callers that passadmitReadableIds. Measured: the docblock sentence reaches no generated surface —check:api-surface,check:docsand the rest of spec'scheck:*ran green on this head with nothing regenerated.controlled_by_parent); the Consequences bullets:74(set-size ceiling) and:75(per-request cost, "cached within the request" — it never was, and it ran twice) each carry a dated correction; so do the amendment'sWhat both halves do now — Readparagraph and itsCostsentence. ADR-0139 (Tier H). D5 gains the dated seventh-consumer note worded "refuses at boot" (engine seat 1's pointer6333147990, the late-registration limit included); D6's sentence "whether that id set becomes a seventh consumer was ⛔ not decided here" gains a dated parenthetical pointing at ADR-0055's amendment and D5's note. That D6 parenthetical is beside the ruling's named sentences, added because this PR falsifies the clause next to it; the seat may strike it.Deviations, named
/api/v1/analytics/sqldry-run echo of acontrolled_by_parentobject refuses (READ_SCOPE_COMPILE_FAILED/ 500, withheld) where it rendered the bound id list. The one visible change, on a debugging aid, as ruling ⑤ names it.explain-enforce-parity's rowan agent on behalf of the org writer, a detail under someone else's master, updatemoves fromRECORD_NOT_FOUND/ 404 toPERMISSION_DENIED/ 403, andcontrolled-by-parent-chain's delegated row (a hand-built two-human context) fromline_ustoline_us, line_jp. Cause: the engine composes the master's leaf with the delegated context, askinggetReadFilter(master)— the ADR-0090 D10 spelling plugin-security: the raw-SQL read-scope provider (getReadFilter) does not apply the delegated organization-membership rule PR #25101 enforces at the operation prologue; measure reach first (detail withheld) #25106 landed, the principal's leg at the ceiling's DECLARED depth (orgwhen the ceiling declares none). The id-list derivation resolved that half at the ceiling's EFFECTIVE depth (ownon a private master), so a detail was absent to a caller who could read its master: the parity row's sibling,p_theirs(the master shape), already answered 403 for the same caller. The detail now reads exactly the rows a delegated direct read of its master reads (ADR-0139 D5.1 as amended, ADR-0055 §2's equality). No row is served that the same caller could not already read on the master; the write is still refused at the master-edit gate. Reported here and in the dev report as a measured delta, with the recommendation to accept it; keeping the old narrower answer would need a second spelling of the master's delegated scope, which ruling B ③ refuses.update/deletewithmulti) the matched set is unchanged: security(data): a predicate-scoped update or delete is refused when its predicate matches only rows the caller cannot read, and succeeds with zero rows when it matches nothing: an existence signal the read door withholds #21829 narrows it to the rows the read door returns (through the leaf) and plugin-security: one write path to a controlled_by_parent detail does not ask for edit access on its master, where ADR-0055 §3 requires it (reach measured; detail withheld under the disclosure ruling) #25012 judges every master; pinned on the real stack (a bulk update by a caller touches exactly the rows under the masters it may read and edit). The two security(data): a predicate-scoped update or delete is refused when its predicate matches only rows the caller cannot read, and succeeds with zero rows when it matches nothing: an existence signal the read door withholds #21829 stand-down shapes — a nested operation and the engine's referential field clear — carry the RLS write filter only, where before this PR they also carriedmasterFK IN (readable master ids). NOT MEASURED at run time: those two shapes are engine-internal writes (no caller addresses them); named here so the seat can ask for a measurement if it wants one.72a302a9dwithout merging the sixmaincommits that landed meanwhile (b6e870707..14e7239e7): none of their 71 files is among this branch's 29, and the queue's merge-ref run validates the merge;02ad31a0c(the spec parse refusing acontrolled_by_parentmaster reference ADR-0055 §1 does not admit) is the one to watch on that run.Measurement conditions (the ruling's)
PostgreSQL — a local PostgreSQL 16 (
initdb+pg_ctlas thepostgresuser, port 54329, torn down with this work). driver-sql's readable-ids conformance withOS_TEST_POSTGRES_URLset:live postgres RAN, 36 passed / 1 skipped (the suite's own MySQL carve-out), the nested row included. The switch's own rig ran its PostgreSQL cell too (on postgres, every pin below: row sets, statements with double-quoted identifiers, the 33,000-master read, the guards):controlled-by-parent-leaf-switch,controlled-by-parent-master-object-read,explain-enforce-paritytogether 3 files / 231 tests passed on2b988305d.Dashboard latency, HotCRM fixture (
objectstack-ai/hotcrm@c8a7a7047: the sixcontrolled_by_parentobjects the card names pluscrm_campaign_memberandcrm_article_feedback, which HotCRM declares since; masterscrm_account/crm_opportunity/crm_eventprivate,crm_campaign/crm_knowledge_articlepublic_read; the chaincrm_quote_line_item → crm_quote → crm_account). Harness: the real engine, SecurityPlugin and SharingService; SCALE = 10,000 masters per master object (the caller owns every even one), 2 details each; 16 widgets = count and grouped-by-status per object, each run 7 times after 2 warm-ups, medians. Three cells: today's native face (NativeSQLStrategy over the pre-resolved id list, the pre-scan timed inside the cell as the retired derivation ran it), the engine face after the switch (the native strategy declines, the ObjectQL strategy composes the leaf), and a control (the engine face over the id list). SQLite, shared box:Every cell answered the same counts. "Material" was judged as a widget whose after/today median ratio exceeds 1.5 or whose total exceeds today's by more than 20%; the two widgets above 1.0 (1.04, 1.18) sit inside their own max spread (82.6 → max 138.2; 32.7 → max 52.3), the total is 25% faster, and the control shows the gain is the subquery, not the routing. No regression to report. PostgreSQL (same harness, after the battery): total 1101.4 ms today → 510.0 ms after (ratio 0.46, control engine+idlist 1432.1 ms), identical counts; per-widget 0.20–1.60. Two count widgets trip the per-widget threshold stated above: crm_contact count 32.7 → 50.5 ms (1.54) and crm_quote count 33.9 → 54.4 ms (1.60), one-hop details of the private crm_account. Their grouped twins are 1.19 and 0.38, and the absolute +18–20 ms sits inside the box's own max spread (40.7 / 44.4 today vs 52.7 / 56.5 after). The control cell shows the cost is the engine face's routing on a PG COUNT, which the leaf recovers most of. This is reported as the ruling asks; the materiality call is put to the maintainer (plugin-security: a controlled_by_parent child's read loads every readable master id with no limit (twice per request), so its CPU grows with the master table and the read answers 500 "too many SQL variables" past ~32.8k masters #24932).
Turso remote nested leaf — landed with the switch; the conformance row measures it on both faces (27 passed), and the ablation below shows the remote face refusing without the one line.
The cloud seat's staging read of a two-level
controlled_by_parentchain at its pin move: not this PR's; named here as owed.Pins (every one on the real stack unless named as a double)
packages/plugins/plugin-security/src/controlled-by-parent-leaf-switch.test.ts— ObjectQL over better-sqlite3 (and over live PostgreSQL whenOS_TEST_POSTGRES_URLnames one; both cells ran here), the real SecurityPlugin and SharingService, member contexts and the OAuth-assembled delegated context:viewAllRecords: every contact whose account exists, never the null or dangling pointer), child-only (canReadObject(master)false ⇒ no row, count 0), delegated (the ADR-0090 D10 intersection), the two-level chain (line ⊂ quote ⊂ account, for every caller), a system control;bc1, bc2, count 2, one statement binding the owner and no master id;getReadFilteranswers carries the branded leaf — one for a direct caller, one per leg for a delegated one, the leaf over ITS master for a derived master, and still the leaf for a caller the master object does not admit (the engine answers it$in: []).readable-ids-nested-compose.test.ts(reworked) — the stand-in wrapper that spelled the switch is retired; the switch's path and a consumer's leaf serve the same rows, one statement each, no pre-scan of the parent or the grandparent at either level;getReadFilter(PARENT)carries the leaf over GRAND, one per leg.controlled-by-parent-chain.test.ts,controlled-by-parent-sharing.test.ts,check-only-write-scope.test.ts(engine doubles) — evaluate the injected filter throughreadable-ids-in-memory.testkit.ts, the engine's five composition steps over the double's rows with the real service answering the master; their row-set pins stand unchanged except the two named under Deviations, and the two master-scan pins flip to "the derivation does no store work; the grant table is read when the master's scope is composed".Ten real-stack rigs gain
wireEngineSecuritySource(engine, () => plugin)(engine-security-source.testkit.ts), the slotObjectQLPlugin.initfills on a kernel: without it an engine composes every leaf$in: [], and those suites' detail reads and by-id write probes saw no row (89 reds on the first run, all one cause).packages/services/service-analytics/src/__tests__/controlled-by-parent-leaf-three-faces.test.ts— a leaf-carrying scope on the three faces: the engine-bound face composes it and answers the find path's rows (the security service is askedcanReadObject/getReadFilterof the master), and composes with the caller's ownwhere; the native face declines incanHandle(direct context and the door'sreadScopedObjects) and through the service the raw-SQL seam is never called; a scope with no leaf is still served natively (the decline is content-based); the echo refuses in the withheldREAD_SCOPE_COMPILE_FAILED/ 500 envelope before any statement, the compiler naming the leaf and the object for the log; an authored look-alike ({ "$in": { "object": … } }) keepsINVALID_FILTER/ 400 on every face with or without the leaf-carrying scope; the deny sentinel keeps serving nothing on the engine face and is still served natively.packages/spec/src/data/readable-ids-conformance.ts— the nested row runs in every driver suite: driver-sql (sqlite and live PostgreSQL), driver-turso (local and remote), driver-memory, driver-sqlite-wasm, driver-mongodb (refusing), and spec's own.Ablations (negative pins put back to the old behaviour, red, restored, restore proven)
scripts/pm-free legs, each:git restore --source=BASE -- FILESwith the blob hash proven equal to BASE's, the pins run,git checkout HEAD -- FILESwith the blob proven equal to HEAD's andgit diff HEADempty for the leg's files; a trap restores on any exit. BASE72a302a9d, HEAD2b988305d.ReadableIds.of(rel.master)count 1 → 0): 30 pins red — on the SQLite and the PostgreSQL cells: every statements-per-request pin (the find reads the master on its own), the 33,000-master read (DATABASE_ERROR/ 500,too many SQL variableson SQLite), the one-statement pins, the leaf-shape pins (getReadFilteranswers bound ids), the engine's-cut pin; in the reworked suites the "no store work", "the scope carries the leaf", nested-compose "one statement on both paths" and the delegated-row pins. Restored: blob == HEAD, tree clean.READ_SCOPE_COMPILE_FAILED),canHandleanswers true and the raw seam is called, the echo's compiler names no leaf. Restored.Tests and gates
All under
scripts/pm/os-verify-lock.sh(shared box; the lock's VERDICT lines are in the dev report). On head2b988305d:@objectstack/plugin-securityfull suite 208 files / 4275 tests passed, 45 skipped;@objectstack/service-analytics184 files / 4505 passed, 262 skipped; typechecks of plugin-security, service-analytics and driver-turso exit 0; readable-ids conformance: driver-sql sqlite 18 passed / 2 skipped and live PostgreSQL 36 passed / 1 skipped, driver-turso 27, driver-memory 15, driver-sqlite-wasm 11, driver-mongodb 13 (refusing), spec 48; the dependency closure built (22 tasks). Gates:dispatch-gates --commandsderived 102 families on2b988305d; 100 ran exit 0 and 2 are NOT MEASURED locally (check:dual-build-cjs-loads,check:i18n: PREREQUISITE NOT MET, both read every package'sdist/; CI'sLint & Repo Gatesruns them);--ranreconciliation: 102 accounted, 0 unrun.check:nul-bytesOK. The full dogfood battery is owed under the lock and reported in the dev report.Final head
35c4c3951(one test-only commit after the PR opened at2b988305d):packages/core's repo pinrow-serving-door-exposure, which reads any non-test source that calls.canReadObject(as a row-serving door, and foundreadable-ids-in-memory.testkit.ts. Reproduced locally (pnpm --filter @objectstack/core run test:repo: 1 failed / 81 passed).readAdmission,readScope, ADR-0139 D2 steps 1 and 5), wired by each suite, so the testkit names no service member. On35c4c3951,test:repois 6 files / 82 passed and the three rewired suites 3 files / 80 passed. CI on35c4c3951: 35 success, 7 skipped, every Test Core shard green.35c4c3951: 102 derived, 102 run exit 0, 0 NOT MEASURED (the two PREREQUISITE-NOT-MET families of the first run passed once every dist was built).35c4c3951under the lock:Test Files 287 passed | 3 skipped,Tests 2395 passed | 18 skipped,VERDICT command-exit 0(held 47m47s).Acceptance notes
packages/spec/src/data/readable-ids.ts:50-53("The engine's filter seam is the one caller that passes it") andpackages/objectql/src/filter-comparand-shape.ts:109("This is the one caller that passesadmitReadableIds") say the sentence this PR corrects infilter-comparand-shape.ts's docblock; both are outside the claim's bound (the first is a second spec file, the second is objectql). Prose only; carrier: the seat.controlled_by_parentstands, set-size ceiling included" is false since this PR; the dated parenthetical added beside it points at ADR-0055's amendment rather than rewriting the accepted sentence. Tier H; carrier: the seat./analytics/sqlecho refuses forcontrolled_by_parentobjects (Deviation 1); if the maintainer wants the echo to render for them, that is the raw-SQL compiler learning the engine's composition (ruling's option B for Q2), a separate card.readable-ids-in-memory.testkit.tsis a test double of the engine's five composition steps for the fake-engine suites; parity with the engine's own composition is pinned on the real stack in this package and in objectql's suites. If a later change to the engine's composition is meant to reach those suites, the testkit must move with it.维护者速读(草稿)
改了什么: 主从对象(
controlled_by_parent)的读取范围不再预先把「能读的主记录 id 列表」查出来再绑进 SQL,而是交给引擎用子查询(ADR-0139 的ReadableIds叶子)在一条语句里完成;分析报表的原生 SQL 通道遇到这种范围主动让给引擎通道,/analytics/sql试运行回显对主从对象改为响亮拒绝;Turso 远程端补上两级链;ADR-0055 与 ADR-0139 按裁决逐句更正。为什么改: HotCRM 规模下主记录超过约 3.3 万条时,子对象读取直接 500(SQL 变量数超限),且每次请求扫两遍主表。切换后一条语句、不随主表增长,实测 3.3 万主记录返回 200 且行集正确;16 个仪表盘小组件在 SQLite 上总耗时为切换前的 75%。
风险与代价(含回滚): 行集实测不变(普通、受限、全部可见、两级链、空外键、悬空外键、无主对象读权限);一处代理读取的行集按已裁定的「子对象跟随主对象的代理读取」变宽(上文 Deviation 2,该主记录本来就对该调用者可见),请你拍板是否接受;唯一可见变化是试运行回显改为拒绝。回滚是一次 revert;#25144 的启动期拒绝独立成立。
席位意见: (留空)
你要做的: 本 PR 触及
docs/adr/**(Tier H),需要你的批准后由席位落地;并请对 Deviation 2 表态。Generated by Claude Code