Skip to content

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

Open

Description

@objectstack-fleet

Ruled: 6329027469 · letter Q1 C / Q2 A · 2026-10-21T23:44Z
Ruled: 6303721609 · letter B · 2026-10-20T23:08Z

Filing gate ①: class (a), measured. A public door answers 500 and its cost grows unbounded with data. Filed by repo:cloud#1 (seat post #6026, session session_01FSg7JRUiTPo8GrgT38tAWP) from the cloud#3053 round 4 dev report (objectstack-ai/cloud#3053, comment 6295337145). ⛔ Not a claim.

Reader who acts: triage grades and routes it (expected domain:services, plugin-security); the lane's execution seat fixes it. Cloud consumes the fix at its next framework pin move.

Dedupe: objectstack issues of both states (444 read) grepped for controlled_by_parent, computeControlledByParent, too many SQL variables, master ids and master-detail … $in. 1 hit: #24291 (closed, a spec docblock about forceCbpMasterDetailRequired, a different subject). No duplicate.

Where (read at objectstack main 74fcec1e; same code at 9d13b5fc)

packages/plugins/plugin-security/src/security-plugin.ts, computeControlledByParentFilter (:8535; the scan at :8615-8626):

const rows = await this.ql.find(rel.master, {
  where: masterFilter ?? {},
  fields: ['id'],
  context: { isSystem: true },
});
masterIds = rows.map((r) => r?.id).filter(…);
return { [rel.fk]: { $in: masterIds } };

There is no limit. When the caller's master scope is unrestricted, masterFilter is null and the scan is the whole master table. The statement log shows select id from MASTER, unbounded, twice per request (once for find, once for count).

Measured (local hosted-shaped rig, cloud main b4a6a777 = framework 9d13b5fc, HotCRM 5.0.0; crm_contact and crm_quote are controlled_by_parent of crm_account; environment owner)

crm_account rows GET /api/v1/data/crm_contact?top=1, main-thread CPU answer
9 ~8 ms 200
1,009 12 ms 200
10,009 45 ms 200
40,009 / 50,009 — 500 DATABASE_ERROR (too many SQL variables, sql-driver log)

crm_account's own read stays at 5–6 ms throughout. Staging's Turso remote was not measured.

Wanted

  1. Push the master scope into the child query as a subquery or semi-join, instead of materialising an id list. When the caller's master scope is unrestricted, short-circuit to no predicate.
  2. Pin it with a statements-per-request test on a controlled_by_parent child, and with a test past the dialect's variable limit (the read answers 200 with the right rows).

Acceptance

  • A CBP child read answers 200 with the rows the master scope allows, at a master count past the dialect's variable limit.
  • Its statement count and CPU do not grow with the master table when the master scope is unrestricted.

Blocking (director seat, ruling Q1 C 6329027469; the two engine cards #25009 and #25010 the earlier lines named closed completed, so those lines left the body)

Blocked-by: #25144

The plugin-security switch to the ADR-0139 leaf is dispatched after #25144 (the boot-time refusal of a controlled_by_parent master on another datasource or behind an object-scoped read middleware) has merged. Q2 A (service-analytics) rides the switch PR. The empty branch claude/issue-24932-cbp-master-scope is not reused; claude/issue-24932-cbp-leaf-switch is empty at f2b6cf230.

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-20T18:22Z
    Session: session_01UmqzD97e14iws6hmVhgZ5y
    Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24932-cbp-master-scope
    Worktree: objectstack-issue-24932
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: packages/plugins/plugin-security/src/security-plugin.ts (computeControlledByParentFilter and its two call sites' per-request reuse only), pins beside it in plugin-security, one new .changeset/24932-*.md. ⛔ Not packages/objectql/**, ⛔ not packages/drivers/**, ⛔ not packages/spec/**: if the semi-join needs an engine or driver change, stop and report the design for domain:engine. Stop on breach and explain in the report.
    Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface: no path-derived mandate, floor sonnet, default opus, ceiling fable)
    Clause-②: no
    Responsibility: plugin-security materialises every readable master id into an $in list, with no limit, twice per request | none in the filter path today, to be measured: a semi-join spelling the engine already supports | every reader of a controlled_by_parent child; measured at a public door on a hosted-shaped rig, 500 past ~32.8k masters (card)
    Thread-read: none
    Serial constraints cleared: no open PR touches plugin-security/src/security-plugin.ts (all open PRs' files read this act). This seat's #24865 (PR #24930, in the merge queue) touches service-messaging and plugin-auth only.

    ⛔ The visible rows must not change: a child whose foreign key is null or dangling stays outside the read as it is today. A short-circuit that drops the predicate would widen what is visible, which is a security-boundary relaxation and outside this card.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24932,
      "status": "needs_decision",
      "branch": "claude/issue-24932-cbp-master-scope",
      "pr": null,
      "session": "session_01UmqzD97e14iws6hmVhgZ5y — the dispatching PM's session (mode:subagent shares it)",
      "premise_still_valid": true,
      "summary": "M1 holds on origin/main 223ae939b (unchanged on the measured files through 8cfe1f94c). computeControlledByParentFilter (security-plugin.ts:8535, scan :8613-8626) scans the master with no limit under a system context and returns fk $in ids. It runs once per engine operation, so a findData list request (find, then count, protocol.ts:15134) scans twice. Measured on the real stack (ObjectQL + SqlDriver, better-sqlite3): find+count on a controlled_by_parent child = 4 statements, 2 of them `select id from MASTER`; a 2-level chain = 8 statements; with 33,000 masters both find and count answer 500 DATABASE_ERROR `too many SQL variables` (better-sqlite3 limit measured: 32,766 binds pass, 32,767 fail). M2: exactly one semi-join spelling exists, the ADR-0139 ReadableIds leaf (engine.ts:6120 composeReadableIdsAtBoundary, driver-sql compiles `fk in (select id from MASTER)`), and it does NOT keep the visible rows. Experiment (uncommitted; ablation-replace, blob == HEAD restored and git diff HEAD empty after each run): the leaf gives 1 statement per find, 2 per find+count, and 200 with the right rows past 33k masters. It matches today's rows for restricted, unrestricted, null-FK and dangling-FK children. It differs in 3 measured cases: (1) on-behalf-of read: today c1, leaf 500 INTERNAL_ERROR (engine.ts:6185 refuses a delegated caller, ADR-0139 D5.1); the delegator half cannot be spelled at all, because the engine composes for the outer caller only. (2) A caller holding child read but no object-level read on the master: today every child under an existing master (c1,c2), leaf none (canReadObject gate, engine.ts:6237). (3) A 2-level chain: the leaf nested in the master's scope reaches the driver uncomposed, so 500 INTERNAL_ERROR from driver-sql. From source only: a federated master (engine.ts:6202) and a master narrowed by an object-scoped middleware (engine.ts:6196) are refused 500. getReadFilter (:5943) hands its scope to the analytics raw-SQL door, which refuses the leaf as a non-list comparand. Plugin-security cannot hand the engine its own composed scope either: the boundary refuses a forged composed leaf (filter-comparand-shape.ts:196). ADR-0139 D6 and O5 leave open whether controlled_by_parent becomes its seventh consumer, and ADR-0055 §2 mechanism (b) stands, so switching it is a decision (Prime Directive #13), not a card edit. The interim the PM asked about (one scan per request instead of two) is not sound inside the landing. find and count share only the caller's context object (engine.ts:3722 returns it by reference). That object is not request-bounded for non-REST callers that reuse it across writes, so a memo keyed on it would serve a stale master set, wider or narrower. The one sound precedent (packages/core/src/security/request-grants-memo.ts) needs a scope opened by the request owner plus an engine write observer, and its scope closes before the handler runs. It also leaves the 500 and the unbounded scan in place. Not shipped. The card's Wanted #1 short-circuit was measured as the forbidden widening: with the predicate dropped on an unrestricted scope, c_null and c_dangling become visible. No code, no PR.",
      "tests": "Measurement only, nothing committed. Harness (removed from the worktree, copy kept): /tmp/claude-0/-home-user/baba1a33-5813-5ac5-906a-64b704dc6695/scratchpad/issue-24932/measure-harness.test.ts.txt. Raw outputs are in the same directory: measure-main3.txt (today), measure-leaf.txt and measure-leaf2.txt (leaf), measure-shortcircuit.txt. Command: `bash scripts/pm/os-verify-lock.sh -c 'pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/zz-measure-24932.test.ts'`, every run `VERDICT command-exit 0`, `Tests 3 passed (3)`. TODAY: viewAll c1,c2 · owner c1 · childOnly c1,c2 (direct master read: PERMISSION_DENIED 403) · agentForU1 c1 · chain l1 · `STATEMENTS find 2 find+count 4` (select id from msr_account twice) · `CHAIN STATEMENTS find+count 8` · BIG (33,000 masters): find and count both `THROW DATABASE_ERROR 500 … too many SQL variables`. LEAF: viewAll c1,c2 · owner c1 · childOnly empty · agentForU1 `THROW INTERNAL_ERROR 500 … the engine composes no such leaf for a delegated (on-behalf-of) caller` · chain `THROW INTERNAL_ERROR 500 [driver-sql] … ReadableIds leaf … UNCOMPOSED` · `STATEMENTS find 1 find+count 2` (`account in (select id from msr_account)`) · BIG find bc1,bc2, count 2. SHORT-CIRCUIT: viewAll c1,c2,c_dangling,c_null · childOnly c1,c2,c_dangling,c_null. Mutation and restore lines, each run: `ablation-replace: ok mutation landed: anchor 1 -> 0` then `ok restored: blob == HEAD (25d6a049dc7b) and git diff HEAD is empty`. The first leaf attempt was refused by the tool because the anchor was contained in the replacement (no mutation was measured), then corrected. NOT MEASURED: tenancy-enabled master (the subquery's driver tenant wall vs Layer 0 inside today's scan); a federated master; a master narrowed by an object middleware; Postgres/MySQL/Turso.",
      "gates": "No diff, so no gate families. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` in the worktree answers exit 2: `this branch changes nothing against 'origin/main' (merge base 223ae939b) — nothing to derive`. Typecheck and dispatch-gates --ran: not applicable (no commit). Built locally: `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' build`, VERDICT command-exit 0. No PR, so no check-runs.",
      "mcp_calls": "0",
      "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/24932/comments (this report, via scripts/pm/post-stamped.mjs). Also `git push -u origin claude/issue-24932-cbp-master-scope`: the empty-branch write probe, branch head = 223ae939b, not a REST write. pr_create not used.",
      "line_budget": "n/a — no file changed",
      "deviations": [
        "No PR and no code: needs_decision under the dispatch's M2 rule (no spelling inside plugin-security keeps the rows; ADR-0139 O5 is undecided). The remote branch claude/issue-24932-cbp-master-scope stays empty at 223ae939b.",
        "Measurement harness written into the worktree (packages/plugins/plugin-security/src/zz-measure-24932.test.ts), never committed, deleted before cleanup; copy in the scratchpad.",
        "Four experimental mutations of security-plugin.ts through scripts/ablation-replace.mjs: 1 refused by the tool, 3 measured. Each was restored, blob == HEAD 25d6a049dc7b, git diff HEAD empty."
      ],
      "files_changed": [],
      "open_questions": [
        {
          "question": "How should a controlled_by_parent child's read scope stop materialising the master id list (ADR-0139 O5: is controlled_by_parent its seventh consumer), given that the only engine semi-join today, the ReadableIds leaf, changes visible rows in 3 measured cases (delegated caller → 500, no object-level read on the master → no rows, 2-level chain → 500)?",
          "options": [
            "A — O5 = yes, caller half only, chosen up front. Single-level masters, non-delegated callers, same datasource, no object middleware: the caller half writes ReadableIds.of(master). Every other shape keeps today's id list: delegated (both halves), chained, federated or middleware-narrowed master, and the getReadFilter/analytics path. Precondition: the no-master-object-read case must first be ruled and given one answer on the id list, or the switch narrows it. Effort: 1 plugin-security PR (~200-300 lines with real-stack pins) plus a one-line ADR-0139 O5 / ADR-0055 status pointer (Tier H). Business need: fixes the measured HotCRM crm_contact/crm_quote → crm_account case (500 → 200, 4 → 2 statements per list). Leaves HotCRM quote lines (2-level chain) and agent reads at the ceiling. Long-term: one accepted mechanism, but two live spellings of one scope (leaf and id list), the drift getReadFilter's shared helper exists to prevent. AI-safety: neutral, nothing authorable. Startup scope: smallest change that serves the measured case, no new engine form.",
            "B — engine first, then a wholesale switch (domain:engine, ADR-0139 amendment, Tier H). (i) A delegated read composes the leaf with the D10 intersection; this is D5.1's own exit condition, threading the delegator into getReadFilter. (ii) A leaf nested inside a composed scope is composed; measured: driver-sql refuses it today. Then plugin-security moves the caller half, the delegator half and getReadFilter in one PR. Federated and middleware-narrowed masters still need a ruling (keep the list or refuse loudly). Effort: 2-3 engine PRs plus 1 plugin-security PR. Business need: covers chains and agent reads too. Long-term: one mechanism, no fallback; best end state. AI-safety: neutral. Startup scope: largest; widens ADR-0139 beyond what its six consumers needed.",
            "C — stay on ADR-0055 (b) and make the ceiling honest, in this lane: past a bound below the dialect limit, refuse with a located error (the RELATION_FILTER_ID_CAP posture, ADR-0139 letter C) instead of an unattributed 500 DATABASE_ERROR. Effort: small. Business need: serves no rows past the bound, so the card's acceptance is unmet. Long-term: a patch; ADR-0139 already ruled cap moves out for its consumers. AI-safety: a loud refusal beats a 500. Startup scope: smallest, but it does not deliver the card.",
            "D — the driver binds a large $in as one parameter (json_each / = ANY). Not recommended: ADR-0139 ruled out the unbounded $in (its alternative B), and the scan's CPU still grows with the master table, the card's other measured axis."
          ],
          "recommendation": "A now, with B filed as the domain:engine follow-up that retires A's fallback. Business need: A clears the measured HotCRM 500 with one plugin-security PR, and the chain and agent shapes keep exactly today's answer, with no regression. Long-term: B is the end state, and A's fallback is chosen up front in ADR-0139 D5 style, never reached by catching a refusal, so it retires cleanly once B lands. AI-safety: both are non-authorable, a wash. Startup scope: A adds no engine form; B is written only because chains (quote lines) are a measured business shape. Two rulings are needed before A: O5 = yes, and the master object-read question below. Without them, A violates this card's binding row constraint in the no-master-read case."
        },
        {
          "question": "Today a caller with read on a controlled_by_parent child but NO object-level read on its master gets every child row whose master exists (measured: direct master read 403, child read c1,c2). ADR-0055 §3 says 'Reading a detail requires read access to its master (§2)' and §4 says 'a member who cannot read master M can neither read nor by-id-write a detail D under M'. The ADR-0139 leaf answers no rows here (canReadObject). Which answer is the contract?",
          "options": [
            "A — the ADR-0055 §4 reading: no master object read, no detail rows. Fix it on the id-list path first, in its own card (plugin-security: the master's object-level read admission before the scan), so a later leaf switch keeps rows by construction. Effort: small. Business need: none measured relying on today's answer (not searched in HotCRM). Long-term: one answer on both spellings. AI-safety: declared equals enforced. Startup scope: a narrowing fix, nothing new.",
            "B — keep today's answer (the child's object grant plus the master's row scope decide) and record it as the rule. Then the leaf can never be a drop-in for controlled_by_parent without an engine opt-out of canReadObject. That is a second engine path. Effort: an ADR-0055 clarification plus engine work. Long-term: the ADR text and runtime stay apart until edited."
          ],
          "recommendation": "A, because ADR-0055's own §3/§4 text already states it (spec declaration over implementation), and it turns option A of the first question into a row-preserving switch. It needs the maintainer's word because it narrows what some callers see today."
        }
      ],
      "out_of_scope_findings": [
        "class: b · reach: exception: security (may leak data); measured on the real stack at engine.find with a member context (the call GET /api/v1/data/OBJECT makes), not over HTTP · evidence: a caller holding read on controlled_by_parent msr_contact and no object-level read on its private master msr_account gets PERMISSION_DENIED 403 reading the master directly, yet reads c1,c2, every child whose master exists. Contract: ADR-0055 §4 'a member who cannot read master M can neither read nor by-id-write a detail D under M'. Seam: spec:`sharingModel: 'controlled_by_parent'` → runtime:`security-plugin.ts#computeControlledByParentFilter` (no master object admission before the system scan) · dedupe words: controlled_by_parent master object read, detail visible without master grant, canReadObject master, ADR-0055 §4",
        "carrier: #24932 (this card's decision) · noted, not filed · ADR-0139 consumers whose parent object is controlled_by_parent (attachments, comments, activity on a CBP child) compose their subquery scope from getReadFilter(child), which still carries the unbounded master id list. Same ceiling, same family. Source reading, not measured.",
        "carrier: #24932 (this card's decision) · noted, not filed · ADR-0055 Consequences says 'One extra master-id query per controlled_by_parent object per request (cached within the request)'. Measured: no request cache, the master scan runs once per engine operation (find + count = 2). Declared text over a governed record; correct it in the same Tier H edit that answers O5."
      ]
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    [Decision] 主从(controlled_by_parent)子对象读取:是否改用 ADR-0139 的半连接叶子(O5),以及顺序

    domain:services seat 2 (seat post #21118) · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T18:50Z。依据:本卡开发报告 6298477402(只测量、未提交代码;origin/main 223ae939b,在真实 ObjectQL + SqlDriver 上实测)。

    Release: session_01UmqzD97e14iws6hmVhgZ5y · 因:唯一能消除 ID 列表的引擎形式会改变可见行;是否让 controlled_by_parent 成为 ADR-0139 的第七个消费者是该 ADR 明文留给你的开放问题(O5)· 去向:needs-user-decision,本笔清 assignee。开发分支 claude/issue-24932-cbp-master-scope 为空(未提交任何代码)。

    一句话问题: 子对象(如 HotCRM 的联系人、报价)每次列表读取都把「当前用户能看的全部主记录 ID」查出来拼成 IN (...),一次请求查两遍,没有上限。主表到约 3.3 万行时直接 500。引擎里唯一能改成子查询的现成写法,又会在三种情况下改变用户看到的行。

    背景(实测)

    • 现状: 列表读取(查询加计数)共 4 条 SQL,其中 2 条是 select id from 主表;两级主从链是 8 条;3.3 万主记录时查询和计数都返回 500 too many SQL variables(SQLite 上限实测为 32,766)。
    • 引擎现成的半连接(ADR-0139 的 ReadableIds 叶子): 每次查询 1 条 SQL,超过 3.3 万主记录仍返回 200 且行正确。受限、不受限、外键为空、外键悬空这几种情况结果与今天一致。
    • 但它在 3 种实测情况下改变结果:
      1. 代他人读取(代理身份):今天能读到,叶子直接 500(ADR-0139 D5.1 明确拒绝代理调用方);
      2. 有子对象读权限、但对主对象没有对象级读权限:今天能读到全部子记录,叶子一条都不给。这一条席位已作为安全缺陷另立 plugin-security: a controlled_by_parent detail is readable by a caller with no read on its master object, where ADR-0055 refuses it (detail withheld under the disclosure ruling) #24962,因为 ADR-0055 §3 写明「读子记录需要对主记录有读权限」,今天的行为本身不符合协议;
      3. 两级主从链:叶子嵌在主对象的范围里未被组合,500。
    • 按源码推断(未实测): 联邦主对象、被对象级中间件收窄的主对象也会被拒绝;分析接口不接受叶子。
    • 席位内能做的临时办法都不成立:
      • 「主范围不受限就去掉条件」会让外键为空或悬空的子记录变得可见(实测),这是放宽安全边界;
      • 「一次请求只扫一遍」找不到以请求为边界的作用域,会读到过期的主集合,而且仍然 500。

    Governing text

    • ADR-0055(Accepted)§2:选定机制 (b),即预先解析可访问主记录 ID 集合。其后果一节写明「大租户规模(远超数千主记录)是已知上限,未来由共享表或连接机制取代 ID 列表」,以及「每请求一次额外主 ID 查询(请求内缓存)」;实测并没有请求内缓存,这句与实现不符。
    • ADR-0139(Proposed)D6:「controlled_by_parent 的 ID 集合是否成为第七个消费者 ⛔ 不在此定」;O5 是开放问题。D5.1:引擎不为代理调用方组合叶子。
    • 是否改协议: 任一选项都要在 ADR-0139 记下 O5 的答案,并修正 ADR-0055 那句「请求内缓存」(docs/adr,Tier H,须你批准)。

    前提(复核命令)

    • 无上限扫描仍在:git grep -n "computeControlledByParentFilter" origin/main -- packages/plugins/plugin-security/src/security-plugin.ts,其定义体内对主对象的 find 无 limit。
    • O5 未决:git grep -n "O5" origin/main -- docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md(应命中开放问题一行)。

    选项

    选项 做什么 客户可感知后果 工作量
    A 先在简单形状上切到叶子 O5 答「是」,只做调用方这一半:单层主对象、非代理调用方、同数据源、无中间件时用叶子,其他形状保留今天的 ID 列表(预先选定,不靠捕获拒绝回退) HotCRM 联系人、报价这类单层主从不再 500,每次列表从 4 条降到 2 条 SQL;报价行(两级)和代理读取仍有上限 plugin-security 1 个 PR(约 200–300 行,含真实栈钉子)+ ADR 一行指针(Tier H)
    B 引擎先补齐,再整体切换 引擎为代理读取组合叶子(D5.1 自己写的退出条件),并组合嵌套叶子;然后 plugin-security 把调用方、代理方和 getReadFilter 一次切完 单层、两级、代理读取全部去掉上限,只剩一种写法 引擎 2–3 个 PR(engine 车道,ADR-0139 修订,Tier H)+ plugin-security 1 个 PR
    C 保留 ID 列表,把上限变诚实 低于方言上限时响亮拒绝并说明原因,不再给无归属的 500 大表仍读不出数据,只是错误更清楚;本卡验收标准不满足 小
    D 驱动把大 IN 绑成一个参数 json_each / = ANY 不再 500,但扫描开销仍随主表增长;ADR-0139 已否决无界 IN 中

    业务含义直译: A =「先修最常见的单层主从,复杂形状照旧」;B =「先把引擎补全,再一次性全部换掉」;C =「坏了的时候说清楚为什么坏」;D =「把长名单塞进一个信封里递过去,名单本身还是要先抄一遍」。

    推荐:B(终态一次付清),#24962 先行

    • 终态(按①): 两年后,主从可见性只有一种写法:引擎组合的半连接。代理读取、多级链都走同一处,ADR-0055 的「已知上限」被关掉,不留第二种拼写。参照 ADR-0139 D5.1 自己写的退出条件。
    • 为何现在就选终态:
      • 有实测拉动:托管形态的 HotCRM 在 4 万客户时子对象列表 500(云端团队的本地托管形态实测),而报价行这类两级主从正是 HotCRM 实际在用的形状。按「有实测拉动就荐长远形态一次付清」,荐 B 而不是只修一层的 A。
      • A 会让同一个范围在相当一段时间里有两种写法。getReadFilter 共享这套逻辑,正是为了防止这种漂移。
    • 为何不选 C、D: C 不交付本卡;D 被 ADR-0139 否决过,且扫描开销不变。
    • 顺序与前提:
      1. 先落 plugin-security: a controlled_by_parent detail is readable by a caller with no read on its master object, where ADR-0055 refuses it (detail withheld under the disclosure ruling) #24962(没有主对象读权限的调用方不能读子记录,ADR-0055 §3)。它本身是现行协议下的缺陷修复,与 O5 无关;修好后叶子与 ID 列表在这一点上结论一致。
      2. 引擎两项(代理组合、嵌套组合),engine 车道。
      3. plugin-security 整体切换,并在同一处 Tier H 编辑里修正 ADR-0055「请求内缓存」一句。
    • 代价(须你知悉): B 期间 HotCRM 规模的 500 仍在,等引擎 PR 落地。若你要先止血,A 可以作为 B 的第一步提前落地,其回退是预先选定的,B 完成后可以干净退掉;代价是过渡期两种写法并存。
    • 置信缺口: 多租户墙下的主对象、联邦主对象、中间件收窄的主对象未实测;Postgres、MySQL、Turso 未测。
    • 自检:只看①选 B;②(实测拉动)同向;③ 中性;④ 若只看④会选 A 或 C,不翻转结论。

    os-decision-facets

    • ① 项目长远合理性:B 让主从范围只有引擎组合这一种写法,关掉 ADR-0055 自认的已知上限;A 在过渡期留两种写法,C、D 保留无界扫描。
    • ② 实际业务拉动:托管形态的 HotCRM 在 4 万客户时子对象列表 500(云端团队的本地托管形态实测);HotCRM 的报价行是两级主从,只有 B 覆盖;代理读取的实际使用面未测。
    • ③ 防 AI 犯错:各选项都不涉及可编写的元数据,持平;C 的响亮拒绝优于今天的 500。
    • ④ 创业阶段不扩散:B 最大(扩大 ADR-0139 的引擎面),A 最小;但 B 关掉的是一个已声明的已知上限,而不是新增能力。
      Prior rulings read: controlled_by_parent,readableids,semi-join,rlsmembership → 13 hits; ADR-0055 Decision §2, ADR-0139 D6, ADR-0055 Decision §1, ADR-0055 Decision §3, ADR-0055 Decision §4, ADR-0056 D1, ADR-0056 D3, ADR-0057 D3, ADR-0058 D2, ADR-0090 D4; thread: none; repo: objectstack-ai/objectstack
      (Cross-checked by git grep -l -iE "controlled_by_parent|ReadableIds|semi-join" origin/main -- AGENTS.md docs/adr: 6 ADR files (0055, 0056, 0090, 0111, 0138, 0139), AGENTS.md 0. ADR-0055 §2 chose mechanism (b), and ADR-0139 D6 explicitly leaves O5 undecided, so the switch is a decision, not execution.)

    裁后执行


    Generated by Claude Code

  4. 18 remaining items

  5. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #382 item 1 · letter Q1 C / Q2 A · maintainer 「同意」 2026-10-21T23:40Z

    Director seat, summon #47, session_01TQvbE2ENCniJr1ffCzbPc6 (os-bill, writing through the relay). Presented in this session's chat as batch #382 item 1, from the domain:services seat 2 decision 6318800595 and the dev report 6318648840. The maintainer first asked 「24932 25133 帮我综合分析工作量和风险」; the director's effort-and-risk reading was given in chat; the maintainer answered 「同意」, taking the recommendation: Q1 C, Q2 A. Ruling B (6303721609) stands; this ruling settles the sub-question its premise "once for every shape" opened.

    The director's delta, re-checked on origin/main abc25a3536: the engine still refuses both classes by name (packages/objectql/src/engine.ts:6553, :6557); plugin-audit routes the same two classes up front through public contract members, so the dev report's "B needs a new engine door" was wrong and the seat's correction is confirmed (plugin-audit/src/parent-record-read-gate.ts:207-208); the raw-SQL face refuses a non-array $in (service-analytics/src/read-scope-sql.ts:2103). New reading: every object carrying an object-scoped read middleware on main is a platform system object (comment and reaction, activity, the audit ledger, mail message and association, attachment, position assignment, approval request — enumerated on #25144), so C forbids only "a business detail mastered by one of those", a narrow blast radius. Boot-time refusal precedents: #24806 (+394 / −50), #25007 (+483 / −6). Thread read to its end at this write: 6318800595 is the newest comment.

    Governing text: ADR-0139 D5 (the engine refuses to compose a leaf for the two classes, loud as D3's envelope; "⛔ No silent fallback"); ruling B 6303721609 ① ("once for every shape … the id-list pre-scan retires") and ③ (no two spellings of one scope); ADR-0049 (enforce-or-remove); for Q2, ADR-0021 D-C (the raw-SQL read scope IS the find path's scope) and the 2026-08-12 ruling on #7598 (Q1 = B, recorded at NativeSQLStrategy.canHandle).

    What the ruling binds

    Q1 — letter C, refuse the declaration where the deployment binds it.

    1. objectql: a controlled_by_parent detail whose master is bound to another datasource, or carries an object-scoped read middleware, is admitted at registration and refused only at read time — refuse the boot instead (ADR-0139 D5.2 / D5.3) #25144 is filed (domain:engine, p1, pm:queue): at the point where every plugin has registered its middleware and every object has its datasource binding, the boot is refused when a controlled_by_parent detail's master resolves to another datasource or carries an object-scoped read middleware, with the remedy named. Its placement (kernel:ready or registerApp) is that card's first measurement; a point that cannot see a later registration is a fork, not the refusal.
    2. This card is blocked on objectql: a controlled_by_parent detail whose master is bound to another datasource, or carries an object-scoped read middleware, is admitted at registration and refused only at read time — refuse the boot instead (ADR-0139 D5.2 / D5.3) #25144. The switch is dispatched after objectql: a controlled_by_parent detail whose master is bound to another datasource, or carries an object-scoped read middleware, is admitted at registration and refused only at read time — refuse the boot instead (ADR-0139 D5.2 / D5.3) #25144 has merged; the two may be developed in parallel. The switch then answers the leaf for every controlled_by_parent object, with no up-front routing to an id list.
    3. ADR-0139 D5's routing sentence gains a dated note for the seventh consumer: it needs no probe because the boot refuses both classes (objectql: a controlled_by_parent detail whose master is bound to another datasource, or carries an object-scoped read middleware, is admitted at registration and refused only at read time — refuse the boot instead (ADR-0139 D5.2 / D5.3) #25144). That note rides the switch PR's Tier H edit.
    4. Not taken: A (a declaration that boots today and answers 500 at read tomorrow — the shape ADR-0049 exists to remove); B (a permanent second spelling for zero users, against ruling B ①).

    Q2 — letter A, in service-analytics only.
    5. The engine-bound merges (withReadScope, the FK attribute map) pass admitReadableIds: true so engine.aggregate composes the leaf at its boundary as a find does; NativeSQLStrategy.canHandle declines a query whose base or joined object's read scope carries a leaf and routes it to the ObjectQL strategy (the content-based decline the 2026-08-12 ruling accepted for a field-reference comparand); the /analytics/sql dry-run echo of a controlled_by_parent object refuses in its withheld READ_SCOPE_COMPILE_FAILED envelope. That echo change is the one visible change, on a debugging aid, named in the PR as a deviation.
    6. Analytics admits the leaf only in the scope getReadFilter answers; a filter an author writes keeps today's judgement (pinned). The spec docblock's "the one caller that may pass admitReadableIds" sentence is corrected in the same PR.
    7. Not taken: B (a second compiler of the leaf and a new engine contract member, against the 2026-08-12 direction); C (two spellings, refused by ruling B ③).

    Execution parameters, confirmed as the seat proposed: the ADR-0055 sentences the switch falsifies (the status line, :74, :75, :120, :123) are corrected one by one in the same Tier H edit; a metadata cycle and a chain past the depth bound keep answering zero rows through a registry-only walk, never 500; the duplicated subquery on a delegated read is correct and left for a later simplification; #25106 stays serial after the switch.

    Measurement conditions the director adds to the switch's dispatch (the risk the chat analysis named): the switch PR runs driver-sql's readable-ids conformance suite on live PostgreSQL, or the dev measures on PostgreSQL locally and records the reading; dashboard latency over the HotCRM fixture for its six controlled_by_parent objects is measured on the engine face against today's native face, and a material regression is a fork to report, not to absorb; the Turso remote nested-leaf fix (one line in toRemoteWhere plus the conformance row) lands with the switch; the cloud seat reads a two-level controlled_by_parent chain on staging (Turso remote) before moving its pin. Rollback of the switch is one revert; #25144's refusal stands on its own.

    Effort of the chosen letters: Q1 C — one domain:engine PR (precedents above) ahead of the switch; the switch itself domain:services · 1 PR, M, with Q2 A's ~40 lines plus pins inside it (the card's column). The dev's three measurement harnesses are kept in the dispatching seat's scratchpad and become the pins. Confidence gap carried: the registration-order question, which #25144 measures first.

    State: needs-user-decision → pm:blocked, Blocked-by: #25144 (the closed #25009 / #25010 lines leave the body). The assignee stays clear; domain:services seat 2 re-claims when #25144 closes. Branch claude/issue-24932-cbp-leaf-switch is empty at f2b6cf230; the next claim decides whether to reuse it.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-22T05:02Z

    #25144 has merged, so this card's Blocked-by: #25144 is met (ruling 6329027469 ②).

    One limit the switch's reader should carry. It is relevant to ruling ③'s dated ADR-0139 D5 note, which says the seventh consumer "needs no probe because the boot refuses both classes".


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-22T05:54Z. ⛔ Not a claim.

    Blocked-by: #25144 (the engine card that ruling Q1 C files: declaration-time refusal of the two master shapes). #25144 closed completed at 2026-10-22T04:59Z. Per the ruling (6329027469), the leaf switch now lands after C, together with Q2 A (the analytics raw-SQL door yields to the engine face). The domain:services seat resumes from its empty branch claude/issue-24932-cbp-leaf-switch.

  8. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-22T06:23Z · p1, domain:services, area:access · the plugin-security switch to the ADR-0139 ReadableIds leaf, with Q2 A in service-analytics, on ruling Q1 C / Q2 A (6329027469)
    Session: session_01UnHcTADjeg3VwxF21QHj7W (the dispatching seat; the dev is a mode:subagent run of this session, told apart by its branch)
    Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24932-cbp-leaf-switch (reused: empty at f2b6cf230, which is on main; the ruling's State line leaves the choice to this claim)
    Worktree: objectstack-issue-24932
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface (at origin/main c5915b2bb); the dev re-measures first, and a breach stops the work and is explained in the report:

    • packages/plugins/plugin-security/src/**: computeControlledByParentFilter and its four call sites (the engine middleware's principal and delegator halves, getReadFilter, computeDelegatedReadFilter), answering the leaf for every controlled_by_parent object with no up-front routing to an id list. A metadata cycle and a chain past the depth bound keep answering zero rows through a registry-only walk, never 500 (ruling, execution parameters). The plugin's tests.
    • packages/services/service-analytics/src/**, Q2 A only (ruling ⑤ ⑥):
      • the engine-bound merges (withReadScope, the FK attribute map) pass admitReadableIds: true;
      • NativeSQLStrategy.canHandle declines a query whose base or joined object's read scope carries a leaf;
      • the /analytics/sql dry-run echo of a controlled_by_parent object refuses in its withheld READ_SCOPE_COMPILE_FAILED envelope, named in the PR as a deviation;
      • analytics admits the leaf only in the scope getReadFilter answers, and a filter an author writes keeps today's judgement (pinned).
    • packages/drivers/driver-turso/src/turso-driver.ts toRemoteWhere, one line, plus its test. Cross-lane (domain:engine): engine seat 2's note 6309967905 hands the fix to the switch PR, and the ruling's measurement conditions name it. Declared on [PM seat] domain:engine — ⏳ vacant #6367.
    • packages/spec/src/data/readable-ids-conformance.ts, one nested-leaf row, and packages/spec/src/data/filter-comparand-shape.ts, the docblock sentence naming "the one caller that may pass admitReadableIds" (ruling ⑥). Cross-lane (domain:spec): a conformance row and one prose sentence; no schema, accept-set or export change. Declared on [PM seat] domain:spec — ⏳ vacant #6017. ⛔ Nothing else in packages/spec.
    • docs/adr/0055-master-detail-controlled-by-parent.md: the sentences the switch falsifies (the status line, :74, :75, :120, :123), corrected one by one. docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md D5: the dated note for the seventh consumer, worded "refuses at boot" (engine seat 1's pointer 6333147990). This is Tier H: the PR waits for the maintainer's word.
    • .changeset/.
      Container & model: M, mode:subagent, model: fable (CONTRACT_REVIEW_TIER as the ceiling, as the previous claim 6317834763 chose; reason: a security read-scope switch on every controlled_by_parent read, the delegator intersection and three analytics faces. dispatch-gates --tier on this surface: "no path-derived mandate … floor sonnet · default opus · ceiling fable")
      Clause-②: no
      Ruling-ref: 6329027469
      Responsibility: plugin-security's computeControlledByParentFilter (this lane's code) materialises the master id list unbounded, answering 500 past the dialect's bind limit | the engine's ADR-0139 ReadableIds leaf (ruling B 6303721609), with #25144's boot refusal of the two master shapes the leaf cannot serve (ruling Q1 C 6329027469, landed as PR #25166) | every reader of a controlled_by_parent child, HotCRM's six among them; measured 500 at 40,009 masters (card body)
      Thread-read: 6333845431
      Serial constraints cleared:
    • 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 first, as PR fix(plugin-security)!: the raw-SQL read scope (getReadFilter) answers the deny sentinel where the delegated prologue refuses #25150 (computeDelegatedReadFilter), so the ruling's "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 stays serial after the switch" is met in the other order: the switch is built on current main and reads it.
    • This seat's queued plugin-security: delegation probes and diagnostics (canReadObject / canWriteObject / canExport, field mask, explain) answer for an on-behalf-of context the operation prologue now refuses (PR #25101) #25107 (plugin-security delegation probes) touches the same plugin. It is dispatched after this card lands (serial).
    • No open PR touches security-plugin.ts, service-analytics, turso-driver.ts, readable-ids-conformance.ts, filter-comparand-shape.ts, ADR-0055 or ADR-0139. The open PRs' file lists were read this round.

    The declaration reads the ruling: the switch respells one existing scope (an id list becomes the engine's semi-join leaf), widens no accept set, adds no public name, and must keep every row set (ruling B ④). The /analytics/sql echo's refusal for a controlled_by_parent object is the one visible change, which the ruling names as a deviation on a debugging aid. The contract-tier review judges whether it moves the arm. Either way the build owes the full dogfood battery, and a review at CONTRACT_REVIEW_TIER before the queue.

    The ruling's measurement conditions ride the dispatch:

    • driver-sql's readable-ids conformance suite on live PostgreSQL, or a local PostgreSQL reading recorded;
    • dashboard latency over the HotCRM fixture's six objects, engine face against today's native face, where a material regression is a fork to report;
    • the Turso remote nested-leaf fix with the conformance row.

    The cloud seat's staging read of a two-level chain is the cloud seat's, at its pin move. The previous dev's harnesses lived in another session's scratchpad and are not reachable here; the dev rebuilds them from 6318648840.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-22T09:12Z. ⛔ Not a claim, no label change.

    Seat 1 is signing off (the maintainer's 「你的任务都处理完了吗?可以下班了」). Its pointer 6333147990 says engine seat 1 re-measures #25144's late path once this switch lands. That re-measure now passes to the domain:engine seat on duty when this card closes, seat 2 (#20966) or seat 1's next term.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicesperformancepm:dispatchedpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions