Skip to content

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

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24932-cbp-leaf-switch
Oct 22, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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 (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.
  • 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 [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. 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 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 (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: 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 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 (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).

  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

os-bill and others added 4 commits October 22, 2026 06:53
…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>
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/driver-turso, @objectstack/plugin-security, @objectstack/service-analytics, @objectstack/spec, touching 32 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/filter-comparand-shape.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2919ddb875cd0daa38ef452d741bcba04cf366af.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/filter-comparand-shape.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 150 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2919ddb875cd0daa38ef452d741bcba04cf366af → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 34086270535c5be27c6daa03a8ac97ca0cf0781c — the merge of head 35c4c3951de7696308d6ef6acc49a8eda6647100 into base 2919ddb875cd0daa38ef452d741bcba04cf366af, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2919ddb875cd0daa38ef452d741bcba04cf366af → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 35c4c3951de7696308d6ef6acc49a8eda6647100
Local-runs: none
Scope: packages/spec/src/data/filter-comparand-shape.ts, packages/spec/src/data/readable-ids-conformance.ts, docs/adr/0055-master-detail-controlled-by-parent.md, docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md, packages/plugins/plugin-security/src/security-plugin.ts, packages/services/service-analytics/src/plugin.ts, packages/services/service-analytics/src/read-scope-sql.ts, packages/services/service-analytics/src/strategies/native-sql-strategy.ts, packages/services/service-analytics/src/strategies/objectql-strategy.ts, packages/drivers/driver-turso/src/turso-driver.ts, .changeset/24932-cbp-leaf-switch.md, packages/plugins/plugin-security/src/check-only-write-scope.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-chain.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-every-master-edit.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-every-row-master.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-leaf-switch.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-master-object-read.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-master-object-write.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts, packages/plugins/plugin-security/src/controlled-by-parent-write-shape-master-edit.test.ts, packages/plugins/plugin-security/src/delegated-write-never-exceeds-delegator.test.ts, packages/plugins/plugin-security/src/engine-security-source.testkit.ts, packages/plugins/plugin-security/src/explain-controlled-by-parent-master-modify-all.test.ts, packages/plugins/plugin-security/src/explain-enforce-parity.test.ts, packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts, packages/plugins/plugin-security/src/readable-ids-in-memory.testkit.ts, packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts, packages/services/service-analytics/src/tests/controlled-by-parent-leaf-three-faces.test.ts + packages/objectql/src/engine.ts, packages/objectql/src/plugin.ts, packages/objectql/src/filter-comparand-shape.ts, packages/spec/src/data/readable-ids.ts

Reviewed at CONTRACT_REVIEW_TIER on the narrowing face, as the dispatch asked: a security read-scope switch. Inputs: card #24932 (body and all 19 comments, including ruling B 6303721609, ruling Q1 C / Q2 A 6329027469, the dev reports 6318648840 and 6340537042, the seat's decision 6340633145, the ruling 6342223675, engine seat 2's note 6309967905, engine seat 1's pointer 6333147990, the claims), the cross-lane declarations 6334266488 (#6367) and 6334275915 (#6017), the PR body, its file list, the net diff against main at this head (29 files, +1788 / −359; merge base 72a302a9d, the dispatch base), and the head's check-runs. The cited engine code was read at this head (the PR does not touch it). Check-runs on the head: 40 success, 9 skipped, 0 failure; the five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) are all success, every Test Core shard included.

① Derived judgments

(a) The four call sites answer the leaf — right. computeControlledByParentFilter (security-plugin.ts:8860) is now synchronous and registry-only: null off a non-controlled_by_parent object, a system or user-less context, or a write verb; the deny sentinel by reference for an unresolvable relation; { fk: { $in: [] } } for a cycle or over-depth chain; otherwise { fk: { $in: ReadableIds.of(rel.master) } }. No up-front routing to an id list remains: the system-context ql.find(rel.master, { fields: ['id'] }) scan is gone, and the three fields: ['id'] reads left in the file (:5927, :7600, :9753) are the authored-row write probe, the #21829 predicate-write narrowing and the #25012 per-master collection, none of them the derivation. All four call sites take the leaf: the middleware's principal half (:4488) and delegator half (:4507), getReadFilter (:6064, with 'find') and computeDelegatedReadFilter (:6179 and :6181, both legs). Row sets: the engine composes the leaf at its executor boundary (engine.ts#composeReadableIdsAtBoundary, :6420) from canReadObject(master) and getReadFilter(master) asked of this same plugin with the caller's context as it stands (readableIdsScopeOf, :6640), and the driver adds the tenant wall (D3). That is exactly the set the retired derivation composed by hand (object admission, RLS with tenant Layer 0, sharing, the master's own derivation), so the plain, restricted, unrestricted (viewAllRecords), null-FK, dangling-FK and master-object-unadmitted callers keep their rows by construction and by the real-stack pins in controlled-by-parent-leaf-switch.test.ts (SQLite and the PostgreSQL cell). The one row-set move is the fork-1 delegated row the ruling accepted: the engine asks getReadFilter(master) with the delegated context, which is computeDelegatedReadFilter(master), the six layers the middleware composes for a delegated find of the master (principal RLS and leaf, delegator RLS and leaf, both sharing legs, the #25106 organization refusal mirrored), under canReadObject(master) for the same context. The detail's composed scope is therefore a subset of the master's delegated direct read, so no detail row is served that the caller cannot read on its master through a delegated direct read; the parity row (d_under_theirs 404 → 403, beside p_theirs already 403) and the chain row (line_us → line_us, line_jp, line_own still out) read as the pins say. The delegator half writes the same leaf, so a delegated read carries the subquery twice; the engine composes the master once and reuses it by reference, rows correct, as the ruling's execution parameter leaves it.

(b) Cycle and over-depth — right, and the cut is the engine's. controlledByParentChainRefusal (:8925) walks the registry alone (getSchema, resolveCbpRelation; no store work, no security call) before the leaf is written, and both guards answer $in: [] with the error log naming the chain: zero rows, 200, never the engine's INTERNAL_ERROR / 500. The cut: the plugin refuses when the read's object plus seven derived masters are followed by an eighth derived master (chain.length reaches CBP_MAX_CHAIN_DEPTH = 8 with the next master still derived); the engine refuses a ninth nested scope (composeReadableIdsChain, ancestors.length of 8 against READABLE_IDS_MAX_CHAIN_DEPTH = 8, engine.ts:6510). Both compose eight nested subqueries and refuse the ninth, so the plugin's walk pre-empts the engine's refusal on every declared chain, at every level (each master's own getReadFilter re-walks from itself). The real-stack pins hold it: a cycle and an over-depth chain answer zero rows with no IN (SELECT statement, and the eight-deep chain serves its row in one statement with eight nested subqueries. The two constants live in two packages and are equal by comment and by that real-stack pin, not by a shared export; the pin reddens on either moving, which is enough.

(c) A write verb's predicate carries no leaf (ADR-0139 D1) — right; no caller-addressable write path loses the master check. CBP_LEAF_READ_VERBS (:562) answers null for update / delete, and the engine itself refuses a leaf in a write's predicate at the executor's entry (collectComposableReadableIds(object, 'update' | 'delete', …), engine.ts:17659 and :20890), so the plugin could not have written one. The master check on the caller-addressable shapes: a by-id write is judged at the 2.8 gate (assertControlledByParentWrite, called at :3276 and :3286 for every write verb, not gated on the stand-down flags), which reads the row and walks the master chain; the predicate update or delete the caller addressed is narrowed to the rows the read door returns for the caller's own predicate (readableRowsScopeForPredicateWrite, :7585, a find under the caller's context where the leaf composes) and judged per master by #25012 (controlledByParentPredicateWriteMasters). Deviation 3, accepted by ruling 6342223675 ③: the two #21829 stand-down shapes (nestedInOperation, :2432, a cascade or a hook's own write; __referentialFieldClear, :2872, the engine's integrity write) are excluded from the read narrowing by addressedPredicateWrite (:7547) and so now carry the RLS write filter only where they also carried masterFK IN (readable master ids); the 2.8 gate still judges them per master over the rows the caller can read. Neither shape is addressed by a caller; the run-time measurement stays unmeasured, as the PR names.

(d) Q2 A in service-analytics — right. admitReadableIds: true is passed at exactly three sites, all engine-bound and all on the scope argument alone: ObjectQLStrategy.withReadScope (:905), resolveFkAttr (:1430) and the plugin's record-label pass (plugin.ts:1081, whose $and hands the scope to executeAggregate). compileScopedFilterToSql passes nothing and refuses a leaf-carrying scope first and by name (findReadableIdsLeaf, read-scope-sql.ts:835), in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope, which is the named echo deviation. NativeSQLStrategy.canHandle declines through readScopeCarriesReadableIds over the same object set the other content-based declines read (readScopedObjects, else the cube's base and declared joins), and applyReadScope compiles per object over that same set, so no compile site is reached that the decline did not see. An author's filter never meets the option: the caller's where goes through the engine's own door, the brand is a symbol key no JSON carries, and the three-faces suite pins the unbranded look-alike at INVALID_FILTER / 400 on every face with and without the leaf-carrying scope, the deny sentinel still serving nothing. The admission is for the scope getReadScope answers and nothing else.

(e) Turso — right, nothing else moved. toRemoteWhere (turso-driver.ts:3400) resolves a composed leaf's scope through itself with the outer read's options, so a nested composed leaf meets replaceComposedReadableIds, remoteReadableIdsSemiJoin and remoteTenantScope one level down and reaches the transport resolved; the only other change in the file is the docblock. The spec's new READABLE_IDS_CASES row measures the nested shape in every driver suite (the dev's ablation reddened it on the remote face alone).

Public surface. No metadata key, accepted value, export or default moves: filter-comparand-shape.ts changes a docblock sentence (not a .describe()), readable-ids-conformance.ts adds a fixture row, and check:api-surface / check:docs ran green inside the head's TypeScript Type Check job. The one visible runtime change is the /analytics/sql dry-run echo refusing for a controlled_by_parent object, loud and withheld, on a debugging aid, as ruling ⑤ names it.

② Semver level

Clause-②: no on the PR body and the changeset — right: the switch respells one existing scope, widens no accept set and adds no public name; the fork-1 row is a correction toward the master's own visibility that the ruling fixed as the contract, and the echo's refusal is a runtime answer on one shape of one debugging door, not a surface removal, so the (narrowing) arm is not owed. Metadata-protocol: none — right: the spec docblock and the conformance row change no accept set or export. The level follows the kind: patch on all four moved packages (plugin-security, service-analytics, driver-turso, spec); Check Changeset is green on the head. The full dogfood battery is the dev's reading on this exact head under os-verify-lock (6340537042: 287 files passed / 3 skipped, 2395 tests passed / 18 skipped, 0 failures, command-exit 0), and the head's Dogfood Regression Gate check-run is success; nothing was re-run here.

③ Boundary flags

  • Two stale "one caller" sentences (packages/spec/src/data/readable-ids.ts:50-53, packages/objectql/src/filter-comparand-shape.ts:109): confirmed stale at this head, prose only, outside the claim's bound (a second spec file, objectql). Escalated to the seat as a docs follow-up on the next PR touching either file; not a run-time claim and not a reason to hold this PR.
  • ADR-0139 D6's set-size-ceiling clause: the accepted sentence is kept and the dated parenthetical beside it records the decision and points at ADR-0055's amendment and D5's note — no rewrite, which the maintainer re-read and approved in 6342223675. Answered. One prose nit for a later Tier H edit, not for this one: the parenthetical dates ruling B as 2026-10-21 while its record stamp is 2026-10-20 (UTC), and dates the landing 2026-10-22 ahead of the merge.
  • ADR-0055: every sentence the ruling names is corrected one by one — the status line (a new dated amendment line, the 2026-10-10 line left reading as of its date), :74 set-size ceiling, :75 per-request cost (twice per request, never cached), the amendment's Read paragraph and its Cost sentence — each as a dated note under the kept text. ADR-0139 D5's seventh-consumer note is worded "refuses at boot" with the late-registration limit, as engine seat 1's pointer asks.
  • The duplicated delegated subquery: correct rows, one redundant subquery per delegated read; the ruling's execution parameter leaves it for a later simplification. Answered.
  • The cloud seat's staging read (measurement condition 4): not this PR's; owed at the cloud seat's pin move after landing, as the ruling orders. Escalated there.
  • PG latency (fork 2): accepted A by 6342223675 ②; no measurement card. Answered.
  • Deviation 3 (the two engine-internal write shapes): accepted as named; the run-time measurement is the seat's to ask for. Named above under (c).
  • Deviation 4 (PR on the dispatch base): 24 main commits since 72a302a9d, none touching the PR's 29 files; one touches engine.ts (refactor(objectql)!: retire the PLATFORM_OBJECT_TENANCY ledger — the tenant-audit mute is decided by the schema, and the [tenant-audit] warning names both readings #25210, the tenancy ledger, away from the composition); the PR reads mergeable and clean, and the queue's merge-ref run validates the merge.
  • readable-ids-in-memory.testkit.ts is a test double of the engine's five steps for the fake-engine suites; parity is pinned on the real stack in this package and in objectql. If the engine's composition moves, the testkit moves with it.

Implemented-by: claude/issue-24932-cbp-leaf-switch
Reviewed-by: session_01UnHcTADjeg3VwxF21QHj7W

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:services seat 2, stay-behind (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T14:48Z · ACCEPT at head 35c4c3951de7696308d6ef6acc49a8eda6647100 · dev report 6340537042 · ruling 6342223675

  1. Shape: a draft PR to main whose first line is Fixes #24932, assigned os-bill. Clause-②: no and Metadata-protocol: none are at line start in the body and the changeset; the level is patch on four packages.
  2. Rulings:
    • ruling B 6303721609, ruling Q1 C / Q2 A 6329027469 (the switch with no id-list routing, Q2 A in service-analytics, the Turso nested leaf, the ADR notes);
    • ruling 6342223675: fork 1 A (a delegated detail read equals a delegated direct read of its master, so the two changed pins stand), fork 2 A (the PG count-widget deltas are not material), Deviation 3 accepted, and the Tier H approval granted in words.
  3. Gates (check-runs pinned to this head):
    • 40 success, 9 skipped, 0 failure. The five required contexts are success (Lint & Repo Gates, TypeScript Type Check, Test Core with every shard, Build Core, Governed Surface Queue Guard).
    • mergeable_state: clean. check-governed-merges.mjs --pr 25198: GOVERNED, landing tier H, +1788 / −359.
    • The full dogfood battery on this head: 287 files and 2395 tests passed, 0 failures.
    • Measured: live PostgreSQL 16 conformance passed. HotCRM dashboard totals are 25% faster on SQLite and 54% faster on PostgreSQL.
    • Contract review at CONTRACT_REVIEW_TIER (isolated reviewer): PASS, 6343634246, on this head.
  4. Landing: the ruling orders it. It waits on the maintainer's own APPROVED review on this final head. The two existing approvals sit on 2b988305d (09:18Z, before the ruling and before this head's one test-only commit). On that approval, the seat readies the PR and arms auto-merge through the relay. After landing, the cloud seat's staging read of a two-level chain stays its own (measurement condition 4).
  5. Out of scope (escalated by the review, carrier none):
    • two stale "one caller passes admitReadableIds" sentences (packages/spec/src/data/readable-ids.ts:50-53, packages/objectql/src/filter-comparand-shape.ts:109), for the next PR touching either file;
    • two date nits in ADR-0139's new parenthetical, for a later Tier H edit.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Merge-queue ejection, read · domain:services seat 2, stay-behind · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T16:28Z

Failing check: Lint · CI, release & engine gates (job 119941026865, merge-group run 39812159888 on the queue commit 400bd0bbc, base e3190b949). Its lane verdict names one red gate: Issue citations this change adds resolve on the board.

Why it is not this PR's:

  • The gate exited 3 (PREREQUISITE NOT MET), not 2. Its own log line: "check-issue-citations: PREREQUISITE NOT MET — the board was not read. probeBoard: could not read the allocation frontier". By the gate's own contract that is a failed board read on the runner, not a citation that does not resolve.
  • No citation of this PR was judged. The same gate passed on this PR's own pull_request run at the same head (Lint & Repo Gates success on 35c4c3951).
  • The other 60 gates of the lane passed.

Fix: none is needed in the PR. Its head stays 35c4c3951, with the maintainer's approval on it. This is the one re-run: auto-merge is re-armed through the relay so the PR re-enters the queue. A second failure is treated as real.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 0b8bce4 Oct 22, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24932-cbp-leaf-switch branch October 22, 2026 17:03
os-sales pushed a commit that referenced this pull request Oct 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

2 participants