You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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 main74fcec1e; same code at 9d13b5fc)
packages/plugins/plugin-security/src/security-plugin.ts, computeControlledByParentFilter (:8535; the scan at :8615-8626):
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 mainb4a6a777 = 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
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.
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 (domain:services seat 2, by ruling B 6303721609)
The plugin-security switch is dispatched after both engine cards land (delegated composition with the ADR-0139 revision; nested composition). The empty branch claude/issue-24932-cbp-master-scope is not reused.
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.
{
"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."
]
}
pm:queue → pm:blocked, by ruling B's own instruction (6303721609: "The domain:services seat's first act on this card, before any dispatch of the switch: file the two domain:engine cards … then move this card to pm:blocked with one Blocked-by: line per engine card.") · domain:services seat 2 (seat post #21118) · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-21T00:13Z
The body now carries:Blocked-by: #25009 and Blocked-by: #25010.
When both close, this seat dispatches the plugin-security switch: the caller half, the delegator intersection and getReadFilter in one PR; the id-list pre-scan retires, and ADR-0055's "cached within the request" sentence is corrected in the same Tier H edit.
Note for card (c), the plugin-security switch · domain:engine seat 2 (seat post #20966) · session_01QK6pdbxKZiHdFApL8RYVh9 · 2026-10-21T06:51Z. ⛔ Not a claim, no label change.
#25010 (PR #25072, card (b)(ii)) composes a ReadableIds leaf that sits inside a composed read scope. Its dev measured one driver gap that only this card's switch makes live. That dev report is on #25010; the measurement was taken on head 549f0bcc8, through a probe over the spec's conformance rows with a composed leaf whose scope carries a composed leaf:
TursoDriver's local face answers the nested semi-join (rows c1, c3, c6, c7). driver-sql (applyFilters) and driver-memory (convertToMongoQuery) compile the nested scope by construction.
The remote face refuses, INTERNAL_ERROR / 500: "[driver-turso] where.id.$in holds a ReadableIds leaf … composed, and driver-turso does not compile it: the remote transport compiles it only as TursoDriver resolved it". TursoDriver.toRemoteWhere resolves only top-level composed leaves (replaceComposedReadableIds does not descend into a composed leaf's scope), and hands the nested one to the transport through toRemoteFilter.
Reach today: none. No shipped security service answers a read scope that carries a leaf until this card switches controlled_by_parent to the leaf. After the switch, a two-level controlled_by_parent chain read on a remote Turso datasource would answer 500.
The remedy the dev proposes: one line, resolving the scope through toRemoteWhere with the outer read's options, plus a nested case in packages/spec/src/data/readable-ids-conformance.ts, so every driver suite measures the shape.
This comment is the record (no card filed, since no reach yet). Whichever PR makes the switch owes the fix before, or with, that switch.
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-21T10:03Z. ⛔ Not a claim.
Both blockers have closed completed: Blocked-by: #25009 at 2026-10-21T04:38Z, and Blocked-by: #25010 at 2026-10-21T07:52Z (PR #25072). The domain:engine seat's note of 2026-10-21T06:51Z describes the leaf this card's plugin-security switch now reads.
Claim: PM loop round 1 (re-seated term) · 2026-10-21T13:54Z · p1, domain:services, card (c) of ruling B (6303721609): the plugin-security switch to the ADR-0139 ReadableIds leaf
Session: session_01UmqzD97e14iws6hmVhgZ5y (the dispatching seat; the dev is a mode:subagent run of this session, told apart by its branch)
Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24932-cbp-leaf-switch (new; ruling B: the empty claude/issue-24932-cbp-master-scope is not reused)
Worktree: objectstack-issue-24932
Domain: domain:services
Seat: domain:services#2
File surface: the dev re-measures first; 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), and the plugin's tests.
packages/services/service-analytics/src/**: only if the raw-SQL door must accept the composed leaf that getReadFilter will now answer (read-scope-sql.ts / read-admission.ts).
packages/drivers/driver-turso/src/turso-driver.tstoRemoteWhere, one line, plus its test. This is a cross-lane surface (domain:engine). It enters through the ownerless-blocking-item channel: the engine seat's note 6309967905 on this card hands that fix to "whichever PR makes the switch … before, or with, that switch", and no card carries it.
docs/adr/0055-master-detail-controlled-by-parent.md:75, the "cached within the request" sentence corrected to what the code does, by ruling B ② (c). This is Tier H: the PR waits for the maintainer's approval.
{
"issue": 24932,
"status": "needs_decision",
"branch": "claude/issue-24932-cbp-leaf-switch",
"pr": null,
"session": "session_01UmqzD97e14iws6hmVhgZ5y — the dispatching seat's session (mode:subagent shares it; this run is told apart by its branch)",
"premise_still_valid": true,
"summary": "All seven premises were measured on the real stack (ObjectQL + SqlDriver on better-sqlite3 + the real SecurityPlugin and SharingService, origin/main f2b6cf230) before any edit, and two of them fork, so by the dispatch's stop conditions no switch was built: no tracked file was edited, the branch is empty at f2b6cf230, no PR. P3 forks: the declaration path admits a controlled_by_parent master of both classes the engine refuses by name (ADR-0139 D5.2 and D5.3, engine.ts:6550-6558 on main). ObjectSchema.parse admits a private master declared on another datasource together with its controlled_by_parent child, and a controlled_by_parent child whose master is sys_attachment (an object-scoped-middleware object); engine.registerApp admits a child whose master is bound explicitly to a second registered datasource and a child whose master carries an object-scoped read middleware; today's id list serves both (viewer xc1,xc2 and wc1,wc2; owner xc1 and wc1; delegated xc1 and wc1), and the leaf form answers INTERNAL_ERROR / 500 for every caller on both. No shipped producer: HotCRM e6d5d3e declares six controlled_by_parent objects (crm_contact, crm_quote, crm_contract, crm_quote_line_item, crm_opportunity_line_item, crm_event_attendee), every master a crm_* object on the one datasource with no object-scoped middleware; in-repo declarations outside tests: none. P5 forks: with the leaf as getReadFilter's answer, every analytics face answers READ_SCOPE_COMPILE_FAILED / 500 where today it answers the find path's rows — the native raw-SQL face ($in needs an array), the engine-bound face (assertReadScopeComparandsRunnable refuses the branded leaf as a non-list comparand before engine.aggregate could compose it) and the /analytics/sql echo. Compiling the leaf on the raw-SQL face itself needs the engine's composition and the driver's tenant wall (outside the surface); the in-surface design (the native strategy declines a leaf-carrying scope and routes to the engine face, as it declines a field-reference comparand by the 2026-08-12 ruling; the engine-bound guard admits the branded leaf with the engine's own option) changes the echo's answer for controlled_by_parent objects, so it is put to the seat rather than built. P1, P2, P4, P6 and P7 hold (readings below). The leaf form was measured through two held mutations of security-plugin.ts (scripts/ablation-replace.mjs), restored with blob == HEAD 0fe4494afce3 and git diff HEAD empty; it answers 1 statement per find and 2 per find+count with no master id scan, the same rows as today for the plain, restricted, delegated, nested-chain, null-FK, dangling-FK and child-only callers, and 200 with exactly bc1,bc2 (count 2) past 33,000 masters where today answers DATABASE_ERROR / 500. Two more doors differ under the leaf and are part of the switch's design, not a fork: a metadata cycle and a chain over the depth bound answer zero rows today (the plugin's own $in: [] doors) and INTERNAL_ERROR / 500 from the engine's cycle and depth refusals under the leaf; the switch keeps both doors as closed as today through a registry-only walk (no store work).",
"premises": {
"P1": "HOLDS. computeControlledByParentFilter (security-plugin.ts:8799 on f2b6cf230; the scan at :8906-8910) runs ql.find(rel.master, { where: masterFilter ?? {}, fields: ['id'], context: { isSystem: true } }) with no limit and answers { fk: { $in: ids } }. Measured: find+count on a controlled_by_parent child = 4 statements, 2 of them `select id from msr_account` (unrestricted scope: `select id from msr_account`; restricted: `… where owner_id = ?`); a delegated caller = 6 statements, 4 master id scans (two per half); a two-level chain = 6 statements, 4 pre-scans (grand, then mid with the grand ids bound). At 33,000 masters find and count both answer DATABASE_ERROR / 500, the driver log reading `too many SQL variables`.",
"P2": "HOLDS (measured under the leaf mutation, restored). A plain caller: unrestricted c1,c2 / restricted c1, one statement `select * from msr_contact where ((account in (select id from msr_account [where owner_id = ?])))`. A delegated caller with the delegator intersection: c1, one statement carrying both halves as two subqueries over the master. A leaf nested in a composed scope (two-level chain): l1 for the owner, l1,l2 for a view-all caller, one statement `… mid in (select id from msr_mid where grand in (select id from msr_grand where owner_id = ?))`. find=1, find+count=2, master id scans=0 for every shape.",
"P3": "FORKS — both classes are admitted. Spec: ObjectSchema.parse admits { sharingModel: private, datasource: 'other' } beside its controlled_by_parent child, and a controlled_by_parent child whose master_detail references sys_attachment. Engine: registerApp admits a child whose master is bound to a second registered datasource (resolveDatasourceBinding answers { datasource: 'other', via: 'explicit' } for the master and the default driver for the child) and a child whose master has an object-scoped read middleware (hasObjectMiddleware true). Today's id list serves rows on both (viewer xc1,xc2 and wc1,wc2; owner xc1 and wc1; delegated xc1 and wc1; the masters read directly x1,x2 / x1 and w1). Under the leaf both answer INTERNAL_ERROR / 500 by name: `… composes no such leaf over 'msr_xmaster', which is bound to datasource 'other' …` and `… over 'msr_mwmaster', whose rows an object-scoped read middleware narrows …` (engine.ts:6550 and :6554-6558). Shipped compositions: HotCRM e6d5d3e, 6 controlled_by_parent objects, every master a crm_* object on the single datasource, no middleware-gated master, no datasource or datasourceMapping declaration anywhere in its src; in-repo declarations outside test fixtures: none.",
"P4": "HOLDS. A caller holding read on the child and no object-level read on the master: today [] on the child (direct master read PERMISSION_DENIED / 403), the leaf form [] too (canReadObject false ⇒ $in: []). The plugin's own door (objectReadAdmission before the scan, #24962) and the leaf's door (canReadObject at ADR-0139 D2 step 1) close identically for a plain caller, and for a delegated caller the leaf's one canReadObject(delegated context) equals today's two halves (principal AND delegator).",
"P5": "FORKS. Today all three analytics faces answer the find path's rows (viewer c1,c2; owner c1; delegated c1; child-only []), the native face running the id list as a bound IN. Under the leaf: native face READ_SCOPE_COMPILE_FAILED / 500 `$in for \"account\" needs an array (fail-closed)` (read-scope-sql.ts compileOperator, :2102-2103); engine-bound face READ_SCOPE_COMPILE_FAILED / 500 from assertReadScopeComparandsRunnable (:1076-1079, assertListComparandShapes without admitReadableIds: `Operator $in on field account requires an ARRAY of values. Received object …`), before engine.aggregate could compose the leaf; the /analytics/sql echo refuses the same way; the child-only caller keeps [] on every face (the deny sentinel, unchanged). What the door needs: the engine-bound merges (objectql-strategy.ts withReadScope :900 and the FK attribute map :1422) need assertReadScopeComparandsRunnable to pass admitReadableIds: true (the option the engine's own ingress door passes, objectql/filter-comparand-shape.ts:123) — normalizeFilterComparandTypes skips a non-array $in by design, assertReadScopeCannotVacate reads no finding on it, and judgeFilter runs the engine's admitting pipeline — after which engine.aggregate composes the leaf at its boundary; the raw-SQL face (compileScopedFilterToSql) cannot compile a leaf without the engine's composition (canReadObject + getReadFilter per parent, recursive, the cycle and depth guards) and the driver's tenant wall for the master (driver-sql applyTenantScope with the outer options) — work outside the claimed surface — so the in-surface shape is the ruled precedent: NativeSQLStrategy.canHandle declines a scope carrying a leaf and the query routes to the engine face, and the dry-run echo refuses in its withheld envelope for controlled_by_parent objects where today it renders the bound list. Reported, not built.",
"P6": "HOLDS. docs/adr/0055-master-detail-controlled-by-parent.md:75 reads `One extra master-id query per controlled_by_parent object per request (cached within the request)`; the code has no request cache (find then count scan the master twice, measured in P1). :74 (`large-tenant scale … is a known limit — a future share-table/join mechanism would replace the id list`) is made false by the switch and would be corrected in the same edit.",
"P7": "HOLDS. Probe over the spec's conformance rows with a composed leaf whose scope carries a composed leaf (`record_id in composed(P, { id: { $in: composed(P, { owner: 'u1' }) } })`): TursoDriver local face answers c1,c3,c6,c7 (count 4; under tenantId org_a c1,c3,c7); the remote face refuses INTERNAL_ERROR / 500 `[driver-turso] where.id.$in holds a ReadableIds leaf … composed, and driver-turso does not compile it: the remote transport compiles it only as TursoDriver resolved it`. TursoDriver.toRemoteWhere (turso-driver.ts:3457-3466) resolves the leaf's scope through toRemoteFilter only, and core's replaceComposedReadableIds returns a composed leaf unentered (readable-ids-driver-refusal.ts:132), so the nested one reaches the transport unresolved. The one-line remedy (resolve `leaf.scope` through toRemoteWhere with the outer read's options) and the conformance row are ready to land with the switch."
},
"tests": "Measurement only, nothing committed; the worktree is clean (git status --porcelain empty, security-plugin.ts blob 0fe4494afce3 == HEAD). Harnesses (removed from the worktree, copies kept): /tmp/claude-0/-home-user/baba1a33-5813-5ac5-906a-64b704dc6695/scratchpad/issue-24932/dev/harness-main.test.ts.txt (rows, statements, the bind limit, the P3 pairs, the cycle and depth chains), harness-analytics.test.ts.txt (the three analytics faces over the same stack), harness-turso-probe.test.ts.txt (both Turso faces). Runs, every one through scripts/pm/os-verify-lock.sh: closure build `pnpm exec turbo run build --filter='@objectstack/plugin-security...' --filter='@objectstack/service-analytics...' --filter='@objectstack/driver-turso...' --filter='@objectstack/driver-memory...' --filter='@objectstack/driver-sqlite-wasm...' --concurrency=2` 22 tasks successful, VERDICT command-exit 0 (237 s); today's main harness VERDICT command-exit 0, Tests 4 passed (4) (measure-main2.txt); Turso probe Tests 2 passed (2) and analytics harness Tests 1 passed (1), sequenced with ';' under one acquisition (VERDICT batch-last-exit 0; each part's own exit recorded in its log: turso=0, analytics=0); the leaf leg (leaf-leg.sh under the lock, VERDICT command-exit 0): two held mutations of security-plugin.ts through scripts/ablation-replace.mjs — `ok mutation landed: anchor 1 → 0, blob 0fe4494afce3 → dca16daa31b7` (the import) and `anchor 1 → 0, blob dca16daa31b7 → fe26c47aa78f` (the scan replaced by the leaf answer; on disk: leaf-return count 1, scan-anchor count 0), main harness main=0 Tests 4 passed, analytics harness analytics=0 Tests 1 passed, then `ok restored: blob == HEAD (0fe4494afce3) and git diff HEAD is empty`. Both harness suites resolve @objectstack/objectql and @objectstack/driver-sql from src through the package's vitest alias, so no dist was on the mutation's resolution path. Not re-run: the landed suites of #25032 / #25072 (their pins stand on main). NOT MEASURED: Postgres, MySQL, a live Turso remote; a tenancy-enabled master.",
"gates": "No diff, so no family is owed. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) in the worktree: exit 2, `this branch changes nothing against 'origin/main' (merge base f2b6cf230) — nothing to derive`. No PR, so no check-runs.",
"mcp_calls": "0 — no MCP GitHub tool was called; reads were `gh api` REST GETs (issue 24932 and its comments, issue 25010 and its comments, pulls 25032 / 25072 and their files, pull 24893 and its files).",
"api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/24932/comments (this report, via scripts/pm/post-stamped.mjs through the fleet-write relay as objectstack-fleet[bot]). Not a REST write: `git push -u origin claude/issue-24932-cbp-leaf-switch`, the empty-branch write probe (head f2b6cf230, one push). pr_create not used.",
"line_budget": "n/a — no file changed",
"deviations": [
"No PR and no code: needs_decision by the dispatch's stop conditions (P3 and P5 fork). The remote branch claude/issue-24932-cbp-leaf-switch stays empty at f2b6cf230; ⛔ not reused for the empty old branch's work either.",
"Three harness files were written into the worktree (packages/plugins/plugin-security/src/zz-measure-24932.test.ts, …/zz-measure-24932-analytics.test.ts, packages/drivers/driver-turso/src/zz-probe-24932-nested.test.ts), never committed, deleted before cleanup; copies in the scratchpad. The analytics harness imported service-analytics' src by a relative path from plugin-security — a harness-only spelling, never a committed test input.",
"P2 was measured by a two-anchor held mutation of security-plugin.ts (the scan replaced by the leaf answer) rather than by re-running the landed engine suites; both mutations were restored and the restore proven (blob == HEAD, git diff HEAD empty, trap-free script with an EXIT/INT/TERM restore on the absolute path).",
"Two harness runs were sequenced with ';' under one lock acquisition, so that acquisition's VERDICT is batch-last-exit; each part's exit code was captured into its own log before any pipe (turso=0, analytics=0, main=0).",
"The delegated caller was built as a plain context carrying onBehalfOf { userId } with a stored sys_user_position grant for the delegator, not through resolveAuthzContext / assembleExecutionContext; the landed #25032 suite covers the assembled shape."
],
"files_changed": [],
"open_questions": [
{
"question": "P3 — a controlled_by_parent master of a class the engine refuses by name (ADR-0139 D5.2: an object-scoped read middleware narrows its rows; D5.3: bound to another datasource) is admitted by the declaration path (spec parse, registerApp) and served by today's id list; after the switch such a child answers INTERNAL_ERROR / 500 for every caller. Measured: zero shipped producers (HotCRM 0 of 6, in-repo 0). Which answer does the switch give that shape?",
"options": [
"A — The engine's named refusal IS the answer for these two classes: the switch answers the leaf for every controlled_by_parent object and the engine refuses D5.2 / D5.3 masters loudly at read time with the remedy in the message (today's behaviour for the six consumers' D5 classes, chosen by ADR-0139 D5); ADR-0055's corrected Consequence names the two classes as the one limit that replaces the id-list ceiling. Business need: none measured — no shipped producer declares either shape, so no reader loses rows. Long-term: one spelling of one scope (the ruling's end state), the D5 posture already ruled (`refuse loudly, no silent fallback`). AI-error-proofing: a loud, named 500 beats an id list that works until the bind cliff; but the parse still admits a declaration the runtime refuses, so a boot-time or authoring-time refusal would be the honest end state (option C). Startup focus: no new mechanism, implementation-first for a zero-pull declared shape. Effort: the switch as dispatched plus two pins (one per class) and one ADR sentence.",
"B — Keep the id list for exactly these two classes, chosen up front inside plugin-security (ask the engine whether the master has an object-scoped middleware or another datasource binding; answer the bounded list for it, the leaf otherwise). Business need: none measured. Long-term: a second, permanent spelling of one scope — the drift getReadFilter exists to prevent and the shape ruling 6303721609 ③ refused for the transition; the bind cliff stays for that class. AI-error-proofing: neutral. Startup focus: larger — the datasource binding is not on the public IObjectQLEngine contract, so plugin-security would need a new engine door (engine lane). Effort: engine door + plugin-security routing + pins.",
"C — Refuse the declaration where it is made: a controlled_by_parent child whose master resolves to another datasource, or to an object an object-scoped read middleware gates, is refused at app registration / os validate with the remedy named. Business need: none measured; it protects authors from a shape that today works until the bind cliff and tomorrow 500s. Long-term: declared equals enforced, the cleanest end state; both facts are deployment facts (datasourceMapping, package default datasource, lifecycle routing, middleware registrations), so the refusal lives in the engine/runtime boot, not in the spec parse. AI-error-proofing: best — loud before any data exists. Startup focus: a new runtime refusal in another lane; defensible as closing a declared-but-unenforceable shape, not as a new capability. Effort: an engine/runtime card after the switch."
],
"recommendation": "A now, with C filed as the engine-lane follow-up only if the maintainer wants the refusal moved from read time to boot. Business need: zero producers measured, so no reader loses rows under A. Long-term: A keeps one spelling (the ruling's end state) and reuses the D5 refusal ADR-0139 already chose for exactly these classes; B reintroduces the two spellings the ruling refused. AI-error-proofing: A's refusal is loud and names the remedy; C would make it loud earlier, which is why it is the follow-up and not a reason to hold the switch. Startup focus: A adds nothing; B adds an engine door for a shape nobody declares."
},
{
"question": "P5 — with the leaf as getReadFilter's answer, every analytics face (native raw SQL, engine aggregate, the /analytics/sql echo) refuses a controlled_by_parent object's read scope READ_SCOPE_COMPILE_FAILED / 500 where today it answers the find path's rows. Compiling the leaf on the raw-SQL face needs the engine's composition and the driver's tenant wall (outside the claimed surface). May the switch carry the in-surface design, and with which answer for the echo?",
"options": [
"A — In service-analytics only: (i) the engine-bound merges (withReadScope and the FK attribute map) admit the branded uncomposed leaf at assertReadScopeComparandsRunnable by passing admitReadableIds: true — the option the engine's own ingress door passes — so engine.aggregate composes it at its boundary exactly as a find does; (ii) NativeSQLStrategy.canHandle declines a query whose base or joined object's read scope carries a ReadableIds leaf, routing it to the ObjectQL strategy — the content-based decline the 2026-08-12 maintainer ruling (Q1 = B, recorded at canHandle) accepted for a field-reference comparand (security rules exist in one place, no copy, no new StrategyContext hook) and the one hasObjectMiddleware already drives; (iii) the /analytics/sql dry-run echo of a controlled_by_parent object refuses in its withheld READ_SCOPE_COMPILE_FAILED envelope (it runs no composition and must not render one it did not run), the shape the cross-field ruling kept (`only the dry-run face refuses, one consistent loud answer`). Business need: HotCRM dashboards over its six controlled_by_parent objects keep every row through the engine face; the echo of those objects changes from rendered SQL to a loud refusal. Long-term: one owner of the composition (the engine); the native face keeps declining what only the engine can judge. AI-error-proofing: neutral (nothing authorable). Startup focus: smallest, ~40 lines plus pins. Cost: controlled_by_parent objects leave the native-SQL strategy for the engine aggregate (same rows; the performance class of the two faces on those objects is unmeasured), and the spec docblock's `the one caller that may pass admitReadableIds` sentence gains a second caller, the engine's mirror.",
"B — The raw-SQL face compiles the leaf itself: the engine exposes its composition as a contract door (compose the read scope of an object for a context, the chain, cycle and depth guards included) and service-analytics compiles the composed leaf as a semi-join with the master's tenant wall obtained from the driver. Business need: keeps controlled_by_parent objects on native SQL and the echo rendering. Long-term: a second compiler of the leaf beside driver-sql and the Turso remote transport, and a new engine contract member; against the 2026-08-12 ruling's direction for this strategy. AI-error-proofing: neutral. Startup focus: largest — engine, spec contract, driver-sql tenant-wall access, service-analytics; two lanes.",
"C — getReadFilter keeps answering the bound id list (only the middleware halves switch). Business need: none. Long-term: two spellings of one scope (ruling 6303721609 ③ refused it), the bind cliff stays on analytics, and the engine's nested composition would receive a list from getReadFilter(master) while the middleware writes a leaf — the drift this method exists to prevent. Not recommended."
],
"recommendation": "A, because it is inside the claimed surface, is the direction a prior maintainer ruling fixed for this strategy, keeps one composition owner and answers the find path's rows on every analytics query; the one visible change — the dry-run echo refusing for controlled_by_parent objects — is a loud answer on a debugging aid, not a row change, and is named in the PR as a deviation. If the maintainer wants the echo to keep rendering for these objects, that is B's work and a separate card."
}
],
"deferred_card_repricing": "#25106 is left unaffected in shape by this card: it still edits getReadFilter and computeDelegatedReadFilter, which the switch leaves as two of the four call sites of the derivation. After the switch those methods answer a leaf-carrying scope, so #25106's pins must read rows through the engine (a leaf composes only there) rather than match an in-memory filter — a heavier test rig, not a different fix. Its serial order after this card stands.",
"measurements_local": "All under /tmp/claude-0/-home-user/baba1a33-5813-5ac5-906a-64b704dc6695/scratchpad/issue-24932/dev/: base.txt (BASE=f2b6cf230); measure-main.txt and measure-main2.txt (today: rows per persona, statements, the bind limit, P3 admission readings — the second run adds the delegator's stored grant); measure-turso-main.txt (P7, both faces); measure-analytics-main.txt (P5 today, three faces); leaf-leg.sh and leaf-leg.txt (the two held mutations, on-disk counts, the restore proof); measure-leaf.txt (P2 / P3 / P4 under the leaf, rows and statements); measure-leaf-analytics.txt (P5 under the leaf); build-closure.txt; gates-derive.txt; the thread and PR bodies as issue.json, comments.json, c-*.md, pr-25032-body.md, pr-25072-body.md, c25010-*.md; harness copies harness-main.test.ts.txt, harness-analytics.test.ts.txt, harness-turso-probe.test.ts.txt.",
"out_of_scope_findings": [
"carrier: #24932 (this card's decision) · noted, not filed · Under the leaf, a metadata cycle (two controlled_by_parent objects mastering each other) and a chain past the depth bound answer INTERNAL_ERROR / 500 from the engine's own cycle and depth refusals (READABLE_IDS_MAX_CHAIN_DEPTH, engine.ts:6479-6498), where today's derivation answers zero rows with an error log naming the cycle or the bound (its $in: [] doors, controlled-by-parent-chain.test.ts pins both). The switch keeps both doors as closed as today through a registry-only walk of the master chain before answering the leaf (no store work), so the engine never meets either; stated here so the seat can object before it is built.",
"carrier: the switch PR (Tier H text) · noted, not filed · Beyond ADR-0055 :74-:75, three more sentences of that record describe the id list and go stale with the switch: the status line's `§2's chosen mechanism (b) … stand`, the amendment's `What both halves do now — Read` paragraph (:120, the master set resolved through computeControlledByParentFilter as a third layer) and its `Cost … paid per hop` sentence (:123). The dispatch allows no other ADR text; the seat decides whether the switch's Tier H edit may name them.",
"carrier: the switch PR · noted, not filed · packages/spec/src/data/readable-ids.ts:50 and filter-comparand-shape.ts:432-444 say the engine is `the one caller that may pass admitReadableIds`; option A for P5 makes the analytics read-scope mirror a second caller of the same door (judging the scope the engine will run). Spec docblock, outside this card's spec surface (the conformance row only).",
"carrier: none · noted, not filed · On a delegated read the leaf form composes the master's scope once per parent (the engine asks each parent once per read) but the middleware's two halves each write a leaf over the same master, so the one statement carries the same subquery twice (`account in (select …) and account in (select …)`, measured). Correct rows, one redundant subquery per delegated read; a later simplification of the delegator half, not this card's.",
"carrier: none · noted, not filed · driver-turso and driver-sqlite-wasm have no vitest alias for @objectstack/spec, so the nested conformance row reaches their suites only after a spec rebuild (scripts/ablation-dist-preflight.mjs before any verdict); driver-sql and driver-memory alias spec to src. A test-rig fact for the switch PR, not a defect."
]
}
[Decision] Sub-question of ruling B: two declarable master shapes that the leaf cannot serve, and the analytics raw-SQL door
Ruling-ref: 6303721609 (letter B stands; only the sub-question that its premise "once for every shape" falsifies is put here)
domain:services seat 2 (seat post #21118) · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-21T14:41Z. Evidence: the dev report 6318648840. It is measurement only, on the real stack (ObjectQL + SqlDriver/better-sqlite3 + SecurityPlugin + SharingService) at origin/mainf2b6cf230. No code was committed.
Release: session_01UmqzD97e14iws6hmVhgZ5y · Cause: the dispatch's stop conditions fired (P3 and P5 fork), and P3 sets an Accepted ADR rule against the ruling's text, which is the maintainer's to settle · Goes to: needs-user-decision; this write clears the assignee. Branch claude/issue-24932-cbp-leaf-switch is empty (at f2b6cf230, no commits). The measurements and harness copies are kept in the seat's scratchpad.
ADR-0139 D5 (Accepted), verbatim: "Each of the two remaining classes is recognised by the engine, which refuses to compose a leaf for it (loud, as D3's envelope), and routed by the consumer to today's probe before the leaf is written. ⛔ No silent fallback: the probe is chosen up front, never reached by catching a refusal." Also: "D5.3 … Recorded as a limit, not solved."
Ruling B (6303721609) ①: "once for every shape … and the id-list pre-scan retires". ③: "⛔ Not taken: A as a stop-gap (two spellings of one scope during the transition)".
The conflict: if D5 is followed as written, controlled_by_parent keeps the id list for these two classes, which falls short of ① "retires". If ① is followed, D5's routing sentence needs an amendment for the seventh consumer.
Activity
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 3 · 2026-10-20T18:22Z
Session:
session_01UmqzD97e14iws6hmVhgZ5yAccount:
os-support-ai(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24932-cbp-master-scopeWorktree:
objectstack-issue-24932Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface:
packages/plugins/plugin-security/src/security-plugin.ts(computeControlledByParentFilterand its two call sites' per-request reuse only), pins beside it inplugin-security, one new.changeset/24932-*.md. ⛔ Notpackages/objectql/**, ⛔ notpackages/drivers/**, ⛔ notpackages/spec/**: if the semi-join needs an engine or driver change, stop and report the design fordomain:engine. Stop on breach and explain in the report.Container & model:
M,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron 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) touchesservice-messagingandplugin-authonly.⛔ 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
objectstack-fleet commentedon Oct 20, 2026
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
objectstack-fleet commentedon Oct 20, 2026
[Decision] 主从(controlled_by_parent)子对象读取:是否改用 ADR-0139 的半连接叶子(O5),以及顺序
domain:servicesseat 2 (seat post #21118) ·os-support-ai·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T18:50Z。依据:本卡开发报告6298477402(只测量、未提交代码;origin/main223ae939b,在真实 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。引擎里唯一能改成子查询的现成写法,又会在三种情况下改变用户看到的行。背景(实测)
select id from 主表;两级主从链是 8 条;3.3 万主记录时查询和计数都返回 500too many SQL variables(SQLite 上限实测为 32,766)。Governing text
前提(复核命令)
git grep -n "computeControlledByParentFilter" origin/main -- packages/plugins/plugin-security/src/security-plugin.ts,其定义体内对主对象的find无limit。git grep -n "O5" origin/main -- docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md(应命中开放问题一行)。选项
getReadFilter一次切完IN绑成一个参数json_each/= ANYIN业务含义直译: A =「先修最常见的单层主从,复杂形状照旧」;B =「先把引擎补全,再一次性全部换掉」;C =「坏了的时候说清楚为什么坏」;D =「把长名单塞进一个信封里递过去,名单本身还是要先抄一遍」。
推荐:B(终态一次付清),#24962 先行
getReadFilter共享这套逻辑,正是为了防止这种漂移。os-decision-facets
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.)裁后执行
pm:blocked等这两张卡,之后派发 plugin-security 整体切换。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 照常走安全通道,优先。Generated by Claude Code
6 remaining items
objectstack-fleet commentedon Oct 21, 2026
pm:queue→pm:blocked, by ruling B's own instruction (6303721609: "Thedomain:servicesseat's first act on this card, before any dispatch of the switch: file the twodomain:enginecards … then move this card topm:blockedwith oneBlocked-by:line per engine card.") ·domain:servicesseat 2 (seat post #21118) ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-21T00:13ZBlocked-by: #25009andBlocked-by: #25010.getReadFilterin one PR; the id-list pre-scan retires, and ADR-0055's "cached within the request" sentence is corrected in the same Tier H edit.6298477402) is put to the engine dev on objectql: the ReadableIds leaf composes for a delegated (on-behalf-of) caller, with the delegator intersection (ADR-0139 D5.1's exit), and ADR-0139 records O5 answered #25009, to measure and report.objectstack-fleet commentedon Oct 21, 2026
Note for card (c), the plugin-security switch ·
domain:engineseat 2 (seat post #20966) ·session_01QK6pdbxKZiHdFApL8RYVh9· 2026-10-21T06:51Z. ⛔ Not a claim, no label change.#25010 (PR #25072, card (b)(ii)) composes a
ReadableIdsleaf that sits inside a composed read scope. Its dev measured one driver gap that only this card's switch makes live. That dev report is on #25010; the measurement was taken on head549f0bcc8, through a probe over the spec's conformance rows with a composed leaf whose scope carries a composed leaf:TursoDriver's local face answers the nested semi-join (rowsc1, c3, c6, c7). driver-sql (applyFilters) and driver-memory (convertToMongoQuery) compile the nested scope by construction.INTERNAL_ERROR/ 500: "[driver-turso] where.id.$in holds a ReadableIds leaf … composed, and driver-turso does not compile it: the remote transport compiles it only as TursoDriver resolved it".TursoDriver.toRemoteWhereresolves only top-level composed leaves (replaceComposedReadableIdsdoes not descend into a composed leaf's scope), and hands the nested one to the transport throughtoRemoteFilter.controlled_by_parentto the leaf. After the switch, a two-levelcontrolled_by_parentchain read on a remote Turso datasource would answer 500.toRemoteWherewith the outer read's options, plus a nested case inpackages/spec/src/data/readable-ids-conformance.ts, so every driver suite measures the shape.This comment is the record (no card filed, since no reach yet). Whichever PR makes the switch owes the fix before, or with, that switch.
Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
Unlock:
pm:blocked→pm:queueTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-21T10:03Z. ⛔ Not a claim.Both blockers have closed
completed:Blocked-by: #25009at 2026-10-21T04:38Z, andBlocked-by: #25010at 2026-10-21T07:52Z (PR #25072). Thedomain:engineseat's note of 2026-10-21T06:51Z describes the leaf this card's plugin-security switch now reads.Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
Claim: PM loop round 1 (re-seated term) · 2026-10-21T13:54Z · p1,
domain:services, card (c) of ruling B (6303721609): the plugin-security switch to the ADR-0139 ReadableIds leafSession:
session_01UmqzD97e14iws6hmVhgZ5y(the dispatching seat; the dev is amode:subagentrun of this session, told apart by its branch)Account:
os-support-ai(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24932-cbp-leaf-switch(new; ruling B: the emptyclaude/issue-24932-cbp-master-scopeis not reused)Worktree:
objectstack-issue-24932Domain:
domain:servicesSeat:
domain:services#2File surface: the dev re-measures first; a breach stops the work and is explained in the report.
packages/plugins/plugin-security/src/**:computeControlledByParentFilterand its four call sites (the engine middleware's principal and delegator halves,getReadFilter,computeDelegatedReadFilter), and the plugin's tests.packages/services/service-analytics/src/**: only if the raw-SQL door must accept the composed leaf thatgetReadFilterwill now answer (read-scope-sql.ts/read-admission.ts).packages/drivers/driver-turso/src/turso-driver.tstoRemoteWhere, one line, plus its test. This is a cross-lane surface (domain:engine). It enters through the ownerless-blocking-item channel: the engine seat's note6309967905on this card hands that fix to "whichever PR makes the switch … before, or with, that switch", and no card carries it.packages/spec/src/data/readable-ids-conformance.ts: one nested-leaf row (the remedy in objectql: a ReadableIds leaf nested inside a composed read scope (a two-level controlled_by_parent chain) is composed, instead of reaching the driver uncomposed #25010's dev report). Cross-lane (domain:spec), and a test fixture only: no schema, no accept-set change.docs/adr/0055-master-detail-controlled-by-parent.md:75, the "cached within the request" sentence corrected to what the code does, by ruling B ② (c). This is Tier H: the PR waits for the maintainer's approval..changeset/.Container & model:
M,mode:subagent,model: CONTRACT_REVIEW_TIER(ceiling; reason: a security read-scope switch on everycontrolled_by_parentread, with the delegator intersection and the raw-SQL door. This run'sdispatch-gates.mjs --tier: "no path-derived mandate")Clause-②: no — the switch respells one existing scope (an id list becomes the engine's semi-join leaf). It widens no accept set, adds no public name, and must keep every row set; ruling B ④ forbids dropping the predicate. A measured row-set change is a fork for the dev to report, not to absorb.
Responsibility:
plugin-security's computeControlledByParentFilter (this lane's code) materialises the master id list unbounded | the engine's ADR-0139 ReadableIds leaf, which since #25009 / #25010 composes for delegated and nested callers, and which this card adopts | every reader of a controlled_by_parent child (HotCRM crm_contact, crm_quote) at a master count past the dialect's bind limit; used todayRuling-ref: 6303721609
Thread-read: 6313320410
Serial constraints cleared:
security-plugin.ts: seat 1's post ([PM seat] domain:services — 🟢 os-justin #6021) records thecomputeControlledByParentFilterregion as this card's; its next card plugin-security: the controlled_by_parent detail write gate may not hold one more value of a master reference to ADR-0055 §1 and §3 (reach not measured; detail withheld under the disclosure ruling) #25095 (the detail write gate, a different region) is queued, not dispatched. In flight: deps(plugin-auth): lift the better-auth family from 1.7.3 to 1.7.12 — security fixes published after the 2026-10-01 deferral #25096 / PR fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 #25109 (plugin-auth dependency lift), which is disjoint. 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 (queued, p2) sharesgetReadFilter/computeDelegatedReadFilter. Fold or serial: serial, because fold gate ① fails (an organization-membership check vs. the id-list scan, a different defect shape and a different fix); 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 is dispatched after this card lands.driver-turso: the engine epic refactor(drivers,objectql,plugin-security)!: 18.x step 7: retire the organization-less readers (8c), COALESCE and __global__ (8d), and steps 1–3's dual reads #24583's PR feat(driver-sql,driver-turso,driver-memory,objectql,cli,metadata-protocol): plain organization key part in new unique indexes; the differ reads COALESCE(tenant) as tenant on a certified database (18.x step 7b-1 + 7b-2) #24893 editsturso-driver.tsaway fromtoRemoteWhere, and PR feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940 does not edit it; that is ordinary concurrency, and whichever lands second mergesmain. No open PR touchesreadable-ids-conformance.ts, ADR-0055 orservice-analytics.objectstack-fleet commentedon Oct 21, 2026
os-dev-report
{ "issue": 24932, "status": "needs_decision", "branch": "claude/issue-24932-cbp-leaf-switch", "pr": null, "session": "session_01UmqzD97e14iws6hmVhgZ5y — the dispatching seat's session (mode:subagent shares it; this run is told apart by its branch)", "premise_still_valid": true, "summary": "All seven premises were measured on the real stack (ObjectQL + SqlDriver on better-sqlite3 + the real SecurityPlugin and SharingService, origin/main f2b6cf230) before any edit, and two of them fork, so by the dispatch's stop conditions no switch was built: no tracked file was edited, the branch is empty at f2b6cf230, no PR. P3 forks: the declaration path admits a controlled_by_parent master of both classes the engine refuses by name (ADR-0139 D5.2 and D5.3, engine.ts:6550-6558 on main). ObjectSchema.parse admits a private master declared on another datasource together with its controlled_by_parent child, and a controlled_by_parent child whose master is sys_attachment (an object-scoped-middleware object); engine.registerApp admits a child whose master is bound explicitly to a second registered datasource and a child whose master carries an object-scoped read middleware; today's id list serves both (viewer xc1,xc2 and wc1,wc2; owner xc1 and wc1; delegated xc1 and wc1), and the leaf form answers INTERNAL_ERROR / 500 for every caller on both. No shipped producer: HotCRM e6d5d3e declares six controlled_by_parent objects (crm_contact, crm_quote, crm_contract, crm_quote_line_item, crm_opportunity_line_item, crm_event_attendee), every master a crm_* object on the one datasource with no object-scoped middleware; in-repo declarations outside tests: none. P5 forks: with the leaf as getReadFilter's answer, every analytics face answers READ_SCOPE_COMPILE_FAILED / 500 where today it answers the find path's rows — the native raw-SQL face ($in needs an array), the engine-bound face (assertReadScopeComparandsRunnable refuses the branded leaf as a non-list comparand before engine.aggregate could compose it) and the /analytics/sql echo. Compiling the leaf on the raw-SQL face itself needs the engine's composition and the driver's tenant wall (outside the surface); the in-surface design (the native strategy declines a leaf-carrying scope and routes to the engine face, as it declines a field-reference comparand by the 2026-08-12 ruling; the engine-bound guard admits the branded leaf with the engine's own option) changes the echo's answer for controlled_by_parent objects, so it is put to the seat rather than built. P1, P2, P4, P6 and P7 hold (readings below). The leaf form was measured through two held mutations of security-plugin.ts (scripts/ablation-replace.mjs), restored with blob == HEAD 0fe4494afce3 and git diff HEAD empty; it answers 1 statement per find and 2 per find+count with no master id scan, the same rows as today for the plain, restricted, delegated, nested-chain, null-FK, dangling-FK and child-only callers, and 200 with exactly bc1,bc2 (count 2) past 33,000 masters where today answers DATABASE_ERROR / 500. Two more doors differ under the leaf and are part of the switch's design, not a fork: a metadata cycle and a chain over the depth bound answer zero rows today (the plugin's own $in: [] doors) and INTERNAL_ERROR / 500 from the engine's cycle and depth refusals under the leaf; the switch keeps both doors as closed as today through a registry-only walk (no store work).", "premises": { "P1": "HOLDS. computeControlledByParentFilter (security-plugin.ts:8799 on f2b6cf230; the scan at :8906-8910) runs ql.find(rel.master, { where: masterFilter ?? {}, fields: ['id'], context: { isSystem: true } }) with no limit and answers { fk: { $in: ids } }. Measured: find+count on a controlled_by_parent child = 4 statements, 2 of them `select id from msr_account` (unrestricted scope: `select id from msr_account`; restricted: `… where owner_id = ?`); a delegated caller = 6 statements, 4 master id scans (two per half); a two-level chain = 6 statements, 4 pre-scans (grand, then mid with the grand ids bound). At 33,000 masters find and count both answer DATABASE_ERROR / 500, the driver log reading `too many SQL variables`.", "P2": "HOLDS (measured under the leaf mutation, restored). A plain caller: unrestricted c1,c2 / restricted c1, one statement `select * from msr_contact where ((account in (select id from msr_account [where owner_id = ?])))`. A delegated caller with the delegator intersection: c1, one statement carrying both halves as two subqueries over the master. A leaf nested in a composed scope (two-level chain): l1 for the owner, l1,l2 for a view-all caller, one statement `… mid in (select id from msr_mid where grand in (select id from msr_grand where owner_id = ?))`. find=1, find+count=2, master id scans=0 for every shape.", "P3": "FORKS — both classes are admitted. Spec: ObjectSchema.parse admits { sharingModel: private, datasource: 'other' } beside its controlled_by_parent child, and a controlled_by_parent child whose master_detail references sys_attachment. Engine: registerApp admits a child whose master is bound to a second registered datasource (resolveDatasourceBinding answers { datasource: 'other', via: 'explicit' } for the master and the default driver for the child) and a child whose master has an object-scoped read middleware (hasObjectMiddleware true). Today's id list serves rows on both (viewer xc1,xc2 and wc1,wc2; owner xc1 and wc1; delegated xc1 and wc1; the masters read directly x1,x2 / x1 and w1). Under the leaf both answer INTERNAL_ERROR / 500 by name: `… composes no such leaf over 'msr_xmaster', which is bound to datasource 'other' …` and `… over 'msr_mwmaster', whose rows an object-scoped read middleware narrows …` (engine.ts:6550 and :6554-6558). Shipped compositions: HotCRM e6d5d3e, 6 controlled_by_parent objects, every master a crm_* object on the single datasource, no middleware-gated master, no datasource or datasourceMapping declaration anywhere in its src; in-repo declarations outside test fixtures: none.", "P4": "HOLDS. A caller holding read on the child and no object-level read on the master: today [] on the child (direct master read PERMISSION_DENIED / 403), the leaf form [] too (canReadObject false ⇒ $in: []). The plugin's own door (objectReadAdmission before the scan, #24962) and the leaf's door (canReadObject at ADR-0139 D2 step 1) close identically for a plain caller, and for a delegated caller the leaf's one canReadObject(delegated context) equals today's two halves (principal AND delegator).", "P5": "FORKS. Today all three analytics faces answer the find path's rows (viewer c1,c2; owner c1; delegated c1; child-only []), the native face running the id list as a bound IN. Under the leaf: native face READ_SCOPE_COMPILE_FAILED / 500 `$in for \"account\" needs an array (fail-closed)` (read-scope-sql.ts compileOperator, :2102-2103); engine-bound face READ_SCOPE_COMPILE_FAILED / 500 from assertReadScopeComparandsRunnable (:1076-1079, assertListComparandShapes without admitReadableIds: `Operator $in on field account requires an ARRAY of values. Received object …`), before engine.aggregate could compose the leaf; the /analytics/sql echo refuses the same way; the child-only caller keeps [] on every face (the deny sentinel, unchanged). What the door needs: the engine-bound merges (objectql-strategy.ts withReadScope :900 and the FK attribute map :1422) need assertReadScopeComparandsRunnable to pass admitReadableIds: true (the option the engine's own ingress door passes, objectql/filter-comparand-shape.ts:123) — normalizeFilterComparandTypes skips a non-array $in by design, assertReadScopeCannotVacate reads no finding on it, and judgeFilter runs the engine's admitting pipeline — after which engine.aggregate composes the leaf at its boundary; the raw-SQL face (compileScopedFilterToSql) cannot compile a leaf without the engine's composition (canReadObject + getReadFilter per parent, recursive, the cycle and depth guards) and the driver's tenant wall for the master (driver-sql applyTenantScope with the outer options) — work outside the claimed surface — so the in-surface shape is the ruled precedent: NativeSQLStrategy.canHandle declines a scope carrying a leaf and the query routes to the engine face, and the dry-run echo refuses in its withheld envelope for controlled_by_parent objects where today it renders the bound list. Reported, not built.", "P6": "HOLDS. docs/adr/0055-master-detail-controlled-by-parent.md:75 reads `One extra master-id query per controlled_by_parent object per request (cached within the request)`; the code has no request cache (find then count scan the master twice, measured in P1). :74 (`large-tenant scale … is a known limit — a future share-table/join mechanism would replace the id list`) is made false by the switch and would be corrected in the same edit.", "P7": "HOLDS. Probe over the spec's conformance rows with a composed leaf whose scope carries a composed leaf (`record_id in composed(P, { id: { $in: composed(P, { owner: 'u1' }) } })`): TursoDriver local face answers c1,c3,c6,c7 (count 4; under tenantId org_a c1,c3,c7); the remote face refuses INTERNAL_ERROR / 500 `[driver-turso] where.id.$in holds a ReadableIds leaf … composed, and driver-turso does not compile it: the remote transport compiles it only as TursoDriver resolved it`. TursoDriver.toRemoteWhere (turso-driver.ts:3457-3466) resolves the leaf's scope through toRemoteFilter only, and core's replaceComposedReadableIds returns a composed leaf unentered (readable-ids-driver-refusal.ts:132), so the nested one reaches the transport unresolved. The one-line remedy (resolve `leaf.scope` through toRemoteWhere with the outer read's options) and the conformance row are ready to land with the switch." }, "tests": "Measurement only, nothing committed; the worktree is clean (git status --porcelain empty, security-plugin.ts blob 0fe4494afce3 == HEAD). Harnesses (removed from the worktree, copies kept): /tmp/claude-0/-home-user/baba1a33-5813-5ac5-906a-64b704dc6695/scratchpad/issue-24932/dev/harness-main.test.ts.txt (rows, statements, the bind limit, the P3 pairs, the cycle and depth chains), harness-analytics.test.ts.txt (the three analytics faces over the same stack), harness-turso-probe.test.ts.txt (both Turso faces). Runs, every one through scripts/pm/os-verify-lock.sh: closure build `pnpm exec turbo run build --filter='@objectstack/plugin-security...' --filter='@objectstack/service-analytics...' --filter='@objectstack/driver-turso...' --filter='@objectstack/driver-memory...' --filter='@objectstack/driver-sqlite-wasm...' --concurrency=2` 22 tasks successful, VERDICT command-exit 0 (237 s); today's main harness VERDICT command-exit 0, Tests 4 passed (4) (measure-main2.txt); Turso probe Tests 2 passed (2) and analytics harness Tests 1 passed (1), sequenced with ';' under one acquisition (VERDICT batch-last-exit 0; each part's own exit recorded in its log: turso=0, analytics=0); the leaf leg (leaf-leg.sh under the lock, VERDICT command-exit 0): two held mutations of security-plugin.ts through scripts/ablation-replace.mjs — `ok mutation landed: anchor 1 → 0, blob 0fe4494afce3 → dca16daa31b7` (the import) and `anchor 1 → 0, blob dca16daa31b7 → fe26c47aa78f` (the scan replaced by the leaf answer; on disk: leaf-return count 1, scan-anchor count 0), main harness main=0 Tests 4 passed, analytics harness analytics=0 Tests 1 passed, then `ok restored: blob == HEAD (0fe4494afce3) and git diff HEAD is empty`. Both harness suites resolve @objectstack/objectql and @objectstack/driver-sql from src through the package's vitest alias, so no dist was on the mutation's resolution path. Not re-run: the landed suites of #25032 / #25072 (their pins stand on main). NOT MEASURED: Postgres, MySQL, a live Turso remote; a tenancy-enabled master.", "gates": "No diff, so no family is owed. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) in the worktree: exit 2, `this branch changes nothing against 'origin/main' (merge base f2b6cf230) — nothing to derive`. No PR, so no check-runs.", "mcp_calls": "0 — no MCP GitHub tool was called; reads were `gh api` REST GETs (issue 24932 and its comments, issue 25010 and its comments, pulls 25032 / 25072 and their files, pull 24893 and its files).", "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/24932/comments (this report, via scripts/pm/post-stamped.mjs through the fleet-write relay as objectstack-fleet[bot]). Not a REST write: `git push -u origin claude/issue-24932-cbp-leaf-switch`, the empty-branch write probe (head f2b6cf230, one push). pr_create not used.", "line_budget": "n/a — no file changed", "deviations": [ "No PR and no code: needs_decision by the dispatch's stop conditions (P3 and P5 fork). The remote branch claude/issue-24932-cbp-leaf-switch stays empty at f2b6cf230; ⛔ not reused for the empty old branch's work either.", "Three harness files were written into the worktree (packages/plugins/plugin-security/src/zz-measure-24932.test.ts, …/zz-measure-24932-analytics.test.ts, packages/drivers/driver-turso/src/zz-probe-24932-nested.test.ts), never committed, deleted before cleanup; copies in the scratchpad. The analytics harness imported service-analytics' src by a relative path from plugin-security — a harness-only spelling, never a committed test input.", "P2 was measured by a two-anchor held mutation of security-plugin.ts (the scan replaced by the leaf answer) rather than by re-running the landed engine suites; both mutations were restored and the restore proven (blob == HEAD, git diff HEAD empty, trap-free script with an EXIT/INT/TERM restore on the absolute path).", "Two harness runs were sequenced with ';' under one lock acquisition, so that acquisition's VERDICT is batch-last-exit; each part's exit code was captured into its own log before any pipe (turso=0, analytics=0, main=0).", "The delegated caller was built as a plain context carrying onBehalfOf { userId } with a stored sys_user_position grant for the delegator, not through resolveAuthzContext / assembleExecutionContext; the landed #25032 suite covers the assembled shape." ], "files_changed": [], "open_questions": [ { "question": "P3 — a controlled_by_parent master of a class the engine refuses by name (ADR-0139 D5.2: an object-scoped read middleware narrows its rows; D5.3: bound to another datasource) is admitted by the declaration path (spec parse, registerApp) and served by today's id list; after the switch such a child answers INTERNAL_ERROR / 500 for every caller. Measured: zero shipped producers (HotCRM 0 of 6, in-repo 0). Which answer does the switch give that shape?", "options": [ "A — The engine's named refusal IS the answer for these two classes: the switch answers the leaf for every controlled_by_parent object and the engine refuses D5.2 / D5.3 masters loudly at read time with the remedy in the message (today's behaviour for the six consumers' D5 classes, chosen by ADR-0139 D5); ADR-0055's corrected Consequence names the two classes as the one limit that replaces the id-list ceiling. Business need: none measured — no shipped producer declares either shape, so no reader loses rows. Long-term: one spelling of one scope (the ruling's end state), the D5 posture already ruled (`refuse loudly, no silent fallback`). AI-error-proofing: a loud, named 500 beats an id list that works until the bind cliff; but the parse still admits a declaration the runtime refuses, so a boot-time or authoring-time refusal would be the honest end state (option C). Startup focus: no new mechanism, implementation-first for a zero-pull declared shape. Effort: the switch as dispatched plus two pins (one per class) and one ADR sentence.", "B — Keep the id list for exactly these two classes, chosen up front inside plugin-security (ask the engine whether the master has an object-scoped middleware or another datasource binding; answer the bounded list for it, the leaf otherwise). Business need: none measured. Long-term: a second, permanent spelling of one scope — the drift getReadFilter exists to prevent and the shape ruling 6303721609 ③ refused for the transition; the bind cliff stays for that class. AI-error-proofing: neutral. Startup focus: larger — the datasource binding is not on the public IObjectQLEngine contract, so plugin-security would need a new engine door (engine lane). Effort: engine door + plugin-security routing + pins.", "C — Refuse the declaration where it is made: a controlled_by_parent child whose master resolves to another datasource, or to an object an object-scoped read middleware gates, is refused at app registration / os validate with the remedy named. Business need: none measured; it protects authors from a shape that today works until the bind cliff and tomorrow 500s. Long-term: declared equals enforced, the cleanest end state; both facts are deployment facts (datasourceMapping, package default datasource, lifecycle routing, middleware registrations), so the refusal lives in the engine/runtime boot, not in the spec parse. AI-error-proofing: best — loud before any data exists. Startup focus: a new runtime refusal in another lane; defensible as closing a declared-but-unenforceable shape, not as a new capability. Effort: an engine/runtime card after the switch." ], "recommendation": "A now, with C filed as the engine-lane follow-up only if the maintainer wants the refusal moved from read time to boot. Business need: zero producers measured, so no reader loses rows under A. Long-term: A keeps one spelling (the ruling's end state) and reuses the D5 refusal ADR-0139 already chose for exactly these classes; B reintroduces the two spellings the ruling refused. AI-error-proofing: A's refusal is loud and names the remedy; C would make it loud earlier, which is why it is the follow-up and not a reason to hold the switch. Startup focus: A adds nothing; B adds an engine door for a shape nobody declares." }, { "question": "P5 — with the leaf as getReadFilter's answer, every analytics face (native raw SQL, engine aggregate, the /analytics/sql echo) refuses a controlled_by_parent object's read scope READ_SCOPE_COMPILE_FAILED / 500 where today it answers the find path's rows. Compiling the leaf on the raw-SQL face needs the engine's composition and the driver's tenant wall (outside the claimed surface). May the switch carry the in-surface design, and with which answer for the echo?", "options": [ "A — In service-analytics only: (i) the engine-bound merges (withReadScope and the FK attribute map) admit the branded uncomposed leaf at assertReadScopeComparandsRunnable by passing admitReadableIds: true — the option the engine's own ingress door passes — so engine.aggregate composes it at its boundary exactly as a find does; (ii) NativeSQLStrategy.canHandle declines a query whose base or joined object's read scope carries a ReadableIds leaf, routing it to the ObjectQL strategy — the content-based decline the 2026-08-12 maintainer ruling (Q1 = B, recorded at canHandle) accepted for a field-reference comparand (security rules exist in one place, no copy, no new StrategyContext hook) and the one hasObjectMiddleware already drives; (iii) the /analytics/sql dry-run echo of a controlled_by_parent object refuses in its withheld READ_SCOPE_COMPILE_FAILED envelope (it runs no composition and must not render one it did not run), the shape the cross-field ruling kept (`only the dry-run face refuses, one consistent loud answer`). Business need: HotCRM dashboards over its six controlled_by_parent objects keep every row through the engine face; the echo of those objects changes from rendered SQL to a loud refusal. Long-term: one owner of the composition (the engine); the native face keeps declining what only the engine can judge. AI-error-proofing: neutral (nothing authorable). Startup focus: smallest, ~40 lines plus pins. Cost: controlled_by_parent objects leave the native-SQL strategy for the engine aggregate (same rows; the performance class of the two faces on those objects is unmeasured), and the spec docblock's `the one caller that may pass admitReadableIds` sentence gains a second caller, the engine's mirror.", "B — The raw-SQL face compiles the leaf itself: the engine exposes its composition as a contract door (compose the read scope of an object for a context, the chain, cycle and depth guards included) and service-analytics compiles the composed leaf as a semi-join with the master's tenant wall obtained from the driver. Business need: keeps controlled_by_parent objects on native SQL and the echo rendering. Long-term: a second compiler of the leaf beside driver-sql and the Turso remote transport, and a new engine contract member; against the 2026-08-12 ruling's direction for this strategy. AI-error-proofing: neutral. Startup focus: largest — engine, spec contract, driver-sql tenant-wall access, service-analytics; two lanes.", "C — getReadFilter keeps answering the bound id list (only the middleware halves switch). Business need: none. Long-term: two spellings of one scope (ruling 6303721609 ③ refused it), the bind cliff stays on analytics, and the engine's nested composition would receive a list from getReadFilter(master) while the middleware writes a leaf — the drift this method exists to prevent. Not recommended." ], "recommendation": "A, because it is inside the claimed surface, is the direction a prior maintainer ruling fixed for this strategy, keeps one composition owner and answers the find path's rows on every analytics query; the one visible change — the dry-run echo refusing for controlled_by_parent objects — is a loud answer on a debugging aid, not a row change, and is named in the PR as a deviation. If the maintainer wants the echo to keep rendering for these objects, that is B's work and a separate card." } ], "deferred_card_repricing": "#25106 is left unaffected in shape by this card: it still edits getReadFilter and computeDelegatedReadFilter, which the switch leaves as two of the four call sites of the derivation. After the switch those methods answer a leaf-carrying scope, so #25106's pins must read rows through the engine (a leaf composes only there) rather than match an in-memory filter — a heavier test rig, not a different fix. Its serial order after this card stands.", "measurements_local": "All under /tmp/claude-0/-home-user/baba1a33-5813-5ac5-906a-64b704dc6695/scratchpad/issue-24932/dev/: base.txt (BASE=f2b6cf230); measure-main.txt and measure-main2.txt (today: rows per persona, statements, the bind limit, P3 admission readings — the second run adds the delegator's stored grant); measure-turso-main.txt (P7, both faces); measure-analytics-main.txt (P5 today, three faces); leaf-leg.sh and leaf-leg.txt (the two held mutations, on-disk counts, the restore proof); measure-leaf.txt (P2 / P3 / P4 under the leaf, rows and statements); measure-leaf-analytics.txt (P5 under the leaf); build-closure.txt; gates-derive.txt; the thread and PR bodies as issue.json, comments.json, c-*.md, pr-25032-body.md, pr-25072-body.md, c25010-*.md; harness copies harness-main.test.ts.txt, harness-analytics.test.ts.txt, harness-turso-probe.test.ts.txt.", "out_of_scope_findings": [ "carrier: #24932 (this card's decision) · noted, not filed · Under the leaf, a metadata cycle (two controlled_by_parent objects mastering each other) and a chain past the depth bound answer INTERNAL_ERROR / 500 from the engine's own cycle and depth refusals (READABLE_IDS_MAX_CHAIN_DEPTH, engine.ts:6479-6498), where today's derivation answers zero rows with an error log naming the cycle or the bound (its $in: [] doors, controlled-by-parent-chain.test.ts pins both). The switch keeps both doors as closed as today through a registry-only walk of the master chain before answering the leaf (no store work), so the engine never meets either; stated here so the seat can object before it is built.", "carrier: the switch PR (Tier H text) · noted, not filed · Beyond ADR-0055 :74-:75, three more sentences of that record describe the id list and go stale with the switch: the status line's `§2's chosen mechanism (b) … stand`, the amendment's `What both halves do now — Read` paragraph (:120, the master set resolved through computeControlledByParentFilter as a third layer) and its `Cost … paid per hop` sentence (:123). The dispatch allows no other ADR text; the seat decides whether the switch's Tier H edit may name them.", "carrier: the switch PR · noted, not filed · packages/spec/src/data/readable-ids.ts:50 and filter-comparand-shape.ts:432-444 say the engine is `the one caller that may pass admitReadableIds`; option A for P5 makes the analytics read-scope mirror a second caller of the same door (judging the scope the engine will run). Spec docblock, outside this card's spec surface (the conformance row only).", "carrier: none · noted, not filed · On a delegated read the leaf form composes the master's scope once per parent (the engine asks each parent once per read) but the middleware's two halves each write a leaf over the same master, so the one statement carries the same subquery twice (`account in (select …) and account in (select …)`, measured). Correct rows, one redundant subquery per delegated read; a later simplification of the delegator half, not this card's.", "carrier: none · noted, not filed · driver-turso and driver-sqlite-wasm have no vitest alias for @objectstack/spec, so the nested conformance row reaches their suites only after a spec rebuild (scripts/ablation-dist-preflight.mjs before any verdict); driver-sql and driver-memory alias spec to src. A test-rig fact for the switch PR, not a defect." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
[Decision] Sub-question of ruling B: two declarable master shapes that the leaf cannot serve, and the analytics raw-SQL door
Ruling-ref: 6303721609 (letter B stands; only the sub-question that its premise "once for every shape" falsifies is put here)
domain:servicesseat 2 (seat post #21118) ·os-support-ai·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-21T14:41Z. Evidence: the dev report6318648840. It is measurement only, on the real stack (ObjectQL + SqlDriver/better-sqlite3 + SecurityPlugin + SharingService) atorigin/mainf2b6cf230. No code was committed.Release:
session_01UmqzD97e14iws6hmVhgZ5y· Cause: the dispatch's stop conditions fired (P3 and P5 fork), and P3 sets an Accepted ADR rule against the ruling's text, which is the maintainer's to settle · Goes to:needs-user-decision; this write clears the assignee. Branchclaude/issue-24932-cbp-leaf-switchis empty (atf2b6cf230, no commits). The measurements and harness copies are kept in the seat's scratchpad.一句话问题: 切换后,有两种「主记录所在位置」的写法今天能读出子记录,切换后会直接报错。这两种写法今天能声明,但实测没人用。问题是:这两种写法该保留旧路径、在读取时报错,还是在声明时就拒绝?另外,分析报表的原生 SQL 通道也要定一个配套口径。
背景(实测,
6318648840)切换本身成立: HotCRM 的 6 个主从对象(联系人、报价、合同、报价行、商机行、活动参与人)在新写法下:
分叉一(P3): 有两种写法今天能通过声明,读取也正常,切换后对所有人报 500:
原因是引擎对这两类主对象按名拒绝组合,即 ADR-0139 D5.2 / D5.3。实测拉动为零: HotCRM
e6d5d3e的 6 个主从对象 0 个属于这两类,仓内(测试之外)也没有任何此类声明。分叉二(P5): 分析报表有三条通道:原生 SQL、引擎聚合、
/analytics/sql试运行回显。切换后这三条通道读主从对象都会 500。原生 SQL 要自己编译新写法,就得有引擎的组合能力和驱动的租户墙,都超出本卡范围。开发报告里一处与源码不符(席位已读
edcc8ceae核对): 报告说选项 B「需要新的引擎接口」,这不成立。另两个消费者 plugin-audit(parent-record-read-gate.ts#planParentReads)和 service-storage 已经用现成的公开契约成员预先分流:IObjectQLEngine.hasObjectMiddleware与IDataEngine.resolveEffectiveDatasource。Governing text
6303721609) ①: "once for every shape … and the id-list pre-scan retires". ③: "⛔ Not taken: A as a stop-gap (two spellings of one scope during the transition)".service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 (Q1 = B), recorded atNativeSQLStrategy.canHandleand inservice-analytics/src/comparand-shape.ts:113and:297.是否改协议: 三个选项都要改 Tier H 文本,都需要你批准。
前提(复核命令)
git grep -n "hasObjectMiddleware(parent)\|resolveDatasourceBinding(parent)" origin/main -- packages/objectql/src/engine.ts(两处命中)。git grep -n "resolveEffectiveDatasource\|hasObjectMiddleware" origin/main -- packages/plugins/plugin-audit/src/parent-record-read-gate.ts。git -C hotcrm grep -c "datasource" -- 'src/**/*.object.ts'(0)。git grep -n "needs an array" origin/main -- packages/services/service-analytics/src/read-scope-sql.ts。问题一(P3):选项
planParentReads(行数未测)业务含义直译:
问题二(P5):席位拟按 #7598 先例执行 A,请同笔确认
/analytics/sql试运行回显对主从对象改为响亮拒绝,不再渲染 SQL。这是唯一可见变化,在调试工具上。getReadFilter继续给 ID 列表。这就是两套写法,裁决 B ③ 已否决。推荐:问题一取 C;问题二取 A
裁后执行段(席位按此执行,你只需定方向)
domain:engine卡(承本卡分叉),本卡挂pm:blocked+Blocked-by:该卡。getReadFilter返回的读取范围放行新写法,作者在报表里写的过滤条件一律保持今天的判定(钉子证明);admitReadableIds的调用方」一句同笔更正。:74、:120、:123)在同一份 Tier H 修改中逐句点名更正;os-decision-facets
Prior rulings read: controlled_by_parent federated,object-scoped middleware,readableids d5,nativesqlstrategy canhandle → 0 hits; ADR-0139 D5 (read directly, not a term hit); thread: 1 ruling(s) (6303721609)
相关: ruling B
6303721609; the engine cards #25009 (PR #25032) and #25010 (PR #25072); #24962 (PR #24985); #25106 (serial after this card); #7598 (the 2026-08-12 analytics ruling).