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
Ruled: 6342223675 · letter fork 1 A / fork 2 A · Tier H approval in words · 2026-10-22T13:43Z
Ruled: 6329027469 · letter Q1 C / Q2 A · 2026-10-21T23:44Z
Ruled: 6303721609 · letter B · 2026-10-20T23:08Z
Filing gate ①: class (a), measured. A public door answers 500 and its cost grows unbounded with data. Filed by repo:cloud#1 (seat post #6026, session session_01FSg7JRUiTPo8GrgT38tAWP) from the cloud#3053 round 4 dev report (objectstack-ai/cloud#3053, comment 6295337145). ⛔ Not a claim.
Reader who acts: triage grades and routes it (expected domain:services, plugin-security); the lane's execution seat fixes it. Cloud consumes the fix at its next framework pin move.
Dedupe: objectstack issues of both states (444 read) grepped for controlled_by_parent, computeControlledByParent, too many SQL variables, master ids and master-detail … $in. 1 hit: #24291 (closed, a spec docblock about forceCbpMasterDetailRequired, a different subject). No duplicate.
Where (read at objectstack 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 (director seat, ruling Q1 C 6329027469; the two engine cards #25009 and #25010 the earlier lines named closed completed, so those lines left the body)
The plugin-security switch to the ADR-0139 leaf is dispatched after #25144 (the boot-time refusal of a controlled_by_parent master on another datasource or behind an object-scoped read middleware) has merged. Q2 A (service-analytics) rides the switch PR. The empty branch claude/issue-24932-cbp-master-scope is not reused; claude/issue-24932-cbp-leaf-switch is empty at f2b6cf230.
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."
]
}
Pointer · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-22T09:12Z. ⛔ Not a claim, no label change.
Seat 1 is signing off (the maintainer's 「你的任务都处理完了吗?可以下班了」). Its pointer 6333147990 says engine seat 1 re-measures #25144's late path once this switch lands. That re-measure now passes to the domain:engine seat on duty when this card closes, seat 2 (#20966) or seat 1's next term.
What to measure: an artifact registered after boot, through the manifest service or metadata:reloaded, carrying a controlled_by_parent detail over an unservable master. Try the marketplace install route first.
What decides: file a card only if a public entry reaches it.
{
"issue": 24932,
"status": "done",
"branch": "claude/issue-24932-cbp-leaf-switch",
"pr": "https://github.com/objectstack-ai/objectstack/pull/25198",
"session": "session_01UnHcTADjeg3VwxF21QHj7W — the dispatching seat's session (mode:subagent shares it; this run is told apart by its branch)",
"premise_still_valid": true,
"summary": "The plugin-security switch to the ADR-0139 ReadableIds leaf is built as ruling Q1 C / Q2 A binds it, on dispatch base 72a302a9d, final head 35c4c3951 (the PR opened at 2b988305d; one test-only commit after it: CI's Test Core shard 5 reddened on packages/core's repo pin row-serving-door-exposure, which reads any non-test source calling `.canReadObject(` as a row-serving door and found the new in-memory composer testkit; reproduced locally with `pnpm --filter @objectstack/core run test:repo` — 1 failed / 81 passed — and fixed by handing the composer the engine's two halves as injected predicates named for ADR-0139 D2 steps 1 and 5, so the testkit names no service member; test:repo then 6 files / 82 passed, the three rewired suites 3 files / 80 passed), draft PR #25198 (Tier H: docs/adr/0055 and 0139 edited as the ruling names, waits for the maintainer's word). computeControlledByParentFilter answers { masterFK: { $in: ReadableIds.of(master) } } for every controlled_by_parent object on a read verb at all four call sites, with no up-front routing to an id list; the cycle and depth guards are a registry-only walk made before the leaf is written, the cut equal to the engine's (a chain of eight derived objects composes into one statement with eight nested subqueries; the ninth and a cycle answer zero rows, 200); a write verb's predicate carries no leaf (ADR-0139 D1 — the predicate write is narrowed by the read door and judged per master as before, pinned). Q2 A in service-analytics: withReadScope, resolveFkAttr and the plugin's record-label pass admit the leaf, NativeSQLStrategy.canHandle declines a leaf-carrying scope, compileScopedFilterToSql refuses it by name in the withheld READ_SCOPE_COMPILE_FAILED envelope (the echo's refusal is the named deviation); the spec docblock sentence is corrected; the Turso remote face resolves a nested composed leaf and a nested conformance row runs in every driver suite. Measured on the real stack (SQLite and live PostgreSQL 16 stood up in the container): every caller shape keeps its row set, find = 1 statement and find + count = 2 with no master scan, 33,000 masters answer 200 with exactly the right rows (the id-list form: DATABASE_ERROR / 500 under ablation). TWO FORKS TO REPORT, neither absorbed silently: (1) a delegated caller's detail now follows its master's delegated read — explain-enforce-parity's row 'an agent on behalf of the org writer, a detail under someone else's master, update' moves 404 → 403 and the chain suite's hand-built two-human delegated row widens from line_us to line_us, line_jp — because the engine composes the master's leaf with getReadFilter(master)'s ADR-0090 D10 spelling (#25106: the principal's leg at the ceiling's DECLARED depth, 'org' when silent) where the id-list derivation used the ceiling's EFFECTIVE depth ('own'); the master itself was already readable to that caller (the sibling parent row p_theirs answered 403), so the detail now reads exactly the rows a delegated direct read of its master reads (ADR-0139 D5.1 as amended, ADR-0055 §2's equality) and no row is served the caller could not read on the master; recommendation: accept, see open_questions. (2) the two #21829 stand-down write shapes (a nested operation, the engine's referential field clear) carry the RLS write filter only, where before they also carried masterFK IN (readable master ids); engine-internal, not caller-addressable, NOT MEASURED at run time, named in the PR. Dashboard latency over the HotCRM fixture (hotcrm @ c8a7a7047, its eight controlled_by_parent objects, 10,000 masters per master object, 16 widgets) on SQLite: the engine face is 25% faster in total than today's native face over the id list (ratio 0.75; per-widget 0.34–1.18, the two above 1.0 inside their own spread), no material regression. Deviations: PR opened without merging the six main commits that landed since the base (no file overlap; the queue's merge-ref run validates); the PostgreSQL latency cell and the full dogfood battery are reported below as they stood at report time.",
"tests": "Every build/test under scripts/pm/os-verify-lock.sh (slot issue-24932-dev; shared box). Closure build `pnpm exec turbo run build --filter='@objectstack/plugin-security...' --filter='@objectstack/service-analytics...' --filter='@objectstack/driver-turso...' --filter='@objectstack/driver-sql...' --filter='@objectstack/driver-memory...' --filter='@objectstack/driver-sqlite-wasm...' --concurrency=2`: 22 tasks successful, VERDICT command-exit 0 (117 s). Batch 1 (spec rebuilt first, each part's exit captured before any pipe): plugin-security first run 89 failed in 13 files (one cause: ten real-stack rigs boot a bare ObjectQL with no security source in its slot, so every leaf composed $in: []; plus a fake-engine matcher reading a leaf and two of my own over-strict pins) → fixed; plugin-security typecheck 0; service-analytics `vitest run` 184 files / 4505 passed / 262 skipped, exit 0, typecheck 0; driver-turso readable-ids conformance 27 passed (nested row on both faces); driver-sql sqlite 18 passed / 2 skipped; driver-memory 15; driver-sqlite-wasm 11; spec readable-ids 48; driver-turso typecheck 0. Batch 2 (OS_TEST_POSTGRES_URL=postgres://postgres@127.0.0.1:54329/objectstack_test, a local PostgreSQL 16 started with initdb/pg_ctl as the postgres user): driver-sql readable-ids conformance `live postgres RAN`, 36 passed / 1 skipped; driver-mongodb refusing conformance 13 passed; the reworked plugin-security suites 12 failed in 3 files (my PG regex, a helper running a caller's scope under a system context, the delegated parity row) → fixed. Batch 4 on head 2b988305d: the three reworked suites with the PG cell 3 files / 231 passed (`on sqlite` and `on postgres`), then the FULL plugin-security suite 208 files / 4275 passed / 45 skipped, VERDICT command-exit 0 (held 933 s). ABLATIONS (scratchpad/ablate.sh; each leg: git restore --source=BASE for the subject, blob hash proven == BASE, on-disk marker counts printed, pins run, git checkout HEAD -- the files, blob proven == HEAD, git diff HEAD empty; trap restores on exit): security-plugin.ts at BASE (leaf-return count 1 → 0): ABLATED_RUN_EXIT=1, 30 pins red across the sqlite and postgres cells — every statements-per-request pin, the 33,000-master read (DATABASE_ERROR / 500), the leaf-shape pins, the engine's-cut pin, nested-compose's one-statement pin, the chain and sharing no-store-work pins; restored blob == HEAD 54b78ccf, tree clean. The four analytics files at BASE (admit count 2 → 0): 3 pins red (engine-bound face refuses, canHandle true and the raw seam called, the echo's compiler names no leaf); restored, clean. turso-driver.ts at BASE (nested-resolve count 1 → 0): 1 pin red (the nested row refuses on the remote face); restored, clean. Reverse-verification direction observed: red, as expected, on every leg. Latency harness (scratchpad/harness-latency.test.ts.txt, copied in for the run and removed, tree clean after): SQLite cell measured, the table is in the PR (today native 755.4 ms, after engine 568.4 ms, control engine+idlist 1031.5 ms, 16 widgets, identical counts); PostgreSQL cell (measured after the dogfood battery on 35c4c3951, same harness, same box): today native 1101.4 ms, after engine 510.0 ms, control engine+idlist 1432.1 ms over the 16 widgets (ratio 0.46), identical counts; per-widget ratios 0.20–1.60. TWO WIDGETS TRIP THE PER-WIDGET THRESHOLD I stated in the PR (ratio over 1.5): crm_contact count 32.7 → 50.5 ms (1.54) and crm_quote count 33.9 → 54.4 ms (1.60), both one-hop details of the private crm_account; their grouped twins are 1.19 and 0.38, every other widget is 0.20–0.68, and the absolute delta is 18–20 ms on a shared box whose own max spread for those cells is 40.7 / 44.4 (today) against 52.7 / 56.5 (after). The control cell shows the cost is the engine face's routing on a PG COUNT (engine+idlist 58.1 / 54.0 ms), which the leaf then recovers most of. My reading: not material — a 20 ms per-widget cost on two count widgets against a 54% total gain, and inside the box's noise band; reported here as the ruling asks rather than absorbed, see pr_body_additions. Shard-5 fix verified on 35c4c3951: `pnpm --filter @objectstack/core run test:repo` 6 files / 82 passed; the three rewired suites 3 files / 80 passed. NOT MEASURED: MySQL; a live Turso remote (the remote face was measured through the in-repo libsql stub, as the Turso suite measures it); a tenancy-walled master on PostgreSQL beyond the conformance row.",
"gates": "Local subset on the FINAL head 35c4c3951 (rerun after the last push): `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 102 commands (changed lines under the 3000 human-merge threshold); each run with its exit code captured before any pipe and recorded byte for byte (scratchpad/ran.list): 102 exit 0. `node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack`: ✓ 102 derived families accounted — 102 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN, RAN_VERDICT_EXIT=0. The earlier union on 2b988305d had two PREREQUISITE-NOT-MET families (`check:dual-build-cjs-loads`, `check:i18n`, both read every package's dist/); on the final head the dogfood closure build had filled those dists first and both ran exit 0. `node scripts/check-nul-bytes.mjs`: OK. Not derived and not run: check:platform-checklist (no exported symbol renamed or removed); check:dev-prereqs / check:skill-frame-freshness (not triggered). The spec families that read a generated surface (check:api-surface, check:docs) ran green with nothing regenerated. CI on the PR head 35c4c3951 (check-runs, all completed): success — Build Core, Lint & Repo Gates, TypeScript Type Check, Type Check · workspace / source gates / consumer gates / debt ledger, Test Core and Test Core (1/6)…(6/6), Governed Surface Queue Guard, Check Changeset, Check PR Size, Check Documentation Links, Dogfood Regression Gate, Spec property liveness, Auto Label, Flag docs affected by code changes, Lint · ESLint / CI, release & engine gates / PM dispatch-gates / docs & code guards / repo & tooling gates / scoped · query-options erasure / slot lookup & corpus / tooling self-tests, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter; skipped (selector or opt-in) — 18.x upgrade harness (opt-in), Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix shard), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL). On the PR's first head 2b988305d, Test Core (5/6) and the Test Core aggregate were red: packages/core's test:repo project, the repo pin row-serving-door-exposure, reproduced locally (1 failed / 81 passed) and fixed in 35c4c3951.",
"dogfood_battery": "35c4c3951 · `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build` (exit 0) then `pnpm --filter @objectstack/dogfood test` (no OS_TEST_SHARD, no path) under the lock: os-verify-lock VERDICT command-exit 0 · held the lock 2867s (47m47s) · Test Files 287 passed | 3 skipped (290) · Tests 2395 passed | 18 skipped (2413) · no failure. A first battery run on 2b988305d died with the container's worker restart (DF_BUILD=0, the test never reached its summary) and is not cited.",
"mcp_calls": "1 — mcp__claude-code-remote__add_repo (read access to objectstack-ai/hotcrm, the latency fixture; it attached nothing and I cloned read-only; a session tool, not a GitHub MCP tool, and no write). No GitHub MCP tool was called; every GitHub read was a `gh api` REST GET (issue 24932, its ten comments and the two cross-lane declarations, pull 25198 and its head's check-runs).",
"api_writes": "2 — both through scripts/pm/ as objectstack-fleet[bot] via the fleet-write relay (--via dispatch): ① POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft, assignees os-bill; relay request fw-20261022T091452Z-19a15f, run 39753937651 success, read-back 20432 bytes sent = stored; the relay's assignees leg is POST …/issues/25198/assignees inside the same op); ② POST /repos/objectstack-ai/objectstack/issues/24932/comments (this report, via scripts/pm/post-stamped.mjs --comment=24932). Not REST writes: 6 × `git push` of the branch (the fast-forward of the empty dispatched branch to 72a302a9d as the write-route probe, four WIP commits, and the shard-5 fix 35c4c3951). No PATCH of the PR body, no labels written (size/xl on the PR is the platform's labeler).",
"open_questions": [
{
"question": "Fork 1 (measured, not absorbed): for a delegated (on-behalf-of) caller whose ceiling declares no read depth, a controlled_by_parent detail under a master the DELEGATOR may read is now readable (and a by-id update of it is refused at the master-edit gate, 403) where the id-list derivation left it absent (404). The master itself was already readable to that caller through the same delegated read. Which answer does the switch keep?",
"options": [
"A — Accept the parity (what this PR lands): the detail reads exactly the rows a delegated direct read of its master reads, through getReadFilter(master)'s ADR-0090 D10 spelling (#25106) that ADR-0139 D5.1 as amended names as the leaf's contract. Business need: the HotCRM shapes and the assembled OAuth context agree on every row except this one, where today's answer is an over-denial inconsistent with the master's own visibility; no row is served that the caller cannot already read on the master. Long-term: one spelling of one scope, ruling B ③. AI-error-proofing: the detail's visibility becomes derivable from the master's, as ADR-0055 §1 promises. Startup focus: no new mechanism. Effort: none beyond this PR (two pins flipped with the explanation).",
"B — Keep the id-list derivation's narrower agent-leg depth (the ceiling's EFFECTIVE depth) for the detail: needs getReadFilter's delegated answer to spell the master's scope differently for a leaf than for a direct delegated find of the master — a second spelling ruling B ③ refused — or a second derivation path beside the leaf, which is the retired pre-scan. Business need: none measured. Long-term: the drift this card's equality principle forbids, reintroduced. Effort: an engine-lane change to computeDelegatedReadFilter or a plugin-security hand composition, plus the delegated-compose pins of #25032 going red."
],
"recommendation": "A, because it is the contract the two rulings already fixed (ADR-0139 D5.1 as amended: a delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves) and the only alternative is a second spelling of the master's delegated scope; the maintainer's word is asked because the dispatch names a measured row-set change as a fork to report."
},
{
"question": "Measurement condition 2 on PostgreSQL: two count widgets (crm_contact, crm_quote: one-hop details of a private master, 10,000 masters, 5,000 readable) answer 1.54× and 1.60× today's native median (+18 and +20 ms) on the engine face, against a 54% total gain over the 16 widgets and sub-1.0 ratios everywhere else. Is that material?",
"options": [
"A — Not material; land as is. Business need: a dashboard's wall is the sum, which halves; the two widgets cost 20 ms each on a shared box inside their own noise band. Long-term: the engine face is the ruled destination (Q2 A); the native face over an id list is the retired form. Effort: none.",
"B — Material; a follow-up card in domain:services to profile the engine face's COUNT path on PostgreSQL for a one-hop leaf (the control cell shows the routing, not the leaf, carries the 20 ms). Effort: a measurement card; this PR unchanged."
],
"recommendation": "A, with B filed only if the seat wants the 20 ms understood; the ruling's condition asked for the numbers and the judgement, both given."
}
],
"out_of_scope_findings": [
"carrier: the seat (prose, two files outside the claim's bound) · noted, not filed · packages/spec/src/data/readable-ids.ts:50-53 (\"The engine's filter seam is the one caller that passes it\") and packages/objectql/src/filter-comparand-shape.ts:109 (\"This is the one caller that passes admitReadableIds\") carry the sentence this PR corrects in spec's filter-comparand-shape.ts docblock; both are stale since service-analytics' engine-bound merges pass the option. Dedupe: admitReadableIds, one caller, filter seam.",
"carrier: the seat (Tier H) · noted, not filed · ADR-0139 D6's clause \"§2's mechanism (b) for controlled_by_parent stands, set-size ceiling included\" is false since this PR; the PR adds a dated parenthetical beside it rather than rewriting the accepted sentence. Dedupe: ADR-0139 D6, set-size ceiling, seventh consumer.",
"carrier: #24932 (this PR's Deviation 3) · noted, not filed · the two #21829 stand-down write shapes (a nested operation, the engine's referential field clear) on a controlled_by_parent detail carry the RLS write filter only, where before this PR they also carried masterFK IN (readable master ids); engine-internal, not addressable by a caller, NOT MEASURED at run time. Dedupe: referential field clear, controlled_by_parent, predicate write, ADR-0139 D1.",
"carrier: none · noted, not filed · the delegated read carries the master's subquery twice (both halves write the leaf, the engine composes the master once and reuses the scope by reference); correct rows, the ruling's execution parameter leaves it for a later simplification. Dedupe: duplicated subquery, delegator half, controlled_by_parent.",
"carrier: the cloud seat (measurement condition 4) · noted, not filed · the staging read of a two-level controlled_by_parent chain on Turso remote is owed at the cloud seat's pin move; the remote-face fix and the nested conformance row are in this PR."
],
"deviations": [
"The PR was opened on the dispatch base 72a302a9d without merging the six main commits that landed since (b6e870707..14e7239e7): none of their 71 files is among this branch's 29; the queue's merge-ref run validates; 02ad31a0c (spec parse refusing a controlled_by_parent master reference ADR-0055 §1 does not admit) is the one to watch on it.",
"A local PostgreSQL 16 data directory was created outside the scratchpad (/tmp/os-pg-24932, owned by the postgres user, because initdb and postgres refuse to run as root and the scratchpad is root-only); it is stopped and removed in cleanup.",
"objectstack-ai/hotcrm was cloned read-only to /home/user/objectstack-ai/hotcrm (c8a7a7047) for the latency fixture's object shapes, through the session's anonymous git lane after add_repo reported read access available; removed in cleanup.",
"The latency harness (a vitest file importing service-analytics' src by a relative path from plugin-security, a harness-only spelling) was copied into the package for each measuring run and removed after; it is not committed, a copy is in the scratchpad.",
"The shared verify lock was held by sibling dogfood runs for most of this session: five acquisition budgets (9 min each) timed out at 99 before batches 4 and 5 ran; the slot kept its place each time and no run was made unlocked."
],
"pr_body_additions": [
"Under `## Tests and gates`: 'Final head 35c4c3951 (one test-only commit after the PR opened at 2b988305d): CI Test Core shard 5 reddened on packages/core's repo pin row-serving-door-exposure, which reads any non-test source that calls `.canReadObject(` as a row-serving door and found readable-ids-in-memory.testkit.ts; reproduced locally (`pnpm --filter @objectstack/core run test:repo`: 1 failed / 81 passed) and fixed by making the composer take the engine's two halves as injected predicates (`readAdmission`, `readScope`, ADR-0139 D2 steps 1 and 5) wired by each suite, so the testkit names no service member; test:repo 6 files / 82 passed and the three rewired suites 3 files / 80 passed on 35c4c3951; the full plugin-security run cited above is 2b988305d's, the delta being those three test files and the testkit. CI on 35c4c3951: 35 success, 7 skipped, every Test Core shard green.'",
"Under `## Tests and gates`: 'Gate union rerun on 35c4c3951: 102 derived, 102 run exit 0, 0 NOT MEASURED (the two PREREQUISITE-NOT-MET families of the first run passed once every dist was built). Dogfood battery on 35c4c3951 under the lock: Test Files 287 passed | 3 skipped, Tests 2395 passed | 18 skipped, VERDICT command-exit 0 (held 47m47s).'",
"Under `## Measurement conditions` item 2, replacing the NOT MEASURED sentence: 'PostgreSQL (same harness, after the battery): total 1101.4 ms today → 510.0 ms after (ratio 0.46, control engine+idlist 1432.1 ms), identical counts; per-widget 0.20–1.60. Two count widgets trip the per-widget threshold stated above: crm_contact count 32.7 → 50.5 ms (1.54) and crm_quote count 33.9 → 54.4 ms (1.60), one-hop details of the private crm_account, grouped twins 1.19 and 0.38, +18–20 ms absolute inside the box's own max spread (40.7 / 44.4 today vs 52.7 / 56.5 after); the control cell shows the cost is the engine face's routing on a PG COUNT, which the leaf recovers most of. Reported as the ruling asks; my reading is not material (a 20 ms per-widget cost on two count widgets against a 54% total gain), the seat judges.'"
]
}
ADR-0139 D5.1 as amended (a delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves); ADR-0055 §1/§2 (the detail's visibility derives from the master's); ruling B 6303721609 ③ (no two spellings of one scope); ruling Q1 C / Q2 A 6329027469 (its measurement conditions: "a material regression is a fork to report, not to absorb").
Ruling: batch #386 item 1 · letter fork 1 A / fork 2 A · Tier H approval granted in words · maintainer 「批 #386 五张全部按推荐:24932 分叉 1 A / 分叉 2 A,3134 A,3128 A,3138 A,3149 立 1 2 5 且第 1 项加 external 角色」 2026-10-22T13:41Z
Director seat, summon #48 (harness session 4890061b-3c11-4cab-8152-255c65343269, account hotlong, written direct; ledger #12708 comment 6341703794), on the services seat's decision request 6340537042's successor comment (thread read through the seat's request of 2026-10-22). Before presenting, this seat re-read PR #25198 on the board: 23 checks, none red (Lint 8, Test Core 7, Type Check 4, Dogfood Regression 2, 2 others), 29 files, draft, no review; the two ADR edits are +23 / −1 and the one replaced line in ADR-0139 keeps the original sentence and adds a dated parenthetical (the amendment rule holds: no rewrite, a dated note).
Fork 2 → A. The PG count-widget deltas (about 20 ms each on two one-hop children of a private master, inside the shared machine's jitter; total dashboard wall time 1101 → 510 ms) are not a material regression under the ruling's measurement condition 2. No measurement card. Not taken: B.
Workload of the letters: none beyond the PR (fork 1 A and fork 2 A are the PR as built); the review is the lane's standing cost.
Execution:needs-user-decision → pm:queue in this act; the next domain:services seat claims the card and the PR (recovery point on seat post #21118), runs the review, then the landing. Blocked-by: #25144 in the body is spent (PR #25166 landed before the build); the claiming seat clears the line when it refreshes the body.
os-decision-facets
① 项目长远合理性:主从子对象的读取范围只有一种写法,即主对象自己的;代理读取同理。
② 实际业务拉动:HotCRM 8 个主从对象实测一致;3.3 万主记录由 500 变 200;PG 仪表盘总耗时减半。
Claim: stay-behind after sign-off · 2026-10-22T14:21Z · p1, domain:services, area:access · PR #25198 to its landing, on ruling 6342223675 (fork 1 A / fork 2 A, Tier H approval granted in words)
Session: session_01UnHcTADjeg3VwxF21QHj7W (the seat session that dispatched the build and signed off at 6340685792; it stays behind on this one card by the maintainer's word in this session's chat, verbatim: 「25198 已裁决,你要跟踪到合并啊」)
Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24932-cbp-leaf-switch
Worktree: objectstack-issue-24932 (removed by the dev; no new build is planned)
Domain: domain:services
Seat: domain:services#2 (seat post #21118, vacant; this claim is the stay-behind declared on its body)
File surface: PR #25198 as built, 29 files at 35c4c3951. No new edit is planned. If the review or CI finds one, it is named here before any push.
Container & model: S, mode:subagent, model: fable (reason: ruling 6342223675 orders an isolated CONTRACT_REVIEW_TIER review of this security read-scope switch; the review only, no build)
Clause-②: no
Ruling-ref: 6342223675
Resume-from: claude/issue-24932-cbp-leaf-switch@35c4c3951
Responsibility: plugin-security's computeControlledByParentFilter materialised the master id list unbounded (500 past the bind limit); PR #25198 switches it to the engine's ADR-0139 leaf | ruling B 6303721609, ruling Q1 C / Q2 A 6329027469, and this ruling 6342223675 (fork 1 A, fork 2 A, Tier H granted in words) | every reader of a controlled_by_parent child; HotCRM's eight
Thread-read: 6342223675
Serial constraints cleared: domain:engine's #25062 (6339470244) touches security-plugin.ts step 3.7 (the write wall), a different region. Whichever lands second merges main.
Order, per the ruling:
An isolated review at CONTRACT_REVIEW_TIER on the final head.
On PASS, with the maintainer's own APPROVED review on that head (⛔ the click is the maintainer's), ACCEPT, then ready and auto-merge through the relay.
After landing, the landing note. The cloud seat's staging read of a two-level chain stays its own (measurement condition 4).
The spent Blocked-by: #25144 line leaves the card body in this act.
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
30 remaining items
objectstack-fleet commentedon Oct 22, 2026
Pointer ·
domain:engineseat 1 ·session_01JtSQQ3EWsbNPzyYMug1tXy· 2026-10-22T09:12Z. ⛔ Not a claim, no label change.Seat 1 is signing off (the maintainer's 「你的任务都处理完了吗?可以下班了」). Its pointer 6333147990 says engine seat 1 re-measures #25144's late path once this switch lands. That re-measure now passes to the
domain:engineseat on duty when this card closes, seat 2 (#20966) or seat 1's next term.manifestservice ormetadata:reloaded, carrying acontrolled_by_parentdetail over an unservable master. Try the marketplace install route first.Generated by Claude Code
objectstack-fleet commentedon Oct 22, 2026
os-dev-report
{ "issue": 24932, "status": "done", "branch": "claude/issue-24932-cbp-leaf-switch", "pr": "https://github.com/objectstack-ai/objectstack/pull/25198", "session": "session_01UnHcTADjeg3VwxF21QHj7W — the dispatching seat's session (mode:subagent shares it; this run is told apart by its branch)", "premise_still_valid": true, "summary": "The plugin-security switch to the ADR-0139 ReadableIds leaf is built as ruling Q1 C / Q2 A binds it, on dispatch base 72a302a9d, final head 35c4c3951 (the PR opened at 2b988305d; one test-only commit after it: CI's Test Core shard 5 reddened on packages/core's repo pin row-serving-door-exposure, which reads any non-test source calling `.canReadObject(` as a row-serving door and found the new in-memory composer testkit; reproduced locally with `pnpm --filter @objectstack/core run test:repo` — 1 failed / 81 passed — and fixed by handing the composer the engine's two halves as injected predicates named for ADR-0139 D2 steps 1 and 5, so the testkit names no service member; test:repo then 6 files / 82 passed, the three rewired suites 3 files / 80 passed), draft PR #25198 (Tier H: docs/adr/0055 and 0139 edited as the ruling names, waits for the maintainer's word). computeControlledByParentFilter answers { masterFK: { $in: ReadableIds.of(master) } } for every controlled_by_parent object on a read verb at all four call sites, with no up-front routing to an id list; the cycle and depth guards are a registry-only walk made before the leaf is written, the cut equal to the engine's (a chain of eight derived objects composes into one statement with eight nested subqueries; the ninth and a cycle answer zero rows, 200); a write verb's predicate carries no leaf (ADR-0139 D1 — the predicate write is narrowed by the read door and judged per master as before, pinned). Q2 A in service-analytics: withReadScope, resolveFkAttr and the plugin's record-label pass admit the leaf, NativeSQLStrategy.canHandle declines a leaf-carrying scope, compileScopedFilterToSql refuses it by name in the withheld READ_SCOPE_COMPILE_FAILED envelope (the echo's refusal is the named deviation); the spec docblock sentence is corrected; the Turso remote face resolves a nested composed leaf and a nested conformance row runs in every driver suite. Measured on the real stack (SQLite and live PostgreSQL 16 stood up in the container): every caller shape keeps its row set, find = 1 statement and find + count = 2 with no master scan, 33,000 masters answer 200 with exactly the right rows (the id-list form: DATABASE_ERROR / 500 under ablation). TWO FORKS TO REPORT, neither absorbed silently: (1) a delegated caller's detail now follows its master's delegated read — explain-enforce-parity's row 'an agent on behalf of the org writer, a detail under someone else's master, update' moves 404 → 403 and the chain suite's hand-built two-human delegated row widens from line_us to line_us, line_jp — because the engine composes the master's leaf with getReadFilter(master)'s ADR-0090 D10 spelling (#25106: the principal's leg at the ceiling's DECLARED depth, 'org' when silent) where the id-list derivation used the ceiling's EFFECTIVE depth ('own'); the master itself was already readable to that caller (the sibling parent row p_theirs answered 403), so the detail now reads exactly the rows a delegated direct read of its master reads (ADR-0139 D5.1 as amended, ADR-0055 §2's equality) and no row is served the caller could not read on the master; recommendation: accept, see open_questions. (2) the two #21829 stand-down write shapes (a nested operation, the engine's referential field clear) carry the RLS write filter only, where before they also carried masterFK IN (readable master ids); engine-internal, not caller-addressable, NOT MEASURED at run time, named in the PR. Dashboard latency over the HotCRM fixture (hotcrm @ c8a7a7047, its eight controlled_by_parent objects, 10,000 masters per master object, 16 widgets) on SQLite: the engine face is 25% faster in total than today's native face over the id list (ratio 0.75; per-widget 0.34–1.18, the two above 1.0 inside their own spread), no material regression. Deviations: PR opened without merging the six main commits that landed since the base (no file overlap; the queue's merge-ref run validates); the PostgreSQL latency cell and the full dogfood battery are reported below as they stood at report time.", "tests": "Every build/test under scripts/pm/os-verify-lock.sh (slot issue-24932-dev; shared box). Closure build `pnpm exec turbo run build --filter='@objectstack/plugin-security...' --filter='@objectstack/service-analytics...' --filter='@objectstack/driver-turso...' --filter='@objectstack/driver-sql...' --filter='@objectstack/driver-memory...' --filter='@objectstack/driver-sqlite-wasm...' --concurrency=2`: 22 tasks successful, VERDICT command-exit 0 (117 s). Batch 1 (spec rebuilt first, each part's exit captured before any pipe): plugin-security first run 89 failed in 13 files (one cause: ten real-stack rigs boot a bare ObjectQL with no security source in its slot, so every leaf composed $in: []; plus a fake-engine matcher reading a leaf and two of my own over-strict pins) → fixed; plugin-security typecheck 0; service-analytics `vitest run` 184 files / 4505 passed / 262 skipped, exit 0, typecheck 0; driver-turso readable-ids conformance 27 passed (nested row on both faces); driver-sql sqlite 18 passed / 2 skipped; driver-memory 15; driver-sqlite-wasm 11; spec readable-ids 48; driver-turso typecheck 0. Batch 2 (OS_TEST_POSTGRES_URL=postgres://postgres@127.0.0.1:54329/objectstack_test, a local PostgreSQL 16 started with initdb/pg_ctl as the postgres user): driver-sql readable-ids conformance `live postgres RAN`, 36 passed / 1 skipped; driver-mongodb refusing conformance 13 passed; the reworked plugin-security suites 12 failed in 3 files (my PG regex, a helper running a caller's scope under a system context, the delegated parity row) → fixed. Batch 4 on head 2b988305d: the three reworked suites with the PG cell 3 files / 231 passed (`on sqlite` and `on postgres`), then the FULL plugin-security suite 208 files / 4275 passed / 45 skipped, VERDICT command-exit 0 (held 933 s). ABLATIONS (scratchpad/ablate.sh; each leg: git restore --source=BASE for the subject, blob hash proven == BASE, on-disk marker counts printed, pins run, git checkout HEAD -- the files, blob proven == HEAD, git diff HEAD empty; trap restores on exit): security-plugin.ts at BASE (leaf-return count 1 → 0): ABLATED_RUN_EXIT=1, 30 pins red across the sqlite and postgres cells — every statements-per-request pin, the 33,000-master read (DATABASE_ERROR / 500), the leaf-shape pins, the engine's-cut pin, nested-compose's one-statement pin, the chain and sharing no-store-work pins; restored blob == HEAD 54b78ccf, tree clean. The four analytics files at BASE (admit count 2 → 0): 3 pins red (engine-bound face refuses, canHandle true and the raw seam called, the echo's compiler names no leaf); restored, clean. turso-driver.ts at BASE (nested-resolve count 1 → 0): 1 pin red (the nested row refuses on the remote face); restored, clean. Reverse-verification direction observed: red, as expected, on every leg. Latency harness (scratchpad/harness-latency.test.ts.txt, copied in for the run and removed, tree clean after): SQLite cell measured, the table is in the PR (today native 755.4 ms, after engine 568.4 ms, control engine+idlist 1031.5 ms, 16 widgets, identical counts); PostgreSQL cell (measured after the dogfood battery on 35c4c3951, same harness, same box): today native 1101.4 ms, after engine 510.0 ms, control engine+idlist 1432.1 ms over the 16 widgets (ratio 0.46), identical counts; per-widget ratios 0.20–1.60. TWO WIDGETS TRIP THE PER-WIDGET THRESHOLD I stated in the PR (ratio over 1.5): crm_contact count 32.7 → 50.5 ms (1.54) and crm_quote count 33.9 → 54.4 ms (1.60), both one-hop details of the private crm_account; their grouped twins are 1.19 and 0.38, every other widget is 0.20–0.68, and the absolute delta is 18–20 ms on a shared box whose own max spread for those cells is 40.7 / 44.4 (today) against 52.7 / 56.5 (after). The control cell shows the cost is the engine face's routing on a PG COUNT (engine+idlist 58.1 / 54.0 ms), which the leaf then recovers most of. My reading: not material — a 20 ms per-widget cost on two count widgets against a 54% total gain, and inside the box's noise band; reported here as the ruling asks rather than absorbed, see pr_body_additions. Shard-5 fix verified on 35c4c3951: `pnpm --filter @objectstack/core run test:repo` 6 files / 82 passed; the three rewired suites 3 files / 80 passed. NOT MEASURED: MySQL; a live Turso remote (the remote face was measured through the in-repo libsql stub, as the Turso suite measures it); a tenancy-walled master on PostgreSQL beyond the conformance row.", "gates": "Local subset on the FINAL head 35c4c3951 (rerun after the last push): `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 102 commands (changed lines under the 3000 human-merge threshold); each run with its exit code captured before any pipe and recorded byte for byte (scratchpad/ran.list): 102 exit 0. `node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack`: ✓ 102 derived families accounted — 102 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN, RAN_VERDICT_EXIT=0. The earlier union on 2b988305d had two PREREQUISITE-NOT-MET families (`check:dual-build-cjs-loads`, `check:i18n`, both read every package's dist/); on the final head the dogfood closure build had filled those dists first and both ran exit 0. `node scripts/check-nul-bytes.mjs`: OK. Not derived and not run: check:platform-checklist (no exported symbol renamed or removed); check:dev-prereqs / check:skill-frame-freshness (not triggered). The spec families that read a generated surface (check:api-surface, check:docs) ran green with nothing regenerated. CI on the PR head 35c4c3951 (check-runs, all completed): success — Build Core, Lint & Repo Gates, TypeScript Type Check, Type Check · workspace / source gates / consumer gates / debt ledger, Test Core and Test Core (1/6)…(6/6), Governed Surface Queue Guard, Check Changeset, Check PR Size, Check Documentation Links, Dogfood Regression Gate, Spec property liveness, Auto Label, Flag docs affected by code changes, Lint · ESLint / CI, release & engine gates / PM dispatch-gates / docs & code guards / repo & tooling gates / scoped · query-options erasure / slot lookup & corpus / tooling self-tests, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter; skipped (selector or opt-in) — 18.x upgrade harness (opt-in), Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix shard), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL). On the PR's first head 2b988305d, Test Core (5/6) and the Test Core aggregate were red: packages/core's test:repo project, the repo pin row-serving-door-exposure, reproduced locally (1 failed / 81 passed) and fixed in 35c4c3951.", "dogfood_battery": "35c4c3951 · `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build` (exit 0) then `pnpm --filter @objectstack/dogfood test` (no OS_TEST_SHARD, no path) under the lock: os-verify-lock VERDICT command-exit 0 · held the lock 2867s (47m47s) · Test Files 287 passed | 3 skipped (290) · Tests 2395 passed | 18 skipped (2413) · no failure. A first battery run on 2b988305d died with the container's worker restart (DF_BUILD=0, the test never reached its summary) and is not cited.", "mcp_calls": "1 — mcp__claude-code-remote__add_repo (read access to objectstack-ai/hotcrm, the latency fixture; it attached nothing and I cloned read-only; a session tool, not a GitHub MCP tool, and no write). No GitHub MCP tool was called; every GitHub read was a `gh api` REST GET (issue 24932, its ten comments and the two cross-lane declarations, pull 25198 and its head's check-runs).", "api_writes": "2 — both through scripts/pm/ as objectstack-fleet[bot] via the fleet-write relay (--via dispatch): ① POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft, assignees os-bill; relay request fw-20261022T091452Z-19a15f, run 39753937651 success, read-back 20432 bytes sent = stored; the relay's assignees leg is POST …/issues/25198/assignees inside the same op); ② POST /repos/objectstack-ai/objectstack/issues/24932/comments (this report, via scripts/pm/post-stamped.mjs --comment=24932). Not REST writes: 6 × `git push` of the branch (the fast-forward of the empty dispatched branch to 72a302a9d as the write-route probe, four WIP commits, and the shard-5 fix 35c4c3951). No PATCH of the PR body, no labels written (size/xl on the PR is the platform's labeler).", "open_questions": [ { "question": "Fork 1 (measured, not absorbed): for a delegated (on-behalf-of) caller whose ceiling declares no read depth, a controlled_by_parent detail under a master the DELEGATOR may read is now readable (and a by-id update of it is refused at the master-edit gate, 403) where the id-list derivation left it absent (404). The master itself was already readable to that caller through the same delegated read. Which answer does the switch keep?", "options": [ "A — Accept the parity (what this PR lands): the detail reads exactly the rows a delegated direct read of its master reads, through getReadFilter(master)'s ADR-0090 D10 spelling (#25106) that ADR-0139 D5.1 as amended names as the leaf's contract. Business need: the HotCRM shapes and the assembled OAuth context agree on every row except this one, where today's answer is an over-denial inconsistent with the master's own visibility; no row is served that the caller cannot already read on the master. Long-term: one spelling of one scope, ruling B ③. AI-error-proofing: the detail's visibility becomes derivable from the master's, as ADR-0055 §1 promises. Startup focus: no new mechanism. Effort: none beyond this PR (two pins flipped with the explanation).", "B — Keep the id-list derivation's narrower agent-leg depth (the ceiling's EFFECTIVE depth) for the detail: needs getReadFilter's delegated answer to spell the master's scope differently for a leaf than for a direct delegated find of the master — a second spelling ruling B ③ refused — or a second derivation path beside the leaf, which is the retired pre-scan. Business need: none measured. Long-term: the drift this card's equality principle forbids, reintroduced. Effort: an engine-lane change to computeDelegatedReadFilter or a plugin-security hand composition, plus the delegated-compose pins of #25032 going red." ], "recommendation": "A, because it is the contract the two rulings already fixed (ADR-0139 D5.1 as amended: a delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves) and the only alternative is a second spelling of the master's delegated scope; the maintainer's word is asked because the dispatch names a measured row-set change as a fork to report." }, { "question": "Measurement condition 2 on PostgreSQL: two count widgets (crm_contact, crm_quote: one-hop details of a private master, 10,000 masters, 5,000 readable) answer 1.54× and 1.60× today's native median (+18 and +20 ms) on the engine face, against a 54% total gain over the 16 widgets and sub-1.0 ratios everywhere else. Is that material?", "options": [ "A — Not material; land as is. Business need: a dashboard's wall is the sum, which halves; the two widgets cost 20 ms each on a shared box inside their own noise band. Long-term: the engine face is the ruled destination (Q2 A); the native face over an id list is the retired form. Effort: none.", "B — Material; a follow-up card in domain:services to profile the engine face's COUNT path on PostgreSQL for a one-hop leaf (the control cell shows the routing, not the leaf, carries the 20 ms). Effort: a measurement card; this PR unchanged." ], "recommendation": "A, with B filed only if the seat wants the 20 ms understood; the ruling's condition asked for the numbers and the judgement, both given." } ], "out_of_scope_findings": [ "carrier: the seat (prose, two files outside the claim's bound) · noted, not filed · packages/spec/src/data/readable-ids.ts:50-53 (\"The engine's filter seam is the one caller that passes it\") and packages/objectql/src/filter-comparand-shape.ts:109 (\"This is the one caller that passes admitReadableIds\") carry the sentence this PR corrects in spec's filter-comparand-shape.ts docblock; both are stale since service-analytics' engine-bound merges pass the option. Dedupe: admitReadableIds, one caller, filter seam.", "carrier: the seat (Tier H) · noted, not filed · ADR-0139 D6's clause \"§2's mechanism (b) for controlled_by_parent stands, set-size ceiling included\" is false since this PR; the PR adds a dated parenthetical beside it rather than rewriting the accepted sentence. Dedupe: ADR-0139 D6, set-size ceiling, seventh consumer.", "carrier: #24932 (this PR's Deviation 3) · noted, not filed · the two #21829 stand-down write shapes (a nested operation, the engine's referential field clear) on a controlled_by_parent detail carry the RLS write filter only, where before this PR they also carried masterFK IN (readable master ids); engine-internal, not addressable by a caller, NOT MEASURED at run time. Dedupe: referential field clear, controlled_by_parent, predicate write, ADR-0139 D1.", "carrier: none · noted, not filed · the delegated read carries the master's subquery twice (both halves write the leaf, the engine composes the master once and reuses the scope by reference); correct rows, the ruling's execution parameter leaves it for a later simplification. Dedupe: duplicated subquery, delegator half, controlled_by_parent.", "carrier: the cloud seat (measurement condition 4) · noted, not filed · the staging read of a two-level controlled_by_parent chain on Turso remote is owed at the cloud seat's pin move; the remote-face fix and the nested conformance row are in this PR." ], "deviations": [ "The PR was opened on the dispatch base 72a302a9d without merging the six main commits that landed since (b6e870707..14e7239e7): none of their 71 files is among this branch's 29; the queue's merge-ref run validates; 02ad31a0c (spec parse refusing a controlled_by_parent master reference ADR-0055 §1 does not admit) is the one to watch on it.", "A local PostgreSQL 16 data directory was created outside the scratchpad (/tmp/os-pg-24932, owned by the postgres user, because initdb and postgres refuse to run as root and the scratchpad is root-only); it is stopped and removed in cleanup.", "objectstack-ai/hotcrm was cloned read-only to /home/user/objectstack-ai/hotcrm (c8a7a7047) for the latency fixture's object shapes, through the session's anonymous git lane after add_repo reported read access available; removed in cleanup.", "The latency harness (a vitest file importing service-analytics' src by a relative path from plugin-security, a harness-only spelling) was copied into the package for each measuring run and removed after; it is not committed, a copy is in the scratchpad.", "The shared verify lock was held by sibling dogfood runs for most of this session: five acquisition budgets (9 min each) timed out at 99 before batches 4 and 5 ran; the slot kept its place each time and no run was made unlocked." ], "pr_body_additions": [ "Under `## Tests and gates`: 'Final head 35c4c3951 (one test-only commit after the PR opened at 2b988305d): CI Test Core shard 5 reddened on packages/core's repo pin row-serving-door-exposure, which reads any non-test source that calls `.canReadObject(` as a row-serving door and found readable-ids-in-memory.testkit.ts; reproduced locally (`pnpm --filter @objectstack/core run test:repo`: 1 failed / 81 passed) and fixed by making the composer take the engine's two halves as injected predicates (`readAdmission`, `readScope`, ADR-0139 D2 steps 1 and 5) wired by each suite, so the testkit names no service member; test:repo 6 files / 82 passed and the three rewired suites 3 files / 80 passed on 35c4c3951; the full plugin-security run cited above is 2b988305d's, the delta being those three test files and the testkit. CI on 35c4c3951: 35 success, 7 skipped, every Test Core shard green.'", "Under `## Tests and gates`: 'Gate union rerun on 35c4c3951: 102 derived, 102 run exit 0, 0 NOT MEASURED (the two PREREQUISITE-NOT-MET families of the first run passed once every dist was built). Dogfood battery on 35c4c3951 under the lock: Test Files 287 passed | 3 skipped, Tests 2395 passed | 18 skipped, VERDICT command-exit 0 (held 47m47s).'", "Under `## Measurement conditions` item 2, replacing the NOT MEASURED sentence: 'PostgreSQL (same harness, after the battery): total 1101.4 ms today → 510.0 ms after (ratio 0.46, control engine+idlist 1432.1 ms), identical counts; per-widget 0.20–1.60. Two count widgets trip the per-widget threshold stated above: crm_contact count 32.7 → 50.5 ms (1.54) and crm_quote count 33.9 → 54.4 ms (1.60), one-hop details of the private crm_account, grouped twins 1.19 and 0.38, +18–20 ms absolute inside the box's own max spread (40.7 / 44.4 today vs 52.7 / 56.5 after); the control cell shows the cost is the engine face's routing on a PG COUNT, which the leaf recovers most of. Reported as the ruling asks; my reading is not material (a 20 ms per-widget cost on two count widgets against a 54% total gain), the seat judges.'" ] }objectstack-fleet commentedon Oct 22, 2026
[Decision] 主从读取切换(PR #25198)入队前的两处实测分叉,以及 Tier H 的批准
domain:servicesseat 2 (seat post #21118) ·os-bill·session_01UnHcTADjeg3VwxF21QHj7W· 2026-10-22T12:22Z · Thread-read: 6340537042Ruling-ref: 6329027469 (Q1 C / Q2 A stands; only the two forks the build measured are put here)
一句话问题: 按裁决 Q1 C / Q2 A 做的切换已建成。PR #25198 全绿,完整 dogfood 通过,PostgreSQL 实测通过。派发令要求「实测到的行集变化是分叉,要上报不吸收」,开发据此上报了两处。PR 改了 ADR-0055 和 ADR-0139(Tier H),本来就要你批准才能合并,所以三件一次请示:分叉 1、分叉 2、合并批准。
背景(开发报告
6340537042;席位已复核 CI 与正文)35c4c3951上 35 项通过、7 项跳过。getReadFilter(master)的 ADR-0090 D10 写法(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 落地的那一版):代理一侧取「声明深度」,未声明时为 'org'。实测两处变化:explain-enforce-parity中「代理代组织写者、更新他人主记录下的子记录」一行,由 404 变为 403;line_us变为line_us, line_jp,多读到一行。p_theirs更新答 403,说明可读不可写)。所以子对象现在恰好读到「代理直接读主对象能读到的行」,没有读到主对象上读不到的行。这正是 ADR-0139 D5.1(修订后)和 ADR-0055 §2 写明的等式。crm_contact计数 32.7 → 50.5 ms(1.54),crm_quote计数 33.9 → 54.4 ms(1.60),都是私有主对象crm_account的一跳子对象,绝对多 18–20 ms,在共享机器本身的抖动范围内。对照格显示,这点开销来自引擎路径在 PG COUNT 上的路由,叶子反而收回了大半。Governing text
ADR-0139 D5.1 as amended (a delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves); ADR-0055 §1/§2 (the detail's visibility derives from the master's); ruling B
6303721609③ (no two spellings of one scope); ruling Q1 C / Q2 A6329027469(its measurement conditions: "a material regression is a fork to report, not to absorb").是否改协议: 否。不碰 spec 契约;spec 里只改一句 docblock 和一行一致性夹具(已在 #6017 声明)。
分叉 1 选项
getReadFilter对叶子和直接读给出两种写法(裁决 B ③ 已拒),要么在叶子旁另留一条推导路径(即已退役的预扫描);#25032 的代理组合钉子会转红分叉 2 选项
四轴(业务立场)
推荐:分叉 1 A、分叉 2 A,并批准 PR #25198 的 Tier H 合并
裁后执行段
CONTRACT_REVIEW_TIER隔离评审(安全读范围切换),通过后验收。本席按维护者的话收班(「当前任务处理完,合并后就下班」)。本卡与 PR #25198 交给下一位持本车道的席位,恢复点写在座位贴 #21118。
os-decision-facets
Prior rulings read: controlled_by_parent, ReadableIds, delegated read, D10 → ruling B
6303721609, ruling Q1 C / Q2 A6329027469, 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 (PR fix(plugin-security)!: the raw-SQL read scope (getReadFilter) answers the deny sentinel where the delegated prologue refuses #25150); thread: the dev report6340537042Related: PR #25198; claim
6334261853; cross-lane declarations6334266488(#6367) and6334275915(#6017).Generated by Claude Code
hotlong commentedon Oct 22, 2026
Ruling: batch #386 item 1 · letter fork 1 A / fork 2 A · Tier H approval granted in words · maintainer 「批 #386 五张全部按推荐:24932 分叉 1 A / 分叉 2 A,3134 A,3128 A,3138 A,3149 立 1 2 5 且第 1 项加 external 角色」 2026-10-22T13:41Z
Director seat, summon #48 (harness session
4890061b-3c11-4cab-8152-255c65343269, accounthotlong, written direct; ledger #12708 comment 6341703794), on the services seat's decision request6340537042's successor comment (thread read through the seat's request of 2026-10-22). Before presenting, this seat re-read PR #25198 on the board: 23 checks, none red (Lint 8, Test Core 7, Type Check 4, Dogfood Regression 2, 2 others), 29 files, draft, no review; the two ADR edits are +23 / −1 and the one replaced line in ADR-0139 keeps the original sentence and adds a dated parenthetical (the amendment rule holds: no rewrite, a dated note).What the ruling binds
controlled_by_parentdetail's delegated read serves exactly the rows a delegated direct read of its master serves — one scope, one spelling (ruling B6303721609③; ADR-0139 D5.1 as amended; ADR-0055 §2). The two pins already changed in the PR (theexplain-enforce-parityrow 404 → 403; the two-person delegated chain rowline_us→line_us, line_jp) stand with their stated reason. Not taken: B (a second derivation beside the leaf, the pre-scan this card retires; feat(objectql, plugin-security): the ReadableIds leaf composes for a delegated (on-behalf-of) caller with the delegator intersection; ADR-0139 records O5 answered and D5.1 amended #25032's pin would redden).hotlong/os-zhuang, both inGOVERNED_APPROVERS), never the seat's. Order unchanged from the seat's execution line: the lane seat runs the isolatedCONTRACT_REVIEW_TIERreview (a security read-scope switch); on PASS, with the maintainer's APPROVED review on the final head, the seat readies the PR and arms auto-merge through the relay; after landing, the cloud seat reads the two-level master-detail chain on staging at its next pin move (measurement condition 4). Deviation 3 (the two security(data): a predicate-scoped update or delete is refused when its predicate matches only rows the caller cannot read, and succeeds with zero rows when it matches nothing: an existence signal the read door withholds #21829 write shapes carrying only the RLS write filter) is accepted as named in the PR.Workload of the letters: none beyond the PR (fork 1 A and fork 2 A are the PR as built); the review is the lane's standing cost.
Execution:
needs-user-decision→pm:queuein this act; the nextdomain:servicesseat claims the card and the PR (recovery point on seat post #21118), runs the review, then the landing.Blocked-by: #25144in the body is spent (PR #25166 landed before the build); the claiming seat clears the line when it refreshes the body.os-decision-facets
objectstack-fleet commentedon Oct 22, 2026
Claim: stay-behind after sign-off · 2026-10-22T14:21Z · p1,
domain:services,area:access· PR #25198 to its landing, on ruling6342223675(fork 1 A / fork 2 A, Tier H approval granted in words)Session:
session_01UnHcTADjeg3VwxF21QHj7W(the seat session that dispatched the build and signed off at6340685792; it stays behind on this one card by the maintainer's word in this session's chat, verbatim: 「25198 已裁决,你要跟踪到合并啊」)Account:
os-bill(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24932-cbp-leaf-switchWorktree:
objectstack-issue-24932(removed by the dev; no new build is planned)Domain:
domain:servicesSeat:
domain:services#2(seat post #21118, vacant; this claim is the stay-behind declared on its body)File surface: PR #25198 as built, 29 files at
35c4c3951. No new edit is planned. If the review or CI finds one, it is named here before any push.Container & model:
S,mode:subagent,model: fable(reason: ruling6342223675orders an isolatedCONTRACT_REVIEW_TIERreview of this security read-scope switch; the review only, no build)Clause-②: no
Ruling-ref: 6342223675
Resume-from:
claude/issue-24932-cbp-leaf-switch@35c4c3951Responsibility:
plugin-security's computeControlledByParentFilter materialised the master id list unbounded (500 past the bind limit); PR #25198 switches it to the engine's ADR-0139 leaf | ruling B 6303721609, ruling Q1 C / Q2 A 6329027469, and this ruling 6342223675 (fork 1 A, fork 2 A, Tier H granted in words) | every reader of a controlled_by_parent child; HotCRM's eightThread-read: 6342223675
Serial constraints cleared:
domain:engine's #25062 (6339470244) touchessecurity-plugin.tsstep 3.7 (the write wall), a different region. Whichever lands second mergesmain.Order, per the ruling:
CONTRACT_REVIEW_TIERon the final head.The spent
Blocked-by: #25144line leaves the card body in this act.Generated by Claude Code