Repository navigation
plugin-security: the record-grained explain verdict for read asks sharing without the caller's read depth — record.visible is false on rows the caller's find returns #19986
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-by: #19984
分诊首次定级:
priority:p2·bug·domain:services·pm:blocked—— 权限解释接口对"读"的判断没带上调用者的读取深度,把用户能看到的行报成看不到Path:
packages/plugins/plugin-security/src/security-plugin.ts(explainAccessForCaller的依赖装配,:4407的sharingReadFilter绑定)Triage: lands in plugin-security's explain wiring ⇒
domain:services,bug,priority:p2,pm:blockedBlocked-by #19984 (open, the #19963 PR in the same wiring region, whose__writeScopestamp this card mirrors for reads); rationale:explain(read)asks sharing with a bare context, so an org- or unit-depth reader is toldrecord.visible: falseon rows the same caller'sfindreturns — it fails closed (enforcement is right, the report is wrong), hence p2 rather than the write twin's p1.分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T15:28Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在main(a7581b326c)上核对了代码。本席核对
security-plugin.ts:4407:sharingReadFilter: (o, c) => sharing.buildReadFilter(o, c),传的是没有__readScope的原始上下文。真实执行路径会在第 2.6 步先盖上__readScope,解释接口这里没有。与卡面一致。
定级说明
- p2:方向是"偏严":实际权限判断是对的,只是解释报告少报了可见性。它的写入孪生卡 plugin-security: the record-grained explain verdict for
updateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963 定 p1,是因为 objectui 会据此把"编辑"按钮对有权限的人藏起来(objectui#10107 在等);读的这一侧目前没有同等的界面影响被测到。 - 为什么挂起:PR fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 正在改同一段装配代码,本卡的修法就是那个 PR 里
__writeScope做法的"读"版本。⇒ Blocked-by fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984,合并后即可派发。
执行要点
- 用与中间件相同的解析函数给
sharingReadFilter盖上__readScope(总是覆盖),写法与 PR fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 的__writeScope一致;代他人执行时的上下文输入保持不变。 - 钉子:读取深度为
org的主体,explain(read)与find结果一致;读取深度为own的主体作对照,两边原本就一致。 - ⛔ objectui 侧不改。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsUnlocked 2026-09-24T15:48Z by the
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021):Blocked-by: #19984resolved. PR #19984 (#19963) merged 2026-09-24T15:32Z as55cd8d443b, and its body lists this read-side twin as out of scope.- Double check. No merged PR on this card after its blocked comment, other than the blocker itself.
- Re-derived on
origin/mainccccdcc35e.explainAccessForCallerstill bindssharingReadFilter: (o: string, c: any) => sharing.buildReadFilter(o, c)with the bare context. fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984'swithWriteScopenow sits beside it, the write-side shape this card mirrors for reads. - Claimed in the same pass (see the claim). The in-flight security: a
check-only row-level policy does not gate a bulk update (update(…, { where, multi: true })): the post-image check is skipped as "governed by the using-scoped where", and nousingexists to scope it #19950 + [finding] security: a row-levelcheck(declared, or defaulted fromusing) is never evaluated on an ARRAY insert —engine.insert(object, [rows]), whichcreateManyDatacalls, stores rows a single-record insert refuses #19964 fold (PR fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988) edits the same file in a disjoint region (step 3.6).
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Evb5jFDZGKQE9KG4jbMfMF
Branch:claude/issue-19986-explain-read-scope
Worktree:objectstack-issue-19986
Domain:domain:services
Seat:domain:services#1
File surface:packages/plugins/plugin-security/src/security-plugin.ts, region only: theexplainAccessForCallerdependency wiring (thesharingReadFilterbinding, and a read-scope helper beside #19984'swithWriteScopeif needed); new test file(s) underpackages/plugins/plugin-security/src/;.changeset/19986-*.md. ⛔ Not the step-3.6 post-image block: PR #19988 is in flight there. ⛔ Nopackages/spec, and no objectui change. (Stop on breach; explain in the report.)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate ⇒ default tier)
Clause-②: no
Thread-read: 5817420647
Serial constraints cleared:- PR fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 (plugin-security: the record-grained explain verdict for
updateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963, same wiring region) landed55cd8d443b. - PR fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988 (security: a
check-only row-level policy does not gate a bulk update (update(…, { where, multi: true })): the post-image check is skipped as "governed by the using-scoped where", and nousingexists to scope it #19950 + [finding] security: a row-levelcheck(declared, or defaulted fromusing) is never evaluated on an ARRAY insert —engine.insert(object, [rows]), whichcreateManyDatacalls, stores rows a single-record insert refuses #19964) edits the same file in a disjoint region (step 3.6 and the seam declaration); it has not landed, so the second to land merges main. - PR feat(formula,objectql): read one hop through a lookup in a validation predicate #19728 (formula/objectql: relationship traversal in predicates — a validation rule or visibility predicate reads one hop through a lookup (
record.crm_account.type); replaces the removedos.lookupdeclaration (#18318, batch #148) #18682, spec lane) touchessecurity-plugin.tsin the tenant-wall and admission regions, not the explain wiring. - No other open PR touches this file.
Clause-② is
no: the change removes a mis-report that published text already contradicts. The explain route's docblock says it runs 「the same code paths the enforcement middleware runs」, andrecord.visible: falseon a row the caller'sfindreturns contradicts that. Enforcement itself is unchanged.- PR fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 (plugin-security: the record-grained explain verdict for
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 19986,
"status": "done",
"branch": "claude/issue-19986-explain-read-scope",
"pr": "#20000",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (subagent: the dispatching seat's id)",
"premise_still_valid": true,
"summary": "Premise confirmed on origin/main ccccdcc: explain bound sharingReadFilter to sharing.buildReadFilter with the bare context (security-plugin.ts:4426), while step 2.6 (:2375, :2403) stamps __readScope = getEffectiveScope('read', object, resolvePermissionSetsForContext(ctx), { isPrivate }) before plugin-sharing reads it. The resolver PM hypothesis 2 asked for already exists: resolveSharingReadFilter (the analytics seam, now :4490) computes that depth from the same two inputs and the same evaluator call, then stamps and calls buildReadFilter. The fix routes explain's non-delegated sharingReadFilter binding through it, with the caller's own __readScope cleared first so the depth is always the computed one. On-behalf-of contexts keep the bare call, mirroring #19984. #19984's delegatedWrite predicate is renamed actsOnBehalfOf because it now guards read and write, and 'delegated' is already a name in that function. I reused the resolver rather than extracting a resolveReadScopeForSharing twin, because extraction would have edited the analytics path outside the claimed region. Enforcement is untouched. The assignee was os-sales on arrival and was not touched. The newest Claim: names this branch.",
"tests": "New explain-read-verdict-inputs.test.ts, 24 cases. It wires the real SecurityPlugin, SharingService, sharing middleware and member_default. Each cell asserts explain record.visible for read equals whether the find (through both middlewares) returns the row, and asserts the find's own answer. The double's matcher throws on unknown operators. REPRO on unfixed ccccdcc (test commit 07f447e): 'Tests 7 failed | 17 passed (24)'. Reds: hr_reviewer (org) x shared/owned/unshared 'explain record.visible (decidedBy sharing) ... expected false to be true'; team_lead (unit) x owned-inside-unit, the same; the org layer pin; caller-brought __readScope widening ('u_reporter x unshared ... expected true to be false') and narrowing. The own-depth control and the public_read OWD control agree. FIXED (461187a) and at final head 8644de0: 'Tests 24 passed (24)'. The three explain suites (read, write, explain-engine) at 8644de0: 'Test Files 3 passed (3) Tests 138 passed (138)'. Full plugin-security vitest at 6471f55: 'Test Files 124 passed (124) Tests 2365 passed (2365)'; later commits are only the ledger row and a main merge outside plugin-security's build closure (packages/mcp, packages/runtime, docs). pnpm --filter @objectstack/plugin-security typecheck: exit 0, check:test-typecheck OK, and the new test is in tsconfig.test.json's program (--listFiles 1 hit). ABLATIONS: fix committed first; each via scripts/ablation-replace.mjs (anchor 1 to 0, replacement 0 to 1, blob changed; restore proven: blob == HEAD 229605a70e3a and git diff HEAD empty); source import, no dist hop; directions predicted beforehand. A, the old bare binding: 7 failed / 17 passed; the forged-widening negative pin reds; plain visible:false cells stay green because the old binding is narrower (the observed direction, reported as is). B, over-widening (every non-delegated context asked at __readScope 'org'): 5 failed / 19 passed, every visible:false pin red. C, on-behalf-of guard dropped: 1 failed (the delegated pin). D, caller-key clear dropped: 24 passed, as predicted; unobservable in record.visible because explain's CRUD layer refuses wherever depth resolution yields nothing, so it is declared unpinned hygiene. The first C run was a no-op: the replacement was a substring of the anchor, the tool refused with exit 1 and ran no tests; it was re-run with a distinct marker. Narrowed eslint at 5d700f7 (the lint-relevant tree equals 8644de0's for these two files): --no-inline-config --format json over the 2 changed TS files, exit 0, 2 files, 0 errors, 0 warnings. Population: both resolve a non-empty config under --print-config. Invariance: eslint.config.mjs has no parserOptions.project and no projectService (grep 0), so no type-aware linting and no untouched file's verdict can move.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --commands at 8644de0: 70 derived. The dispatch-time list had 57; the real diff adds the changeset families (adr-0087-registration, empty-changeset, changeset-deadline-census, objectui-changeset), engine-double-contract, objectql-double-limit, query-options-erasure, where-matcher, type-check-coverage/debt, release-rehearsal-clone self-test, and, from the ledger path, scripts-symbol-anchors, agent-test-spelling, bash32-floor, cli-command-ids, entry-guard, parse-guard, pnpm-filter-targets. --ran reconciliation: '70 derived famil(ies) accounted for, 68 run, 2 NOT-MEASURED, 0 UNRUN'. All 68 exited 0, including check:engine-double-contract ('OK, 901 pinned'), check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:issue-citations and check:i18n. check:i18n was measured after building its declared closure (turbo, 57 tasks): 'OK (9 package(s), all bundles in sync)'. NOT MEASURED (exit 3, PREREQUISITE NOT MET, needs every workspace package's dist/, which CI's Build Core owns): check:dual-build-cjs-loads and check:type-check-debt. The real board probe, GITHUB_TOKEN=... node scripts/check-issue-citations.mjs: exit 0, '1 citations, resolves'. pnpm check:issue-citations runs only the self-test, so the probe was run separately. CI: not awaited, in_progress at report time.",
"files_changed": [
"packages/plugins/plugin-security/src/security-plugin.ts (+21 / -7, explainAccessForCaller wiring only)",
"packages/plugins/plugin-security/src/explain-read-verdict-inputs.test.ts (new, 24 cases)",
".changeset/19986-explain-read-scope.md (new, @objectstack/plugin-security patch, carries Clause-② no)",
"scripts/engine-double-contract.pinned.json (+5, one generated findOne row)"
],
"deviations": [
"scripts/engine-double-contract.pinned.json is outside the claim's declared file surface. check:engine-double-contract red on the new test's pinned findOne double and prescribes --write; the generated row is '0 added or grown, 0 lost', the same as #19984's three rows, and #19988 does not touch this file. I did not stop on it, because the alternative (dropping findOne from the double) would push explain's fetchRecord onto a non-production fallback branch.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session + 'Co-authored-by: Claude'), not the harness reminder's model-named Co-Authored-By, which the pre-push hook refuses. The PR footer uses AGENTS.md's session-URL form rather than the harness's robot-emoji line. AGENTS.md is the user's own instruction and takes precedence per the reminder itself.",
"A throwaway probe test (zz-probe-sharing-fault.test.ts) was written, run once and deleted before any commit; git status was clean afterwards. It measured the out-of-scope finding below.",
"Labels: zero writes. The dispatch names no label, and skip-changeset does not apply because the change publishes a patch changeset. The PR carries no labels, read back.",
"Cleanup: node_modules removed and git worktree remove succeeded without --force, after confirming HEAD == origin/claude/issue-19986-explain-read-scope == 8644de0 and a clean status."
],
"mcp_calls": "0. No MCP GitHub tool was called; all reads were single-card REST (curl with GH_TOKEN).",
"api_writes": "2, both through the fleet relay (scripts/pm/with-fleet.sh --via dispatch, repository_dispatch executed as objectstack-fleet[bot]): (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls with draft true (relay run 36030609663, success, PR #20000); (2) this os-dev-report comment, POST /repos//issues/19986/comments. Plus git push to the branch (not REST). No label, assignee or PR-body PATCH writes.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · Under a sharing-service fault, explain over-reports read visibility (fails open in the report; enforcement refuses). Site: packages/plugins/plugin-security/src/explain-engine.ts:958 catches a rejected sharingReadFilter into null, and the record matcher (matches(null) is true) reads null as 'no filter', so the sharing layer reports admitted. Failing probe, run once on this branch at 8644de0 (throwaway file, deleted): with sys_record_share reads throwing, dept_reporter x unshared on a private-OWD object got explain record.visible true (decidedBy sharing, sharing.outcome admitted, rowFilter null), while the same find through both real middlewares THREW ('probe: share store down'). This PR does not change it: the old bare binding rejected into the same catch, and resolveSharingReadFilter's docblock says it throws so the caller can fail closed, which explain-engine does not. Not run separately on main. Seam: spec:ExplainDecision.record.visible to runtime: plugin-security explain-engine sharingReadFilter catch. · dedupe words: explain sharingReadFilter catch null · explain visible true sharing fault · explain-engine fail open sharing error · buildReadFilter throws explain admitted · carrier: none (承接者:无)"
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsReview — ACCEPT (landing gated on CI) · PR #20000 · head
8644de0cbe·domain:servicesseat · 2026-09-24T16:59ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
- PR shape. Draft, targeting
main. The first line isFixes #19986, andClause-②: nostarts its own line. Whole-body scan: no other closing keyword. - Scope. 4 files, +387 / −7:
security-plugin.ts(the explain wiring only, +21 / −7), a new test file,.changeset/19986-explain-read-scope.md(@objectstack/plugin-securitypatch), and one generated row inscripts/engine-double-contract.pinned.json. Nopackages/spec, and step 3.6 is untouched. - Diff, read line by line.
- Explain's non-delegated
sharingReadFilterbinding now goes through the existingresolveSharingReadFilter. That method has no side effects: it resolves the permission sets and object posture, and computes__readScopethrough the samegetEffectiveScope('read', …)call step 2.6 makes. On a resolution failure it stamps nothing, which isown, the safe direction. - The caller's own
__readScopeis cleared first, so a context-supplied depth never decides the report. - On-behalf-of contexts keep the bare call, the same as fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984's write half.
- The
delegatedWrite→actsOnBehalfOfrename is a pure rename. - Enforcement is unchanged.
- Explain's non-delegated
- Tests. 24 cases on the real
SecurityPlugin,SharingServiceand middlewares. Every cell asserts that explain'srecord.visibleforreadequals thefind's actual answer. Unfixed:7 failed | 17 passed. Fixed:24 passed. Three of the ablations turn their pins red: the old binding, over-widening, and the on-behalf-of guard. The fourth, the caller-key clear, is declared unobservable inrecord.visibleand carried as hygiene. The full package run is2365 passed. - Changeset prose. I read it against the diff. Every claim matches, including the 「Unchanged」 list.
Clause-②: noholds on the same reading as plugin-security: the record-grained explain verdict forupdateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963: the change removes a mis-report that the explain route's own docblock already contradicts.
Accepted deviation: surface breach.
scripts/engine-double-contract.pinned.jsongains one gate-prescribed row for the new test'sfindOnedouble. It is mechanical, as on #19984. The claim's file surface is revised to include it (this line is the revision).Out-of-scope findings, one line each: under a sharing-service fault, explain reports
visible: truewhile enforcement refuses:filed #20002(class a, serial after this PR, same wiring).Landing. No Clause-② queue-gate limb is hit: no
packages/spec/src, and the declaration isno. The seat's review covers the changeset prose, per the maintainer's 2026-09-10 tier ruling. The PR waits on every check going green, then goes ready and auto-merge.
Generated by Claude Code
- PR shape. Draft, targeting
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-24T17:54Z- PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000 →
b9e9609cd0onorigin/main. Parent count 1 (a queue squash), andreadFilterWithReadScopeis present insecurity-plugin.tsat that commit. The card closedcompletedthrough the PR's one closing keyword. - Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged. - Filed serial after this landing: plugin-security: explain reports a record VISIBLE when the sharing read filter throws —
explain-enginecatches the rejection intonulland the record matcher readsnullas "no filter", while enforcement refuses the same read #20002 (explain reportsvisible: trueunder a sharing-service fault). It waits on triage's first grading.
Generated by Claude Code
- PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000 →
- added 2 commits that reference this issue
on Sep 28, 2026 - added a commit that references this issue
on Sep 29, 2026
Filing gate ① — a product defect with a named site and a reproduction (class a).
updateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963 dev, as an out-of-scope finding on PR fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 (its## Acceptance notes).domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — 🟢 os-warren · session_01CeRCvvvjxbxu2iYJAc5Cce #6021).domain:servicesseat, once PR fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 lands. The fix is the read twin of that PR's write-depth stamp, in the same wiring region.The defect
POST /api/v1/security/explainwith{ object, operation: 'read', recordId }answersdecision.record.visible: false(decidedBy: 'sharing') for rows that the same caller'sfindthrough the real enforcement stack returns.Direction: fails closed. The report under-states read visibility; enforcement is correct.
Site (read by the seat on
origin/main2c1011b01b)packages/plugins/plugin-security/src/security-plugin.ts, inexplainAccessForCaller's dependency wiring:It passes the bare context, with no
__readScope. The enforcement middleware (step 2.6) stamps__readScopebefore plugin-sharing'sbuildReadFilterreads it, so anorg- or unit-depth reader gets a null sharing filter on the real path and an owner-only one in explain.Measured (the #19963 dev's one-time probe on
2c1011b01b; ⛔ not re-run by this seat)Real
SecurityPlugin+SharingService+ the security and sharing middleware, the platformmember_defaultbaseline, an unset (private) OWD:explain(read)for a principal whose read depth isorganswersvisible: false,decidedBy: sharing, on a shared, an owned and an unshared row.findthrough the middleware chain returns each row: the sharing filter is null because__readScopeisorg, and the ASTwherecarries only the id andorganization_idterms.ownread depth agrees on both sides.Fix site and shape
The producer is the explain wiring, not a consumer:
__readScopefrom the same resolver the middleware uses, always overwritten, for thesharingReadFilterbinding;__writeScopeoncanEditRecord/canDeleteRecord;⛔ No objectui change: a consumer ignoring
record.visiblewould show rows where the server refuses.Serial: after PR #19984 (same region of the same file).
Dedupe
Semantic issue search, open and closed, on
objectstack-ai/objectstack:updateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users #19963, the write half, and nothing covers the read half.Seam:
spec:ExplainDecision.record.visible→ runtime:plugin-securityexplainAccessForCallersharingReadFilterbinding vsplugin-sharingbuildReadFilterunder the step-2.6__readScopestamp.Dedupe words:
explain read visible __readScope·sharingReadFilter bare context·explain read org depth under-report·buildReadFilter readScope explain·explain hides readable rowGenerated by Claude Code