Skip to content

Commit 0b8bce4

Browse files
objectstack-fleet[bot]os-billclaude
authored
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)
Fixes #24932 Clause-②: no Metadata-protocol: none ## What `controlled_by_parent` becomes the seventh consumer of the engine's `ReadableIds` leaf (ADR-0139 D6; ruling B `6303721609`; built as ruling Q1 C / Q2 A `6329027469` binds it, after #25144 / PR #25166 landed the boot-time refusal of the two master shapes the leaf cannot serve). - **plugin-security.** `computeControlledByParentFilter` answers `{ masterFK: { $in: ReadableIds.of(master) } }` for EVERY `controlled_by_parent` object 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 own `canReadObject` / `getReadFilter` of 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 past `CBP_MAX_CHAIN_DEPTH` keep 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 answers `null` for `update` / `delete`; a predicate write of a detail is narrowed to the rows the read door returns for the caller's own predicate (#21829, where the leaf composes) and judged per master by the 2.8 gate (#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. - **service-analytics (Q2 A).** The engine-bound merges — `ObjectQLStrategy.withReadScope`, `resolveFkAttr`, and the plugin's record-label pass (the third engine-bound merge of that family, the structural twin `resolveFkAttr`'s own comment names) — pass `admitReadableIds: true` to `assertReadScopeComparandsRunnable`. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries a leaf (`readScopeCarriesReadableIds`, the content-based decline the 2026-08-12 ruling on #7598 accepted), so the query routes to the ObjectQL strategy. `compileScopedFilterToSql` refuses a leaf-carrying scope first and by name, in its existing withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope. - **driver-turso.** `toRemoteWhere` resolves 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. - **spec.** One nested-leaf row in `READABLE_IDS_CASES` (every driver suite measures the shape), and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. Measured: the docblock sentence reaches no generated surface — `check:api-surface`, `check:docs` and the rest of spec's `check:*` ran green on this head with nothing regenerated. - **ADR-0055 (Tier H).** The status line gains the 2026-10-22 amendment (mechanism (b) and the set-size limit retired for `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's `What both halves do now — Read` paragraph and its `Cost` sentence. **ADR-0139 (Tier H).** D5 gains the dated seventh-consumer note worded "refuses at boot" (engine seat 1's pointer `6333147990`, 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 1. **The `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object 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. 2. **A delegated caller's detail now follows its master's delegated read.** `explain-enforce-parity`'s row `an agent on behalf of the org writer, a detail under someone else's master, update` moves from `RECORD_NOT_FOUND` / 404 to `PERMISSION_DENIED` / 403, and `controlled-by-parent-chain`'s delegated row (a hand-built two-human context) from `line_us` to `line_us, line_jp`. Cause: the engine composes the master's leaf with the delegated context, asking `getReadFilter(master)` — the ADR-0090 D10 spelling #25106 landed, the principal's leg at the ceiling's DECLARED depth (`org` when the ceiling declares none). The id-list derivation resolved that half at the ceiling's EFFECTIVE depth (`own` on 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. 3. **Bulk writes on a detail no longer carry the read derivation in their predicate** (ADR-0139 D1). For an addressed predicate write (`update` / `delete` with `multi`) the matched set is unchanged: #21829 narrows it to the rows the read door returns (through the leaf) and #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 #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 carried `masterFK 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. 4. **PR opened on the dispatch base `72a302a9d` without merging the six `main` commits 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 a `controlled_by_parent` master reference ADR-0055 §1 does not admit) is the one to watch on that run. ## Measurement conditions (the ruling's) 1. **PostgreSQL** — a local PostgreSQL 16 (`initdb` + `pg_ctl` as the `postgres` user, port 54329, torn down with this work). driver-sql's readable-ids conformance with `OS_TEST_POSTGRES_URL` set: `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-parity` together 3 files / 231 tests passed on `2b988305d`. 2. **Dashboard latency, HotCRM fixture** (`objectstack-ai/hotcrm` @ `c8a7a7047`: the six `controlled_by_parent` objects the card names plus `crm_campaign_member` and `crm_article_feedback`, which HotCRM declares since; masters `crm_account` / `crm_opportunity` / `crm_event` private, `crm_campaign` / `crm_knowledge_article` public_read; the chain `crm_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: | object | shape | today native (ms) | after engine (ms) | control engine+idlist (ms) | ratio after/today | |:--|:--|--:|--:|--:|--:| | crm_contact | count | 51.3 | 47.7 | 92.4 | 0.93 | | crm_contact | grouped | 39.2 | 28.8 | 49.1 | 0.74 | | crm_quote | count | 50.9 | 23.8 | 43.7 | 0.47 | | crm_quote | grouped | 33.0 | 24.8 | 42.3 | 0.75 | | crm_contract | count | 45.1 | 36.4 | 55.3 | 0.81 | | crm_contract | grouped | 31.0 | 30.7 | 41.3 | 0.99 | | crm_quote_line_item | count | 82.6 | 86.0 | 130.5 | 1.04 | | crm_quote_line_item | grouped | 89.7 | 71.6 | 142.3 | 0.80 | | crm_opportunity_line_item | count | 53.4 | 41.8 | 39.6 | 0.78 | | crm_opportunity_line_item | grouped | 34.5 | 27.4 | 44.0 | 0.79 | | crm_event_attendee | count | 32.7 | 38.7 | 55.1 | 1.18 | | crm_event_attendee | grouped | 42.2 | 39.5 | 44.4 | 0.93 | | crm_campaign_member | count | 39.1 | 13.2 | 72.3 | 0.34 | | crm_campaign_member | grouped | 40.5 | 17.2 | 68.0 | 0.43 | | crm_article_feedback | count | 48.7 | 21.4 | 57.3 | 0.44 | | crm_article_feedback | grouped | 41.5 | 19.2 | 53.8 | 0.46 | | **total, 16 widgets** | | **755.4** | **568.4** | **1031.5** | **0.75** | 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 (#24932). 3. **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. 4. The cloud seat's staging read of a two-level `controlled_by_parent` chain 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 when `OS_TEST_POSTGRES_URL` names one; both cells ran here), the real SecurityPlugin and SharingService, member contexts and the OAuth-assembled delegated context: - every caller shape keeps its row set — restricted (ownership + a share, both identities), unrestricted (`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; - statements per request: find = 1 statement reading the detail with the master inside its subquery and find + count = 2, for the plain, unrestricted and two-level shapes; zero statements read the master on its own; a delegated find is one statement carrying the master subquery twice; the two-level chain nests the subqueries; - past the dialect's variable limit: 33,000 masters (better-sqlite3 binds 32,766) — the detail answers 200 with exactly `bc1, bc2`, count 2, one statement binding the owner and no master id; - a metadata cycle and a chain past the depth bound answer zero rows, 200, reported (the log names the cycle and the bound), with no subquery statement; the cut is the engine's: eight derived objects compose into one statement with eight nested subqueries and serve the row, the ninth is the empty set; - ADR-0139 D1: a bulk update of the contacts by a caller lands, touching exactly the rows under the accounts it may read and edit; - the scope `getReadFilter` answers 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 through `readable-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 slot `ObjectQLPlugin.init` fills 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 asked `canReadObject` / `getReadFilter` of the master), and composes with the caller's own `where`; the native face declines in `canHandle` (direct context and the door's `readScopedObjects`) 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 withheld `READ_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": … } }`) keeps `INVALID_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 -- FILES` with the blob hash proven equal to BASE's, the pins run, `git checkout HEAD -- FILES` with the blob proven equal to HEAD's and `git diff HEAD` empty for the leg's files; a trap restores on any exit. BASE `72a302a9d`, HEAD `2b988305d`. - **security-plugin.ts at BASE** (the id-list derivation; on disk `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 variables` on SQLite), the one-statement pins, the leaf-shape pins (`getReadFilter` answers 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. - **the four analytics files at BASE** (admit count 2 → 0): 3 pins red — the engine-bound face refuses the leaf (`READ_SCOPE_COMPILE_FAILED`), `canHandle` answers true and the raw seam is called, the echo's compiler names no leaf. Restored. - **turso-driver.ts at BASE** (nested-resolve count 1 → 0): 1 pin red — the nested conformance row refuses on the remote face. 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 head `2b988305d`: `@objectstack/plugin-security` full suite 208 files / 4275 tests passed, 45 skipped; `@objectstack/service-analytics` 184 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 --commands` derived 102 families on `2b988305d`; 100 ran exit 0 and 2 are NOT MEASURED locally (`check:dual-build-cjs-loads`, `check:i18n`: PREREQUISITE NOT MET, both read every package's `dist/`; CI's `Lint & Repo Gates` runs them); `--ran` reconciliation: 102 accounted, 0 unrun. `check:nul-bytes` OK. 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 at `2b988305d`): - CI Test Core shard 5 reddened on `packages/core`'s repo pin `row-serving-door-exposure`, which reads any non-test source that calls `.canReadObject(` as a row-serving door, and found `readable-ids-in-memory.testkit.ts`. Reproduced locally (`pnpm --filter @objectstack/core run test:repo`: 1 failed / 81 passed). - Fixed by making the composer take the engine's two halves as injected predicates (`readAdmission`, `readScope`, ADR-0139 D2 steps 1 and 5), wired by each suite, so the testkit names no service member. On `35c4c3951`, `test:repo` is 6 files / 82 passed and the three rewired suites 3 files / 80 passed. CI on `35c4c3951`: 35 success, 7 skipped, every Test Core shard green. - Gate union rerun on `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). - Dogfood battery on `35c4c3951` under 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") and `packages/objectql/src/filter-comparand-shape.ts:109` ("This is the one caller that passes `admitReadableIds`") say the sentence this PR corrects in `filter-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. - ADR-0139 D6's clause "§2's mechanism (b) for `controlled_by_parent` stands, 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. - The cloud seat's staging read of a two-level chain on Turso remote (measurement condition 4) is owed at its pin move; the remote-face fix and its conformance row are in this PR. - The `/analytics/sql` echo refuses for `controlled_by_parent` objects (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.ts` is 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](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_ --------- Co-authored-by: os-bill <bill@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8010b64 commit 0b8bce4

29 files changed

Lines changed: 1788 additions & 359 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/service-analytics': patch
4+
'@objectstack/driver-turso': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
fix(plugin-security): a `controlled_by_parent` detail's read scope is the engine's `ReadableIds` leaf over its master — one statement per read, no master id list, 200 past the dialect's bind limit
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
- **`@objectstack/plugin-security`.** The read derivation for a `controlled_by_parent` object answers `{ masterFK: { $in: ReadableIds.of(master) } }` (ADR-0139 D6, the leaf's seventh consumer) in place of a master id list it pre-scanned under a system context and bound into `$in`. The engine composes the leaf at its executor boundary from this service's own `canReadObject` / `getReadFilter` of the master — recursively down a master chain — and the driver compiles one statement with the master inside a subquery. Measured before: 2 statements per find and 4 per find + count on a detail, and `DATABASE_ERROR` / 500 past ~32,800 masters (`too many SQL variables`). Now: 1 and 2, no scan of the master, and 200 with the right rows at 33,000 masters. Every row set is unchanged: plain, restricted, unrestricted (`viewAllRecords`), delegated (the ADR-0090 D10 intersection), two-level chains, null and dangling master pointers (still excluded), and a caller the master object does not admit (still nothing). A metadata cycle and a chain past the depth bound still answer zero rows, now from a registry-only walk that pre-empts the engine's own refusal. A write verb's predicate carries no leaf (ADR-0139 D1): a predicate write of a detail is narrowed by the read door's answer and judged per master, as before.
14+
- **`@objectstack/service-analytics`.** The engine-bound merges (`ObjectQLStrategy.withReadScope`, its FK attribute map, and the plugin's record-label pass) admit the leaf and hand the scope to the engine, which composes it as a find does. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries the leaf, so a `controlled_by_parent` object's analytics query routes to the ObjectQL strategy and serves the find path's rows. `compileScopedFilterToSql` refuses a scope carrying the leaf by name, in its existing `READ_SCOPE_COMPILE_FAILED` / 500 envelope: the `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object now refuses where it rendered the bound id list — it runs no composition and shows no statement it would not run. A filter an author writes keeps its judgement on every face.
15+
- **`@objectstack/driver-turso`.** The remote face resolves a composed leaf nested inside a composed leaf's scope through `toRemoteWhere` with the outer read's options, so a two-level `controlled_by_parent` chain on a remote Turso datasource answers rows instead of `INTERNAL_ERROR` / 500.
16+
- **`@objectstack/spec`.** One nested-leaf row in `READABLE_IDS_CASES`, so every driver's conformance suite measures the shape, and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. No schema, accept set or export of a metadata key moves.

‎docs/adr/0055-master-detail-controlled-by-parent.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
**Status**: Accepted (2026-06-19) — implemented in this PR (P0–P2) · **Amended** (2026-09-07, #11082 / PR #11183 — the "Single-level only in v1" limit under Consequences and the "Transitive nested master-detail chains" **Non-goal** are both **reversed**: since `@objectstack/plugin-security` 17.3.0 (merged 2026-08-23) the read derivation and the write gate compose `controlled_by_parent` across a chain, bounded by a cycle guard and a cost ceiling that both fail **closed**. Nothing else in this record changes — §1–§4, the mechanism choice, the set-size limit and the share-table non-goal all stand. See **"Amendment (2026-09-07, #11082): transitive chains compose — the single-level non-goal is reversed"** at the end.)
44
· **Revisited in part** (2026-10-10, [ADR-0139](./0139-engine-internal-semi-join-leaf-readable-parent-ids.md), Proposed) — §2's alternatives row (a) is reversed for ONE engine-internal, non-authorable leaf the engine composes from the compiler's output; the RLS compiler still has no subquery form, and §2's chosen mechanism (b), its `rlsMembership` IN-form and the set-size limit all stand.
5+
· **Amended** (2026-10-22, #24932, ruling B `6303721609` and Q1 C / Q2 A `6329027469`) — **§2's chosen mechanism (b) is retired for `controlled_by_parent`, and the set-size limit with it**: the read derivation (`packages/plugins/plugin-security/src/security-plugin.ts#computeControlledByParentFilter`) answers ADR-0139's `ReadableIds` leaf over the master, the engine composes it from this plugin's own `canReadObject` / `getReadFilter` of the master, and the driver compiles one statement with the master inside a subquery — the seventh consumer of that leaf, in the end-state form. No master id set is pre-resolved any more. The 2026-10-10 line above reads as of its date. What still stands: §1 (the one required `master_detail`), §3 (the write gate), the cycle guard and the depth bound (now the engine's own cut, judged first over the registry), and the RLS compiler's `rlsMembership` IN-form, which serves `current_user.<key>` memberships and never served this derivation. The limit that replaces the set-size ceiling is ADR-0139 D5's two master shapes, which the boot refuses (#25144).
56
**Deciders**: ObjectStack Protocol Architects
67
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs)
78
**Surfaced by**: an audit of master-detail permission semantics — `OWDModel.controlled_by_parent` is **declared but unenforced** (zero runtime consumers; not reachable through the object's `sharingModel` enum; the RLS compiler is relationship-blind). This is *false compliance* (ADR-0049) and *unproven liveness* (ADR-0054).
@@ -72,7 +73,9 @@ A `@objectstack/dogfood` RLS proof, bound in the liveness ledger on `object.shar
7273
- **Positive.** Master-detail finally carries permission inheritance (not just cascade/expand); a declared-but-dead OWD value becomes enforced with a permanent runtime guard; zero RLS-compiler changes (lowest-risk path through a security-critical subsystem); composes with tenant/owner/role RLS and analytics; the `OWDModel`↔`sharingModel` inconsistency is resolved.
7374
- **Negative / limits (honest).**
7475
- **Set-size ceiling.** A user with very many accessible masters produces a large `IN (...)`. Acceptable for typical cardinalities; **large-tenant scale (≫ thousands of masters) is a known limit** — a future share-table/join mechanism would replace the id list. v1 documents this, not solves it.
76+
⚠️ **Amended 2026-10-22 (#24932) — retired.** There is no id list and no `IN (...)` of master ids any more: the read derivation answers the engine's `ReadableIds` leaf and the driver compiles `masterFK IN (SELECT id FROM master WHERE …)`, so nothing is bound per master and the statement does not grow with the master table. The ceiling this bullet booked was measured before it went (`DATABASE_ERROR` / 500, `too many SQL variables`, past ~32,800 masters on SQLite; 200 with the right rows at 33,000 after). The bullet is kept because it was true for mechanism (b); ⛔ it is no longer a statement of a limit.
7577
- **Per-request resolution cost.** One extra master-id query per controlled_by_parent object per request (cached within the request).
78+
⚠️ **Amended 2026-10-22 (#24932) — retired, and corrected.** There is no extra query: the master is a subquery inside the detail's one statement (one per find, one per count). Before the switch the extra query ran **twice** per request — once for `find`, once more for `count` — and was never cached within the request; the parenthesis described an intent the code did not implement.
7679
- **Single-level only in v1.** Nested master-detail chains (a detail whose master is itself a detail) are **not** traversed transitively in v1.
7780
⚠️ **Amended 2026-09-07 — this limit was lifted on 2026-08-23** by #11082 (PR #11183), released in `@objectstack/plugin-security` 17.3.0. The bullet is kept rather than deleted because it was true, and correctly recorded, **for v1**; what changed is the version it describes. ⛔ It is no longer a statement of the **enforced boundary** — both halves walk the chain now. Read the Amendment at the end before deriving any invariant from this line.
7881

@@ -118,9 +121,11 @@ So a two-level chain was enforced at level one and open at level two, under meta
118121
### What both halves do now
119122

120123
- **Read.** `packages/plugins/plugin-security/src/security-plugin.ts#computeControlledByParentFilter` AND-composes the master's **own** `controlled_by_parent` derivation as a third layer, resolved through **that same method**, so the recursive answer cannot drift from the top-level one. The master set is therefore point-for-point equal to what a direct read of the master returns **at every level** — the equality §2 established for one.
124+
⚠️ **Amended 2026-10-22 (#24932).** The derivation no longer composes the master's layers itself: it answers the `ReadableIds` leaf over the master, and the **engine** composes the master's scope by asking this plugin's public doors — `canReadObject(master)` and `getReadFilter(master)`, the latter being this same derivation for a master that is itself `controlled_by_parent`, so the chain composes recursively, one nested subquery per hop, inside one statement (ADR-0139 D6 as amended). The equality holds for the same reason, one level up: the scope the engine composes IS the scope a direct read of the master runs under.
121125
- **Write.** `packages/plugins/plugin-security/src/security-plugin.ts#assertControlledByParentWrite` walks the chain hop by hop, running the same three master-edit legs — extracted verbatim as `packages/plugins/plugin-security/src/security-plugin.ts#assertMasterRowEditable` — on every hop until it reaches a master that governs its own rows. Each added refusal keeps the `403 PERMISSION_DENIED` envelope named for the **caller's own** object and operation, never for an ancestor the caller never asked about.
122126

123127
**Cost.** The per-request resolution cost booked under Consequences is now paid **per hop** — one extra master-id query per `controlled_by_parent` object on the chain, bounded as below.
128+
⚠️ **Amended 2026-10-22 (#24932).** No extra query per hop: each hop is one nested subquery inside the detail's one statement, and the depth bound caps the statement's nesting, not a count of queries. The write gate's per-hop master-row probe is unchanged.
124129

125130
### The two guards, and what the depth bound is NOT
126131

‎docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,21 @@ chosen up front, never reached by catching a refusal.
241241
(ADR-0057 §3.6, `packages/objectql/src/engine.ts#LIFECYCLE_DATASOURCE`): there the leaf serves consumer 1 nothing,
242242
and letter C's loud bound is what that deployment class has. Recorded as a limit, not solved.
243243

244+
**The seventh consumer routes nothing to a probe (2026-10-22, #24932 — the plugin-security switch, ruling Q1 C / Q2 A
245+
[`6329027469`](https://github.com/objectstack-ai/objectstack/issues/24932#issuecomment-6329027469)).**
246+
`controlled_by_parent`'s read derivation (`packages/plugins/plugin-security/src/security-plugin.ts#computeControlledByParentFilter`)
247+
answers the leaf for EVERY `controlled_by_parent` object, with no up-front routing to a bounded id list, because the two
248+
classes above cannot occur for its master: the boot **refuses** a `controlled_by_parent` detail whose master is bound to
249+
another datasource (D5.3) or narrowed by an object-scoped read middleware (D5.2) — `ObjectQLPlugin`, on
250+
`kernel:bootstrapped`, `INVALID_METADATA` / 422 ([#25144](https://github.com/objectstack-ai/objectstack/issues/25144),
251+
PR [#25166](https://github.com/objectstack-ai/objectstack/pull/25166)). "Refuses at boot" is the accurate form: the
252+
refusal judges the artifacts present at boot, and an artifact registered later (the `manifest` service,
253+
`metadata:reloaded`) is admitted, its detail then answering this record's D5 refusal at read, never a wrong row set; no
254+
public entry reaches that path today, and the engine lane re-measures it now that the leaf is written on every read. The
255+
plugin's own two guards stay: a metadata cycle and a chain past `CBP_MAX_CHAIN_DEPTH` answer zero rows from a
256+
registry-only walk, made before the leaf is written, so this record's cycle and depth refusals are never reached for a
257+
declared chain; the two bounds are one cut.
258+
244259
### D6 — What ADR-0055 and ADR-0056 now say
245260

246261
ADR-0055's row (a) is reversed **for this one leaf and nothing else**: the leaf is composed by the engine from the
@@ -249,7 +264,9 @@ IN-form and deliberate lack of a subquery form are unchanged
249264
(`packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler`, `#compileExpression`: a subquery shape compiles to
250265
`null` and fails closed). §2's mechanism (b) for
251266
`controlled_by_parent` stands, set-size ceiling included; whether that id set becomes a seventh consumer was ⛔ not
252-
decided here. ADR-0056's non-goal stands as written for the compiler and is revisited only as the engine-side leaf.
267+
decided here (as accepted; decided 2026-10-21 by ruling B, and landed 2026-10-22 by the plugin-security switch,
268+
#24932 — ADR-0055's own amendment of that date retires its mechanism (b) and the ceiling; D5's seventh-consumer note
269+
records the routing). ADR-0056's non-goal stands as written for the compiler and is revisited only as the engine-side leaf.
253270
Each record carries one pointer to this one, landed with this record — ADR-0055's status line, ADR-0056's non-goal
254271
bullet; nothing else in either moves.
255272

‎packages/drivers/driver-turso/src/turso-driver.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3388,14 +3388,24 @@ export class TursoDriver extends SqlDriver {
33883388
* TENANT)` and decides nothing about the wall, so the two faces answer one
33893389
* row set. An uncomposed leaf, or a composed one outside a `$in`, is refused
33903390
* first — INTERNAL_ERROR / 500, logged at `error` once per driver.
3391+
*
3392+
* [ADR-0139 D6 as amended — the nested master chain] A composed leaf's
3393+
* scope may itself carry a composed leaf (a `controlled_by_parent` master
3394+
* that is `controlled_by_parent` too), so the scope is resolved through
3395+
* THIS method, with the OUTER read's options: the nested parent's semi-join
3396+
* and its tenant wall are compiled the same way, one level down, and the
3397+
* transport nests the subqueries. `replaceComposedReadableIds` returns a
3398+
* composed leaf unentered, so without the recursion the nested leaf reached
3399+
* the transport unresolved and the remote face refused it (500) where the
3400+
* local face answered.
33913401
*/
33923402
private toRemoteWhere(object: string, where: unknown, options: DriverOptions | undefined): unknown {
33933403
const misplaced = findMisplacedReadableIds(where, true);
33943404
if (misplaced) throw refuseReadableIdsOnce(this.readableIdsDriverName, misplaced, this.logger, this.readableIdsRefusalLog);
33953405
const resolved = replaceComposedReadableIds(where, (leaf) => remoteReadableIdsSemiJoin({
33963406
object: leaf.object,
33973407
table: this.remoteTableFor(leaf.object, 'ReadableIds'),
3398-
scope: leaf.scope === undefined ? undefined : (this.toRemoteFilter(leaf.object, leaf.scope) as FilterCondition),
3408+
scope: leaf.scope === undefined ? undefined : (this.toRemoteWhere(leaf.object, leaf.scope, options) as FilterCondition),
33993409
tenant: this.remoteTenantScope(leaf.object, options),
34003410
}));
34013411
return this.toRemoteFilter(object, resolved);

‎packages/plugins/plugin-security/src/check-only-write-scope.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import type { PermissionSet } from '@objectstack/spec/security';
6363
import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system';
6464
import { SecurityPlugin } from './security-plugin.js';
6565
import { defaultPermissionSets } from './objects/default-permission-sets.js';
66+
import { composeReadableIdsInMemory } from './readable-ids-in-memory.testkit.js';
6667

6768
// ── metadata: the showcase invoice graph, exactly as it ships ──────────────
6869

@@ -446,7 +447,15 @@ async function makeStack(opts: { orgScoping?: boolean } = {}): Promise<Stack> {
446447
let result: any[] = [];
447448
await securityMw(opCtx, async () => {
448449
await sharingMw(opCtx, async () => {
449-
result = await engine.find(object, { where: opCtx.ast.where });
450+
// [ADR-0139 D6] A controlled_by_parent read carries the engine's
451+
// leaf over the master; this double is no engine, so the composition
452+
// is done for it (`readable-ids-in-memory.testkit.ts`).
453+
const where = await composeReadableIdsInMemory(opCtx.ast.where, opCtx.context, {
454+
rows: (o) => engine._tables[o],
455+
readAdmission: (o, c) => security.canReadObject(o, c),
456+
readScope: (o, c) => security.getReadFilter(o, c),
457+
});
458+
result = await engine.find(object, { where });
450459
});
451460
});
452461
return result;

‎packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { defaultPermissionSets } from './objects/default-permission-sets.js';
6767
import { SysUserPosition } from './objects/sys-user-position.object.js';
6868
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
6969
import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js';
70+
import { wireEngineSecuritySource } from './engine-security-source.testkit.js';
7071

7172
const SYS = { context: { isSystem: true } } as never;
7273
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
@@ -281,6 +282,8 @@ beforeAll(async () => {
281282
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
282283
await plugin.init(ctx as never);
283284
await plugin.start(ctx as never);
285+
// The engine's security source, as `ObjectQLPlugin.init` wires it on a kernel (ADR-0139 D2).
286+
wireEngineSecuritySource(engine, () => plugin);
284287
sharing = new SharingService({ engine: engine as never, securityService: () => security as never });
285288
engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' });
286289
});

0 commit comments

Comments
 (0)