Repository navigation
plugin-security: explain reports a record VISIBLE when the sharing read filter throws — explain-engine catches the rejection into null and the record matcher reads null as "no filter", while enforcement refuses the same read #20002
Description
Activity
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsBlocked-by: #19986
分诊首次定级:
priority:p2·bug·domain:services·pm:blocked—— 权限「解释」接口在共享层出错时报告「可见」,而真正的读取会被拒;和 #19986(PR #20000)改的是同一处,排在它后面Path:
packages/plugins/plugin-security/src/explain-engine.ts(deps.sharingReadFilter(object, context).catch(() => null))Triage: lands in
plugin-security⇒domain:services,bug,priority:p2,pm:blockedBlocked-by #19986; rationale:explain-enginecatches a rejected sharing read filter intonull, and the record matcher readsnullas "no filter", so a failed sharing layer is reported as ADMITTED (record.visible: true,decidedBy: sharing) while enforcement refuses the same read — the explain route promises "the same code paths the enforcement middleware runs"; the fix is in the explain wiring PR #20000 (for #19986) is changing, so it waits for that landing.分诊席 #6015,2026-09-24T18:28Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack
origin/mainb81da66df7上核对。本席核对
explain-engine.ts:const sharingFilter = deps.sharingReadFilter && recordExists ? await deps.sharingReadFilter(object, context).catch(() => null) …。⇒ 共享层抛错时被吞成null,与卡面一致。- 同一个文件里还有几处
.catch(() => …)回落,例如computeLayeredRlsFilter回落成{ layer0: null, layer1: null }、listRecordShares回落成[]。本卡只管共享读过滤这一处。另外几处是否同样「失败即放行」,本席没有逐个判断,列在执行要点里供接手者测量。 - 卡面的探针是 plugin-security: the record-grained explain verdict for
readasks sharing without the caller's read depth —record.visibleis false on rows the caller'sfindreturns #19986 的开发在 PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000 分支上一次性跑的。本席没有重跑,也没有在 main 上单独跑。
为什么挂在 #19986 后面
卡面自己写了「actionable once PR #20000 lands (same explain wiring)」。PR #20000 是 #19986 的修复,正在改同一段 explain 接线。现在另开一个 PR 会和它互相覆盖。#19986 关闭时本卡解锁。
定级说明
p2,不加
security:- 强制执行是对的:服务端照样拒绝这次读取,没有数据外流。错的是报告:它说「可见」,按
record.visible渲染的 UI 会把一行显示出来,然后服务端拒绝。 - 这是「解释面与执行面不一致」,是 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 / plugin-security: the record-grained explain verdict forreadasks sharing without the caller's read depth —record.visibleis false on rows the caller'sfindreturns #19986 同一类问题。 - 失败方向是报告放行,所以不降到 p3。
解锁后的执行要点
- 先在 main 上重现探针:私有 OWD 对象,共享存储读取抛错,主体对未共享行只有
own读深度 ⇒ 今天 explain 答visible: true,而同一次find抛错。 - 修成 fail closed:共享层求值失败时,explain 报「不可见」,而且结果要说明「该层无法求值」,⛔ 不能说成「该层放行」。用现有的哪个 outcome 值,由实现者测量后选择。
- 顺带测量同一文件里其余
.catch(() => …)回落,是否也把「求值失败」读成了「放行」。有同类问题就列在 PR 里一并处理,或者另开卡。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 24, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsUnlock scan: the blocker closed; the card is returned to
pm:queue.domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T18:52Z- The blocker, plugin-security: the record-grained explain verdict for
readasks sharing without the caller's read depth —record.visibleis false on rows the caller'sfindreturns #19986, closedcompletedat 2026-09-24T17:53Z through PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000 (b9e9609cd0onorigin/main). - Re-derived on
origin/mainfc6ddb87a4.packages/plugins/plugin-security/src/explain-engine.ts:958still readsawait deps.sharingReadFilter(object, context).catch(() => null). The premise holds after the explain-wiring landing. - File surface. No open PR touches
explain-engine.ts. The two open PRs inplugin-securityare fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 (security-plugin.tsand tests) and fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947 (two test files). - Not dispatched now. The seat's three dev slots are full (security: a by-id UPDATE's row-level
checkis judged on the pre-hook change set, so abeforeUpdatestamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989, security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995, member_default gives every authenticated member read on sys_scim_user / sys_scim_group with no row policy and no tenant column: one organization's IdP-provisioned users (emails, names) are readable from any other #20001). The card waits in the queue for the next free slot.
Generated by Claude Code
- The blocker, plugin-security: the record-grained explain verdict for
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Evb5jFDZGKQE9KG4jbMfMF
Branch:claude/issue-20002-explain-sharing-fault
Worktree:objectstack-issue-20002
Domain:domain:services
Seat:domain:services#1
File surface:packages/plugins/plugin-security/src/explain-engine.ts(the sharing read-filter fault path at:958, and the other.catch(() => …)fall-backs in the same file only where measurement shows "could not evaluate" read as "admitted"); new test file(s) underpackages/plugins/plugin-security/src/;.changeset/20002-*.md. ⛔ Notsecurity-plugin.ts(PR #20012). ⛔ Notdefault-permission-sets.ts(#20001, in flight). ⛔ Nopackages/spec: if the explain outcome vocabulary lacks a "could not be evaluated" value, stop and report. Measurement first: the probe reproduced onmain. (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: 5820221521
Serial constraints cleared at 2026-09-24T19:18Z:- PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000 (plugin-security: the record-grained explain verdict for
readasks sharing without the caller's read depth —record.visibleis false on rows the caller'sfindreturns #19986, the same explain wiring) landedb9e9609cd0. - Of the 14 open PRs, fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 (
security-plugin.tsand tests) and fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947 (two test files) are the only ones inplugin-security. Neither touchesexplain-engine.ts. - member_default gives every authenticated member read on sys_scim_user / sys_scim_group with no row policy and no tenant column: one organization's IdP-provisioned users (emails, names) are readable from any other #20001 (in flight) is in
default-permission-sets.ts, which is disjoint.
- PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000 (plugin-security: the record-grained explain verdict for
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20002,
"status": "done",
"branch": "claude/issue-20002-explain-sharing-fault",
"pr": "#20030",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF — this run's id (subagent = the dispatching seat's; container CLAUDE_CODE_REMOTE_SESSION_ID cse_01Evb5jFDZGKQE9KG4jbMfMF; relay route resolved the same session)",
"premise_still_valid": true,
"summary": "Reproduced on origin/main fc6ddb8 with the real SecurityPlugin + SharingService + both middlewares: with the share store down, explain answered record.visible true (decidedBy sharing, sharing admitted, rowFilter null) on unshared, shared and owned rows while the same find threw. Measuring all eight .catch fall-backs in explain-engine.ts (the dispatch listed seven; :1140 resolveDelegatorContext is the eighth) found four that fail OPEN (:858 layered RLS, :958 sharing read filter, :966 write gate, :1140 delegator) and four that fail toward deny/not-visible (:848, :941, :1128, :1147, left as is). The four fail-open sites now keep a rejection apart from every answer (a settle helper returning a DEPENDENCY_FAULT sentinel): the layer reports the existing outcome not_evaluated with no rowFilter/matchesRecord and a could-not-be-evaluated detail, record.visible is false (decidedBy sharing / rls), and a delegator whose grants cannot be read denies at principal and object_crud (allowed false). No new response keys, no packages/spec edit, enforcement untouched; draft PR #20030 opened with the pinning test file, a patch changeset and one generated engine-double ledger row.",
"tests": "All on HEAD e8e6777 (after merging origin/main 4463966) unless noted. (1) Measurement first: new file red on the unfixed engine at 0206768 — "Tests 10 failed | 6 passed (16)", e.g. "AssertionError: u_reporter × unshared × read: explain record.visible (decidedBy sharing) — a fault is not an admission: expected true to be false" and for the delegator "the agent may not act alone when its delegator cannot be resolved: expected true to be false". (2) pnpm --filter @objectstack/plugin-security typecheck: exit 0 ("check:test-typecheck: OK"; tsc --listFiles on tsconfig.test.json lists explain-dependency-fault.test.ts 1x). (3) pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: "Test Files 130 passed (130) / Tests 2541 passed (2541)", lock VERDICT command-exit 0. (4) Consumer files (grep -rln "security/explain\|explainAccess" packages --include=*.test.ts): rest security-routes + security-explain-envelope + rest-write-response-internal-fields.tripwire 45/45; plugin-sharing sharing-service 131/131; client client.test 217/217; dogfood api-key-owner-revoke + owd-public-read-write-write-floor + showcase-d7-default-profile 23/23; spec type-alias-convention.pin + explain-zero-rows-sentinels.pin 11/11; objectql engine-middleware-operation-vocabulary 5/5 (each RC 0). (5) Ablation, every negative pin, via node scripts/ablation-replace.mjs in WRAP mode inside a driver with trap restore EXIT INT TERM, test resolved through relative src imports (no dist): A1 :958 anchor "? await settle(deps.sharingReadFilter(object, context))" -> ".catch(() => null)": "ok mutation landed: anchor 1 -> 0, blob fca6431074ac -> 724c6f3349e7", "Tests 4 failed | 12 passed (16)"; A2 :966 -> ".catch(() => undefined)": blob -> a9dd55497101, "Tests 2 failed | 14 passed (16)"; A3 :858 -> ".catch(() => ({ layer0: null, layer1: null }))": blob -> 08fe9fe2285a, "Tests 3 failed | 13 passed (16)"; A4 :1140 -> ".catch(() => ({ kind: 'none' }))": blob -> 4af969466ede, "Tests 1 failed | 15 passed (16)". Every leg: "ok restored: blob == HEAD (fca6431074ac) andgit diff HEADis empty", post-leg hash match=yes, final git status clean. Each red set is exactly that site's fault cells; controls stayed green in every leg. (6) Narrowed lint: eslint --no-inline-config --format json on explain-engine.ts + explain-dependency-fault.test.ts: 2 files, 0 errors, 0 warnings; the changeset and the JSON ledger are "File ignored because no matching configuration was supplied"; eslint.config.mjs never enables type-aware linting (no parserOptions.project), so no untouched file's verdict can move. (7) grep -naP control-byte scan over the three authored files: no hits; pnpm check:nul-bytes exit 0.",
"gates": {
"derived": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on e8e6777: 71 commands (4 paths vs merge base 4463966); plus 3 roster-flagged families whose roster sits under a touched directory (check-changeset-fixed, check:error-code-casing, check:filter-alias-parity).",
"reconciliation": "dispatch-gates --ran with "cmd :: exit N" lines: "Run reconciliation — 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN" and "✓ dispatch-gates --ran: 71 derived famil(ies) accounted for — 71 run, 0 NOT-MEASURED (a DERIVED zero — all 71 recorded an exit code and none of them is 3)".",
"vs_dispatch_list": "The dispatch-time list (gates-sec.txt, 49 lines) is a subset of the final 71; added by the real diff: check-adr-0087-registration (x2), check-empty-changeset (x2), release-rehearsal-clone --self-test, check-scripts-symbol-anchors (x2), check:agent-test-spelling, check:authz-resolver, check:bash32-floor, check:cli-command-ids, check:engine-double-contract, check:entry-guard, check:objectql-double-limit, check:objectui-changeset, check:parse-guard, check:pm-changeset-deadline-census, check:pnpm-filter-targets, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher.",
"history": "Before the final run, two gates were red and were fixed in the diff: check:engine-double-contract (RETAINED: the new pinned findOne double needed the ledger row; regenerated with --write, commit 683813f) and check:objectql-double-limit (UNJUDGED: the double threw from inside find; the outage moved into the table and find applies limit by presence, commit 68131cc). check:dual-build-cjs-loads, check:i18n and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3) and were measured after building the prerequisite closures.",
"results": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-scripts-symbol-anchors.mjs :: exit 0",
"node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-tenant-audit-census.mjs :: exit 0",
"node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:agent-test-spelling :: exit 0",
"pnpm check:authz-resolver :: exit 0",
"pnpm check:bash32-floor :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cli-command-ids :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:entry-guard :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:i18n :: exit 0",
"pnpm check:i18n-stale-fill :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:parse-guard :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:pnpm-filter-targets :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0 (first run 3 PREREQUISITE NOT MET: plugin-security dist older than sources; rebuilt, re-run 0)",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0",
"node scripts/check-changeset-fixed.mjs :: exit 0",
"pnpm check:error-code-casing :: exit 0",
"pnpm check:filter-alias-parity :: exit 0"
]
},
"files_changed": [
"packages/plugins/plugin-security/src/explain-engine.ts (+91/-21)",
"packages/plugins/plugin-security/src/explain-dependency-fault.test.ts (new, 431 lines)",
".changeset/20002-explain-sharing-fault.md (new: @objectstack/plugin-security patch, Clause-②: no)",
"scripts/engine-double-contract.pinned.json (+5: one generated RETAINED row)"
],
"fallback_measurement_table": "| Site | Caught into | What explain reported when the dependency failed | What enforcement did | Verdict |\n|---|---|---|---|---|\n|:848fetchRecord|null| Record not found:visible: false, nodecidedBy| The find throws on the same store fault | Fails toward not visible. Left as is. The plugin's own binding already catches tonull, so in this wiring this catch is never reached. |\n|:858computeLayeredRlsFilter|{ layer0: null, layer1: null }|allowed: false(the object-level catch denies), butrecord.visible: truefor a shared row, an owned row and an org-depth row | The find throws the injected error | Fails OPEN at row level, and contradictsallowed. Changed. |\n|:941listRecordShares|[]| Verdict unchanged, because the read filter decides it.rules[]is empty | Unaffected: enforcement never callslistShares| Only the attribution changes: it can only drop anadmitsrule. Left as is. |\n|:958sharingReadFilter(the card) |null| Share store down, own-depth reader:visible: trueon the unshared, shared and owned rows. Sharing isadmittedwithrowFilter: null| The find throws the store error | Fails OPEN. Changed. |\n|:966write gate (canEdit/canDelete) |undefined("no gate wired") | Update on an owned row:visible: true(private andpublic_read). On a row with only a READ share, sharing reportedadmitted| The by-id PATCH throws the injected error | Fails OPEN. Changed. |\n|:1128resolveSets|[]|allowed: false,decidedBy: object_crud| 403PERMISSION_DENIED| Fails toward deny. Left as is. |\n|:1140resolveDelegatorContext(not on the dispatch list, found by the same sweep) |{ kind: 'none' }("no delegation") | Delegator's grant store down:allowed: true,principalneutral. The D10 intersection was dropped (a healthy delegator givesallowed: false) | 503SERVICE_UNAVAILABLE| Fails OPEN (object-levelallowed). Changed. |\n|:1147delegatorresolveSets|[]|allowed: false,decidedBy: object_crud| The find throws | Fails toward deny. Left as is. |",
"fallback_measurement_storage_controls": [
"With the share store down, an org-depth reader's read filter answers null before it reads a share, so both sides admit (explain visible true, find returns the row) — unchanged by the fix.",
"The per-record gate catches its own store faults (writeGateFailClosed), so an update during the same outage already agreed on both sides (owned: written/visible; others: 403 PERMISSION_DENIED / visible false)."
],
"deviations": [
"Scope: four sites changed, not only :958. The dispatch listed seven .catch fall-backs; the file has eight — :1140 resolveDelegatorContext -> { kind: none } was missing from the list, measured fail-open (allowed true vs 503), and is inside the claim's file surface ("the other .catch fall-backs in the same file only where measurement shows could-not-evaluate read as admitted"). :858 and :966 are fail-open at dependency level; in this wiring the plugin's own internals catch most storage faults before they reach them, so they are pinned by rejecting the shared function itself (plugin.computeLayeredRlsFilter, sharing.canEdit), which enforcement calls too.",
"Outcome value (Section 2.3): the vocabulary is declared in packages/spec (explain.zod.ts: admitted | excluded | not_evaluated), not in this package. The existing not_evaluated already carries "unavailable" in this engine (split unavailable, record not found), and the card says the value is the implementer's choice among existing ones, so it was used with a fault detail; no new value, no spec edit, not needs_decision.",
"Suggested route not followed on logging: no log line added. The engine has no logger dep and wiring one is a security-plugin.ts edit (off limits); AGENTS.md "Degradation log levels" treats a failure delivered to the caller as not a degradation — the report delivers it. The detail also omits the thrown message (self-explain needs no capability).",
"Extra file: scripts/engine-double-contract.pinned.json (generated, one row) — required by check:engine-double-contract for the new file's pinned findOne double. The double deliberately has no update/delete verbs (a write stops at the engine's door and "reached the engine" is the admission), so no update/delete ledger rows were added.",
"Merged origin/main (4463966, which landed PR #20012 in security-plugin.ts) into the branch before opening the PR (AGENTS.md multi-agent section 10); the final union, ablations and suites ran after it on e8e6777.",
"Attribution: commits carry the AGENTS.md model-free pair (Claude-Session + "Co-authored-by: Claude"), and the PR body ends with the AGENTS.md session-URL footer, not the harness reminder's model-named trailer / emoji footer (AGENTS.md takes precedence per the reminder itself).",
"Labels: zero label writes — the dispatch names no label and skip-changeset does not apply (a patch changeset publishes)."
],
"ci": "Read once after the PR opened, not polled: 31 check runs on e8e6777 — 22 in_progress, 6 success, 3 skipped, 0 failure. Honest state: in_progress.",
"mcp_calls": "0 — no MCP GitHub tool was called (reads went through single-card REST GETs).",
"api_writes": "2 — both through the fleet relay (scripts/pm/fleet-write, as objectstack-fleet[bot]), each one repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft: true) = PR #20030, relay run 36057917701 success, stored body byte-identical to the sent body (11899 bytes, one footer); (2) this os-dev-report comment -> POST /repos//issues/20002/comments via post-stamped.mjs. 0 label writes. Not REST: 6 git pushes of claude/issue-20002-explain-sharing-fault (empty-branch probe, 0206768, e4f83ce, 683813f, 68131cc, e8e6777).",
"open_questions": [],
"out_of_scope_findings": [],
"pr_body_new": "Fixes #20002\n\nClause-②: no\n\nThesecurity/explainroute promises that it runs 「the same code paths the enforcement middleware runs」. Enforcement does not catch a failure in those calls, so the request fails. The explain engine caught the same failure and turned it into a value that its record matcher reads as an answer. So a request that fails was reported as one that succeeds. This is the same defect class as #19963 and #19986 (PR #19984, PR #20000): explain and enforcement give two different answers. Enforcement is not touched. This PR changes the report only.\n\n## Measurement first (onorigin/mainfc6ddb87a4, before any fix)\n\nThe stack is the realSecurityPlugin+SharingService+ both middlewares over one in-memory engine, in the shape of PR #20000's test file. For each caught fall-back inexplain-engine.ts, the dependency was made to fail, and the same request was run through both middlewares. Commit02067686d4adds the test file that pins the fix. On the unfixed engine its 10 fault cells are red and its 6 controls are green. The commit message carries this table.\n\n| Site | Caught into | What explain reported when the dependency failed | What enforcement did | Verdict |\n|---|---|---|---|---|\n|:848fetchRecord|null| Record not found:visible: false, nodecidedBy| The find throws on the same store fault | Fails toward not visible. Left as is. The plugin's own binding already catches tonull, so in this wiring this catch is never reached. |\n|:858computeLayeredRlsFilter|{ layer0: null, layer1: null }|allowed: false(the object-level catch denies), butrecord.visible: truefor a shared row, an owned row and an org-depth row | The find throws the injected error | Fails OPEN at row level, and contradictsallowed. Changed. |\n|:941listRecordShares|[]| Verdict unchanged, because the read filter decides it.rules[]is empty | Unaffected: enforcement never callslistShares| Only the attribution changes: it can only drop anadmitsrule. Left as is. |\n|:958sharingReadFilter(the card) |null| Share store down, own-depth reader:visible: trueon the unshared, shared and owned rows. Sharing isadmittedwithrowFilter: null| The find throws the store error | Fails OPEN. Changed. |\n|:966write gate (canEdit/canDelete) |undefined("no gate wired") | Update on an owned row:visible: true(private andpublic_read). On a row with only a READ share, sharing reportedadmitted| The by-id PATCH throws the injected error | Fails OPEN. Changed. |\n|:1128resolveSets|[]|allowed: false,decidedBy: object_crud| 403PERMISSION_DENIED| Fails toward deny. Left as is. |\n|:1140resolveDelegatorContext(not on the dispatch list, found by the same sweep) |{ kind: 'none' }("no delegation") | Delegator's grant store down:allowed: true,principalneutral. The D10 intersection was dropped (a healthy delegator givesallowed: false) | 503SERVICE_UNAVAILABLE| Fails OPEN (object-levelallowed). Changed. |\n|:1147delegatorresolveSets|[]|allowed: false,decidedBy: object_crud| The find throws | Fails toward deny. Left as is. |\n\nTwo storage-level controls from the same probe:\n\n- With the share store down, anorg-depth reader's read filter answersnullbefore it reads a share, so both sides admit.\n- The per-record gate catches its own store faults (writeGateFailClosed), so an update during the same outage already got the same answer from both sides.\n\n## What changed\n\nInpackages/plugins/plugin-security/src/explain-engine.ts:\n\n- The helper. Asettlehelper keeps a failure apart from every value the call can return: it returns aDEPENDENCY_FAULTsentinel instead. It is used only at the four fail-open sites.\n- Sharing read filter and write gate. The sharing layer's record outcome isnot_evaluated, with norowFilterand nomatchesRecord. Itsdetailsays the layer could not be evaluated and that the request fails on the same call.record.visibleisfalse, withdecidedBy: 'sharing'.\n - The call that is checked is the one the operation's verdict depends on: the read filter for a read, and the per-record gate for a write.\n - It is checked before the OWD, because the sharing middleware calls it whatever the OWD is.\n- Layered RLS composition. Thetenant_isolationandrlsrecord attributions arenot_evaluated, with a detail that names the failure.record.visibleisfalse, withdecidedBy: 'rls'. This matches the object-levelrlslayer, which already reports the same failure as a denial.\n- Delegator resolution. A newdelegatorUnresolvedflag fails closed like a missing delegator.principalandobject_cruddeny with their own wording, andallowedisfalse.\n- Order in the record verdict. Unchanged first: capability, CRUD, missing record, tenant exclusion, RLS exclusion. Then an RLS-composition failure, then a sharing failure. This follows the pipeline, where the RLS composition runs before the sharing middleware.\n\nOutcome vocabulary.ExplainRecordAttributionSchema.outcomeinpackages/spec/src/security/explain.zod.tsisadmitted | excluded | not_evaluated. The engine already usesnot_evaluatedfor "Tenant layer split is unavailable on this engine build" and for a record that is not found. Sonot_evaluatedplus a detail that names the failure says "could not be evaluated". The response has no new value and no new key, andpackages/specis not edited.\n\nNot changed:\n\n-security-plugin.ts. PR #20012 has since landed there, and this branch merges it.\n-default-permission-sets.ts.\n-packages/spec.\n- Enforcement.\n\n## Tests\n\nThe new filepackages/plugins/plugin-security/src/explain-dependency-fault.test.tsuses the realSecurityPlugin+SharingService+ both middlewares over one in-memory engine. Each fault cell asserts both sides:\n\n- Explain:record.visible === falsewith the nameddecidedBy, and the layer isnot_evaluatedwith norowFilterand nomatchesRecord.\n- Enforcement: the same request through both real middlewares fails with the injected fault itself, compared by identity. For the delegator, the cell asserts the ADR-0112 envelope:SERVICE_UNAVAILABLE/ 503.\n\nThe fault cells:\n\n- Share store down × unshared / shared / owned row (read), andbuildReadFilterfailing on apublic_readobject.\n-canEditfailing × owned row (private andpublic_read).\n-computeLayeredRlsFilterfailing × read-shared / owned / org-depth row. These cells also assertallowed: false.\n- Delegator's grant store down (sys_user_position):allowed: false, andprincipalandobject_cruddeny.\n\nThe controls:\n\n- A healthy shared row: admitted,decidedBy: sharing.\n- A healthy unshared row: excluded.\n- An org-depth reader whose filter answersnullwhile the share store is down: still admitted, because only a failure is a fault.\n- A healthy update gate, a healthy tenant wall, and a healthy delegator (principalneutral).\n\nAblation of every negative pin, on HEADe8e677786a. Each leg put one swallowing catch back withscripts/ablation-replace.mjs. The anchor went from 1 hit to 0, and the file's blob changed on disk. The test file then went red on exactly that site's cells. Each leg restored the file: blobfca6431074acequals HEAD, andgit diff HEADis empty.\n\n| Leg | Catch put back | Red | Green |\n|---|---|---|---|\n| A1 |:958.catch(() => null)| 4, the read-filter cells (record.visible:expected true to be false) | 12 |\n| A2 |:966.catch(() => undefined)| 2, the write-gate cells | 14 |\n| A3 |:858.catch(() => ({ layer0: null, layer1: null }))| 3, the layered-RLS cells | 13 |\n| A4 |:1140.catch(() => ({ kind: 'none' }))| 1 (allowed:expected true to be false) | 15 |\n\nThe test reaches the code under test through relativesrcimports (./security-plugin.js→./explain-engine.js), so nodistsits between the mutation and the run.\n\nRuns on HEADe8e677786a, after mergingorigin/mainat44639665ee:\n\n-@objectstack/plugin-security:typecheckexits 0, andtsconfig.test.jsonlists the new file. The full suite passes: 130 files, 2541 tests.\n- Consumers of the explain route, found withgrep -rln \"security/explain\\|explainAccess\" packages --include=*.test.ts:\n - rest:security-routes,security-explain-envelope,rest-write-response-internal-fields.tripwire: 45/45.\n - plugin-sharing:sharing-service: 131/131.\n - client:client.test: 217/217.\n - dogfood:api-key-owner-revoke,owd-public-read-write-write-floor,showcase-d7-default-profile: 23/23.\n - spec:type-alias-convention.pin,explain-zero-rows-sentinels.pin: 11/11.\n - objectql:engine-middleware-operation-vocabulary: 5/5.\n- Gates.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandson this HEAD derives 71 commands, and all 71 ran.\n -check-changeset-fixed,check:error-code-casingandcheck:filter-alias-parityalso ran, because their rosters sit under paths this diff touches.\n - Reconciliation, with every line recorded with its exit code:Run reconciliation — 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN.\n -check:type-check-debtfirst answered PREREQUISITE NOT MET, because the plugin-securitydistwas older than its sources. After a rebuild it answeredOK — 4 ledger entr(ies) re-measured … none above its recorded number.\n- Lint, narrowed to the changed files.eslint --no-inline-config --format jsonon the two changed TS files: 2 files, 0 errors, 0 warnings.\n - The changeset and the JSON ledger are outside eslint's configured file set ("File ignored because no matching configuration was supplied").\n - The config never enables type-aware linting (noparserOptions.project), so this diff cannot change the lint result of any other file.\n\n## Also in the diff\n\n-scripts/engine-double-contract.pinned.json: one row added (explain-dependency-fault.test.ts,findOne), regenerated with--write.check:engine-double-contract(RETAINED) needs the coverage ledger to record every new pinned double. No row was lost.\n-.changeset/20002-explain-sharing-fault.md:@objectstack/plugin-security: patch,Clause-②: no. The report now matches what enforcement already does, and no accept set moves.\n\n## Acceptance notes\n\n- No log line is added at the four sites. The suggested route said to log the failure the way enforcement does, but I did not, for two reasons:\n - The explain engine has no logger dependency, and wiring one would mean editingsecurity-plugin.ts, which is out of scope here.\n - AGENTS.md "Degradation log levels" says that a failure delivered to the caller is not a degradation. The report delivers it:not_evaluated, a detail, and a fail-closed verdict. The real request's failure shows up on the request that fails.\n- The detail names the failure, not the thrown message. Explaining your own access needs no capability, so a raw store error message would reach an ordinary caller.\n-:848and:941word a failure imprecisely. They still say "Record not found" or "0 share(s) attached". Their verdicts fail toward not visible, so they are left as is.\n- Thetenant_isolationlayer-levelverdictstaysnot_applicableunder a layered-RLS failure. That code is unchanged, and the verdict enum has no "unknown". The record attribution carries the failure.\n- A spec describe text does not mention failures.ExplainRecordAttributionSchema.outcome's describe text reads "not_evaluated (skipped/not row-scoped)". The engine already uses the value for "unavailable", so this is a docs detail inpackages/spec, and it is not edited here.\n\n"
}objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsReview — ACCEPT (landing gated on CI) · PR #20030 · head
e8e677786a·domain:servicesseat · 2026-09-24T20:58ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main(26550c6603), ⛔ not against the report's own account.Checklist
-
PR shape. Draft, targeting
main. The first line isFixes #20002, andClause-②: nostarts its own line. No other closing keyword. -
Scope. 4 files, +549 / −21:
explain-engine.ts: thesettlehelper and four sites;- a new 16-cell test file;
- one generated
scripts/engine-double-contract.pinned.jsonrow; .changeset/20002-explain-sharing-fault.md(@objectstack/plugin-securitypatch).
Not
security-plugin.ts(the branch merges fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 unchanged), notdefault-permission-sets.ts, notpackages/spec. NOT governed, 570 lines, merges clean overorigin/main. -
Diff, read line by line.
settlemaps a rejection to aDEPENDENCY_FAULTsymbol, kept apart from every resolved value. A synchronous throw propagates exactly as it did under the old.catch.- At the four fail-open sites (
:858layered RLS,:958the card's read filter,:966the write gate,:1140the delegator), the layer reportsnot_evaluatedwith norowFilter/matchesRecordand a could-not-be-evaluated detail.record.visibleisfalse, withdecidedByin pipeline order (rlsbeforesharing). A delegator whose grants cannot be read now denies atprincipalandobject_crud. - The detail names the failure, never the thrown message. That is correct: self-explain needs no capability.
- No new response key.
not_evaluatedis an existing value the engine already uses for "unavailable".
-
Measurement. A table of all eight
.catchsites: four fail open (changed) and four fail toward deny (left, with one-line verdicts). The eighth,:1140, was missing from the dispatch's list and was found by the same sweep. -
Tests. Red first on the unfixed engine: 10 red / 6 green. Each fault cell pins both sides: explain's verdict, and enforcement failing with the injected fault itself (compared by identity), or 503 for the delegator. Ablations A1–A4 each turn exactly their site's cells red, and every restore is blob-identical. plugin-security (2541) and every explain-route consumer suite are green.
-
Changeset prose. I read it against the diff; every claim matches, including 「Unchanged」.
Accepted deviations.
- Four sites instead of one. This is inside the claim's surface ("other fall-backs in the same file only where measurement shows could-not-evaluate read as admitted").
- No log line: the engine has no logger, and wiring one is a
security-plugin.tsedit. A failure delivered to the caller is not a degradation (AGENTS.md). - The generated ledger row. The claim's file surface is revised to include
scripts/engine-double-contract.pinned.json(this line is the revision), the same gate-prescribed class as fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984.
Out-of-scope findings: none. Acceptance notes carry:
:848/:941wording (their verdicts already fail closed);- the
tenant_isolationlayer verdict's lack of an "unknown" value; - the
ExplainRecordAttributionSchema.outcomedescribe text, which omits the failure reading ofnot_evaluated. That is apackages/specdocs detail, left in Acceptance notes.
Landing. No Clause-② limb is hit: no
packages/spec/src, and the declaration isno. The report is brought back to what enforcement already does, and no accept set moves. 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
-
objectstack-fleet commented
on Sep 24, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-24T22:03Z- PR fix(plugin-security): explain fails closed when a dependency it shares with enforcement throws #20030 →
8e9a425190onorigin/main. Parent count 1 (a queue squash).settle/DEPENDENCY_FAULTare present inexplain-engine.tsat that commit. The card closedcompletedthrough the PR's one closing keyword. - The explain route now fails closed at the four measured fail-open sites. It reports the failing layer as
not_evaluated, and the verdict is not visible or denied. - Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged.
Generated by Claude Code
- PR fix(plugin-security): explain fails closed when a dependency it shares with enforcement throws #20030 →
- added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Sep 29, 2026 - added a commit that references this issue
on Sep 29, 2026
Filing gate ① — a product defect with a named site and a measurement (class a).
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — 🟢 os-project-manager · session_01CBAfsWMSfM3EToQGVStEcp #6021).readasks sharing without the caller's read depth —record.visibleis false on rows the caller'sfindreturns #19986 dev on PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000, as an out-of-scope finding.domain:servicesseat, once PR fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth #20000 lands (same explain wiring).The defect
When plugin-sharing's read filter REJECTS (for example, the share store is unavailable),
security/explainreports the record as readable:record.visible: true,decidedBy: sharing, sharing outcome admitted. The enforcement path refuses the same read; itsfindthrows.record.visiblewould show a row the server then refuses.Site (from the dev's reading; ⛔ not re-read line-by-line by this seat)
packages/plugins/plugin-security/src/explain-engine.ts, around:958: a rejectedsharingReadFilteris caught intonull.nullfilter as "no filter" (it matches). So a failed sharing layer reads as admitted.resolveSharingReadFilter's docblock insecurity-plugin.tssays it throws so that the caller can fail closed.explain-enginedoes not fail closed.Measured (the #19986 dev, a one-time throwaway probe on the PR #20000 branch; ⛔ not re-run by this seat, and not run separately on
main)With the share-store reads throwing, on a private-OWD object, a principal with
ownread depth on an unshared row:record.visible: true(decidedBy: sharing,rowFilter: null);findthrough both real middlewares THREW.PR #20000 does not change this: the old bare binding rejected into the same catch.
Direction
A sharing-layer failure makes explain report the record as NOT visible (fail closed), with an outcome that says the layer could not be evaluated, not that it admitted. Which explain outcome value to use is the implementer's measured choice among the existing ones. The first step is to reproduce the probe on
main.Dedupe
One semantic issue search, open and closed, on the query 「explain engine sharingReadFilter rejection caught as null reported admitted, explain record visible true when sharing service throws, fail open in explain report」. It returned 10 hits. The adjacent ones are #19986 and #19963 (the explain input fixes); none covers the fault path.
Dedupe words:
explain sharingReadFilter catch null·explain visible true sharing fault·explain-engine fail open sharing error·buildReadFilter throws explain admittedGenerated by Claude Code