Skip to content

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

@objectstack-fleet

Filing gate ① — a product defect with a named site and a measurement (class a).

The defect

When plugin-sharing's read filter REJECTS (for example, the share store is unavailable), security/explain reports the record as readable: record.visible: true, decidedBy: sharing, sharing outcome admitted. The enforcement path refuses the same read; its find throws.

  • Direction: fails OPEN in the report, and enforcement is correct. A consumer that gates UI on record.visible would show a row the server then refuses.
  • The explain route's own docblock promises the report runs 「the same code paths the enforcement middleware runs」.

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 rejected sharingReadFilter is caught into null.
  • The record matcher treats a null filter as "no filter" (it matches). So a failed sharing layer reads as admitted.
  • resolveSharingReadFilter's docblock in security-plugin.ts says it throws so that the caller can fail closed. explain-engine does 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 own read depth on an unshared row:

  • explain answered record.visible: true (decidedBy: sharing, rowFilter: null);
  • the same find through 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 admitted


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-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:blocked Blocked-by #19986; rationale: explain-engine catches a rejected sharing read filter into null, and the record matcher reads null as "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/main b81da66df7 上核对。

    本席核对

    为什么挂在 #19986 后面

    卡面自己写了「actionable once PR #20000 lands (same explain wiring)」。PR #20000 是 #19986 的修复,正在改同一段 explain 接线。现在另开一个 PR 会和它互相覆盖。#19986 关闭时本卡解锁。

    定级说明

    p2,不加 security:

    解锁后的执行要点

    1. 先在 main 上重现探针:私有 OWD 对象,共享存储读取抛错,主体对未共享行只有 own 读深度 ⇒ 今天 explain 答 visible: true,而同一次 find 抛错。
    2. 修成 fail closed:共享层求值失败时,explain 报「不可见」,而且结果要说明「该层无法求值」,⛔ 不能说成「该层放行」。用现有的哪个 outcome 值,由实现者测量后选择。
    3. 顺带测量同一文件里其余 .catch(() => …) 回落,是否也把「求值失败」读成了「放行」。有同类问题就列在 PR 里一并处理,或者另开卡。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock scan: the blocker closed; the card is returned to pm:queue. domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T18:52Z


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: 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) under packages/plugins/plugin-security/src/; .changeset/20002-*.md. ⛔ Not security-plugin.ts (PR #20012). ⛔ Not default-permission-sets.ts (#20001, in flight). ⛔ No packages/spec: if the explain outcome vocabulary lacks a "could not be evaluated" value, stop and report. Measurement first: the probe reproduced on main. (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:

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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) and git diff HEAD is 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| :848 fetchRecord | null | Record not found: visible: false, no decidedBy | The find throws on the same store fault | Fails toward not visible. Left as is. The plugin's own binding already catches to null, so in this wiring this catch is never reached. |\n| :858 computeLayeredRlsFilter | { layer0: null, layer1: null } | allowed: false (the object-level catch denies), but record.visible: true for a shared row, an owned row and an org-depth row | The find throws the injected error | Fails OPEN at row level, and contradicts allowed. Changed. |\n| :941 listRecordShares | [] | Verdict unchanged, because the read filter decides it. rules[] is empty | Unaffected: enforcement never calls listShares | Only the attribution changes: it can only drop an admits rule. Left as is. |\n| :958 sharingReadFilter (the card) | null | Share store down, own-depth reader: visible: true on the unshared, shared and owned rows. Sharing is admitted with rowFilter: null | The find throws the store error | Fails OPEN. Changed. |\n| :966 write gate (canEdit / canDelete) | undefined ("no gate wired") | Update on an owned row: visible: true (private and public_read). On a row with only a READ share, sharing reported admitted | The by-id PATCH throws the injected error | Fails OPEN. Changed. |\n| :1128 resolveSets | [] | allowed: false, decidedBy: object_crud | 403 PERMISSION_DENIED | Fails toward deny. Left as is. |\n| :1140 resolveDelegatorContext (not on the dispatch list, found by the same sweep) | { kind: 'none' } ("no delegation") | Delegator's grant store down: allowed: true, principal neutral. The D10 intersection was dropped (a healthy delegator gives allowed: false) | 503 SERVICE_UNAVAILABLE | Fails OPEN (object-level allowed). Changed. |\n| :1147 delegator resolveSets | [] | 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\nThe security/explain route 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 (on origin/main fc6ddb87a4, before any fix)\n\nThe stack is the real SecurityPlugin + SharingService + both middlewares over one in-memory engine, in the shape of PR #20000's test file. For each caught fall-back in explain-engine.ts, the dependency was made to fail, and the same request was run through both middlewares. Commit 02067686d4 adds 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| :848 fetchRecord | null | Record not found: visible: false, no decidedBy | The find throws on the same store fault | Fails toward not visible. Left as is. The plugin's own binding already catches to null, so in this wiring this catch is never reached. |\n| :858 computeLayeredRlsFilter | { layer0: null, layer1: null } | allowed: false (the object-level catch denies), but record.visible: true for a shared row, an owned row and an org-depth row | The find throws the injected error | Fails OPEN at row level, and contradicts allowed. Changed. |\n| :941 listRecordShares | [] | Verdict unchanged, because the read filter decides it. rules[] is empty | Unaffected: enforcement never calls listShares | Only the attribution changes: it can only drop an admits rule. Left as is. |\n| :958 sharingReadFilter (the card) | null | Share store down, own-depth reader: visible: true on the unshared, shared and owned rows. Sharing is admitted with rowFilter: null | The find throws the store error | Fails OPEN. Changed. |\n| :966 write gate (canEdit / canDelete) | undefined ("no gate wired") | Update on an owned row: visible: true (private and public_read). On a row with only a READ share, sharing reported admitted | The by-id PATCH throws the injected error | Fails OPEN. Changed. |\n| :1128 resolveSets | [] | allowed: false, decidedBy: object_crud | 403 PERMISSION_DENIED | Fails toward deny. Left as is. |\n| :1140 resolveDelegatorContext (not on the dispatch list, found by the same sweep) | { kind: 'none' } ("no delegation") | Delegator's grant store down: allowed: true, principal neutral. The D10 intersection was dropped (a healthy delegator gives allowed: false) | 503 SERVICE_UNAVAILABLE | Fails OPEN (object-level allowed). Changed. |\n| :1147 delegator resolveSets | [] | 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, an org-depth reader's read filter answers null before 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\nIn packages/plugins/plugin-security/src/explain-engine.ts:\n\n- The helper. A settle helper keeps a failure apart from every value the call can return: it returns a DEPENDENCY_FAULT sentinel instead. It is used only at the four fail-open sites.\n- Sharing read filter and write gate. The sharing layer's record outcome is not_evaluated, with no rowFilter and no matchesRecord. Its detail says the layer could not be evaluated and that the request fails on the same call. record.visible is false, with decidedBy: '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. The tenant_isolation and rls record attributions are not_evaluated, with a detail that names the failure. record.visible is false, with decidedBy: 'rls'. This matches the object-level rls layer, which already reports the same failure as a denial.\n- Delegator resolution. A new delegatorUnresolved flag fails closed like a missing delegator. principal and object_crud deny with their own wording, and allowed is false.\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.outcome in packages/spec/src/security/explain.zod.ts is admitted | excluded | not_evaluated. The engine already uses not_evaluated for "Tenant layer split is unavailable on this engine build" and for a record that is not found. So not_evaluated plus a detail that names the failure says "could not be evaluated". The response has no new value and no new key, and packages/spec is 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 file packages/plugins/plugin-security/src/explain-dependency-fault.test.ts uses the real SecurityPlugin + SharingService + both middlewares over one in-memory engine. Each fault cell asserts both sides:\n\n- Explain: record.visible === false with the named decidedBy, and the layer is not_evaluated with no rowFilter and no matchesRecord.\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), and buildReadFilter failing on a public_read object.\n- canEdit failing × owned row (private and public_read).\n- computeLayeredRlsFilter failing × read-shared / owned / org-depth row. These cells also assert allowed: false.\n- Delegator's grant store down (sys_user_position): allowed: false, and principal and object_crud deny.\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 answers null while 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 (principal neutral).\n\nAblation of every negative pin, on HEAD e8e677786a. Each leg put one swallowing catch back with scripts/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: blob fca6431074ac equals HEAD, and git diff HEAD is 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 relative src imports (./security-plugin.js → ./explain-engine.js), so no dist sits between the mutation and the run.\n\nRuns on HEAD e8e677786a, after merging origin/main at 44639665ee:\n\n- @objectstack/plugin-security: typecheck exits 0, and tsconfig.test.json lists the new file. The full suite passes: 130 files, 2541 tests.\n- Consumers of the explain route, found with grep -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 --commands on this HEAD derives 71 commands, and all 71 ran.\n - check-changeset-fixed, check:error-code-casing and check:filter-alias-parity also 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-debt first answered PREREQUISITE NOT MET, because the plugin-security dist was older than its sources. After a rebuild it answered OK — 4 ledger entr(ies) re-measured … none above its recorded number.\n- Lint, narrowed to the changed files. eslint --no-inline-config --format json on 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 (no parserOptions.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 editing security-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- :848 and :941 word 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- The tenant_isolation layer-level verdict stays not_applicable under 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 in packages/spec, and it is not edited here.\n\n"
    }

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20030 · head e8e677786a · domain:services seat · 2026-09-24T20:58Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main (26550c6603), ⛔ not against the report's own account.

    Checklist

    • PR shape. Draft, targeting main. The first line is Fixes #20002, and Clause-②: no starts its own line. No other closing keyword.

    • Scope. 4 files, +549 / −21:

      • explain-engine.ts: the settle helper and four sites;
      • a new 16-cell test file;
      • one generated scripts/engine-double-contract.pinned.json row;
      • .changeset/20002-explain-sharing-fault.md (@objectstack/plugin-security patch).

      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), not default-permission-sets.ts, not packages/spec. NOT governed, 570 lines, merges clean over origin/main.

    • Diff, read line by line.

      • settle maps a rejection to a DEPENDENCY_FAULT symbol, kept apart from every resolved value. A synchronous throw propagates exactly as it did under the old .catch.
      • At the four fail-open sites (:858 layered RLS, :958 the card's read filter, :966 the write gate, :1140 the delegator), the layer reports not_evaluated with no rowFilter / matchesRecord and a could-not-be-evaluated detail. record.visible is false, with decidedBy in pipeline order (rls before sharing). A delegator whose grants cannot be read now denies at principal and object_crud.
      • The detail names the failure, never the thrown message. That is correct: self-explain needs no capability.
      • No new response key. not_evaluated is an existing value the engine already uses for "unavailable".
    • Measurement. A table of all eight .catch sites: 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.ts edit. 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 / :941 wording (their verdicts already fail closed);
    • the tenant_isolation layer verdict's lack of an "unknown" value;
    • the ExplainRecordAttributionSchema.outcome describe text, which omits the failure reading of not_evaluated. That is a packages/spec docs detail, left in Acceptance notes.

    Landing. No Clause-② limb is hit: no packages/spec/src, and the declaration is no. 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

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-24T22:03Z

    • PR fix(plugin-security): explain fails closed when a dependency it shares with enforcement throws #20030 → 8e9a425190 on origin/main. Parent count 1 (a queue squash). settle / DEPENDENCY_FAULT are present in explain-engine.ts at that commit. The card closed completed through 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:dispatched and the assignee. The claim is discharged.

    Generated by Claude Code

  7. added a commit that references this issue on Sep 28, 2026
    8e9a425
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions