Skip to content

fix(plugin-security): explain's record read verdict asks sharing with the caller's read depth - #20000

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19986-explain-read-scope
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-19986-explain-read-scope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19986

Clause-②: no

What changes

POST /api/v1/security/explain with { object, operation: 'read', recordId } now asks plugin-sharing's read filter with the same read depth the find path hands it. decision.record.visible for read equals whether the same caller's find returns the row. Enforcement admits and refuses exactly what it did before. Only the explain report changes.

Landing: packages/plugins/plugin-security/src/security-plugin.ts (the explainAccessForCaller dependency wiring only), a new test file, the changeset, and one generated row in scripts/engine-double-contract.pinned.json (see "Surface note"). The step-3.6 post-image block and the seam declaration are untouched. The producer is the explain wiring, as the card says; no other package is involved.

The two sides, quoted on origin/main ccccdcc35e before the change

  • Explain (security-plugin.ts:4426): sharingReadFilter: (o: string, c: any) => sharing.buildReadFilter(o, c). The bare context, no __readScope.
  • The find path, step 2.6 (security-plugin.ts:2375, :2403): for find / findOne / count / aggregate on a non-delegated context, sc.__readScope is set to this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }). permissionSets comes from resolvePermissionSetsForContext and secMeta from getObjectSecurityMeta. plugin-sharing's buildReadFilter then reads it: org gives no owner filter (null), and a unit depth gives the unit's owners.
  • The resolver already exists. resolveSharingReadFilter (security-plugin.ts:4490 after this change; it is the analytics path's seam) computes the depth from the same two inputs and the same evaluator call, stamps it, and calls buildReadFilter. Its docblock names the situation explain is in: no middleware runs on this path, so the depth is computed there. The change reuses it. Nothing new is computed.

Reproduced before the change

The new test file was committed first on the unfixed tree (07f447e0c0, base ccccdcc35e). It wires the real SecurityPlugin, the real SharingService and sharing middleware, and the real platform member_default seed. The object has an unset OWD (private). The one variable is the principal's read depth, on the same three rows:

principal (read depth) row explain record find
hr_reviewer (org) shared to someone else visible: false, decidedBy: sharing returns it
hr_reviewer (org) owned by someone else visible: false, decidedBy: sharing returns it
hr_reviewer (org) unshared visible: false, decidedBy: sharing returns it
team_lead (unit) owned inside the unit visible: false, decidedBy: sharing returns it
dept_reporter (own), the control read-shared / owned / unshared true / true / false returns / returns / refuses: agrees

Result: Tests 7 failed | 17 passed (24). The seventh red is a second direction on the old binding. A __readScope: 'org' already present on the explained context decided the report (dept_reporter × unshared: explain true, find refused), because the bare context was passed straight through.

The change

  • The non-delegated sharingReadFilter binding calls resolveSharingReadFilter(o, { ...c, __readScope: undefined }). The caller's own key is cleared first, so the depth is always the computed one, the way step 2.6 overwrites it. Where resolution yields nothing (no sets, or a failed resolution), the helper stamps nothing and plugin-sharing reads own, the safe direction.
  • On-behalf-of contexts keep sharing.buildReadFilter(o, c) unchanged, as fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 did on the write side. The middleware's delegated read (the agent-leg depth, plus a second filter AND-ed in under the delegator's identity) is not modelled on explain's record path. Stamping the agent's own depth without that second filter could only widen the report.
  • delegatedWrite is renamed actsOnBehalfOf: it now guards read and write bindings, and delegated is already a name inside this function (the delegated-admin gate).

Tests: explain-read-verdict-inputs.test.ts (24 cases)

Every cell asserts that record.visible for read equals whether the find returns the row, and it asserts the find's own answer too. A cell cannot go green because both sides drifted. The find runs through both real middlewares, and the engine double evaluates the composed AST where. The double's matcher throws on an operator it does not know, so a refused cell cannot go green because the double silently answered false. Both sides read one hierarchy resolver: unit = the lead and the reporter.

  • Read matrix, private OWD: builtin admin × unshared; hr_reviewer (org) × shared / owned / unshared; team_lead (unit) × owned inside the unit / owned outside it / shared to someone else; dept_reporter (own) × read-shared / owned / unshared. Negative control: dept_reporter × unshared, refused by both, and explain names sharing (layer excluded).
  • Depth: the org reader's sharing layer is admitted with rowFilter: null. A __readScope the caller brings does not decide the report, in either direction: dept_reporter + 'org' × unshared is refused by both, and hr_reviewer + 'own' × unshared is admitted by both.
  • On-behalf-of: the context explain hands plugin-sharing's read filter carries no __readScope.
  • OWD control public_read: all nine principal × row cells admit on both sides.

Unfixed tree: Tests 7 failed | 17 passed (24). Fixed (461187a0d2) and at the final head: Tests 24 passed (24).

Ablations

The fix was committed first (461187a0d2). Each mutation used scripts/ablation-replace.mjs: the anchor went from 1 hit to 0, the replacement from 0 to 1, and the blob changed on disk. After each restore the blob matched HEAD (229605a70e3a) and git diff HEAD was empty. The test imports ./security-plugin.js from source, so there is no dist/ hop and ablation-dist-preflight does not apply. Directions were predicted before each run.

ablation mutation result
A the old binding: sharing.buildReadFilter(o, c) for every context 7 failed / 17 passed: hr_reviewer × 3, team_lead × owned inside the unit, the org layer pin, and both caller-depth pins. The widening one reads u_reporter × unshared × read: … expected true to be false. The plain visible: false cells stay green, as predicted: the old binding is narrower and cannot widen.
B over-widening: every non-delegated context asked at __readScope: 'org' 5 failed / 19 passed: every visible: false pin (team_lead × unshared, team_lead × shared, dept_reporter × unshared, the negative control, the widening caller-depth pin), each expected true to be false.
C the on-behalf-of guard dropped 1 failed / 23 passed: the on-behalf-of pin (no read depth stamped on a delegated context: expected true to be false).
D the caller-key clear dropped: resolveSharingReadFilter(o, c) 24 passed, as predicted. The clear matters only where depth resolution yields nothing, and there explain's own CRUD layer already refuses, so record.visible cannot show it. It is declared, unpinned hygiene.

The first C attempt was a no-op. Its replacement text was a substring of its anchor, so ablation-replace saw the replacement count stay at 1, refused with exit 1, and ran no tests. The re-run with a distinct marker is the row above.

Local verification: final head 8644de0cbe

  • pnpm --filter '@objectstack/plugin-security...' build: exit 0.
  • Full plugin-security vitest run: Test Files 124 passed, Tests 2365 passed, at 6471f5564d. Later commits add only the ledger row and a main merge whose source changes sit in packages/mcp and packages/runtime, outside plugin-security's build closure. The three explain suites re-ran at 8644de0cbe: Tests 138 passed (138).
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0. The new test is in tsconfig.test.json's program (--listFiles: 1 hit) and check:test-typecheck is OK.
  • dispatch-gates --commands at 8644de0cbe, reconciled with --ran: 70 derived, 68 run with exit 0, 2 NOT MEASURED. check:dual-build-cjs-loads and check:type-check-debt exit 3 (PREREQUISITE NOT MET): they need every workspace package's dist/, which CI's Build Core owns. check:i18n was measured after building its declared closure: exit 0, 9 packages in sync.
  • GITHUB_TOKEN=… node scripts/check-issue-citations.mjs: exit 0, 1 citation, resolves.
  • Narrowed eslint (--no-inline-config --format json) over the two changed TS files: 2 files, 0 errors, 0 warnings. Both files resolve a non-empty config under --print-config. eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move the verdict of any untouched file.
  • Two main merges before opening (ccccdcc35e then bfa23a8f49 then 615c0856ef). Neither touched plugin-security, plugin-sharing, the ledger or the lockfile. fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988 had not landed.

Surface note

scripts/engine-double-contract.pinned.json gains one row (+5 lines: the new test file's findOne double). It was written by node scripts/check-engine-double-contract.mjs --write, which that gate prescribes for a new pinned double ("0 added or grown, 0 lost"). It lies outside the claim's declared file surface, the same as the three rows #19984 added. #19988 does not touch this file.

Acceptance notes

  • Explain's record path does not model the on-behalf-of read, before or after this PR. That is the agent-leg depth intersection plus the delegator's filter AND-ed in. It was not measured here. fix(plugin-security): explain's record update/delete verdict uses the by-id write path's inputs #19984 recorded the same boundary for writes.
  • Under a sharing-service fault, explain over-reports read visibility. This predates the PR, which does not change it. explain-engine.ts:958 catches a rejected sharingReadFilter into null, and the record matcher reads null as "no filter". A one-off probe on this branch made sys_record_share reads throw. dept_reporter × unshared then got explain visible: true (sharing admitted, rowFilter: null), while the same find threw. The old bare binding rejected into the same catch. This is reported for the seat to judge. It is not fixed here: the site is explain-engine.ts, outside this card's surface.
  • The test double's nested reads are not scoped by plugin-sharing's read filter. That boundary is recorded in row-write-widener-composition.test.ts, and no cell here depends on a nested read.

Generated by Claude Code

…ad depth

The record-grained read verdict asked plugin-sharing's buildReadFilter with
the bare context, while the find path stamps __readScope (step 2.6) first, so
an org or unit reader was reported record.visible: false on rows its find
returns. Route the non-delegated binding through resolveSharingReadFilter,
the in-class mirror of step 2.6, with the caller's own key cleared first.
The on-behalf-of context keeps its previous input.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
… double

Generated by check-engine-double-contract --write (0 seams added or lost).

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a method of class SecurityPlugin))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 51a2357ff0224c1eb86b02726c723225abe0f9d4 — the merge of head 8644de0cbe7fe1a711f97ab363ed41426b2010a3 into base e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 51a2357ff0224c1eb86b02726c723225abe0f9d4 && git checkout 51a2357ff0224c1eb86b02726c723225abe0f9d4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b 8644de0cbe7fe1a711f97ab363ed41426b2010a3 && git checkout -B drift-repro e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b && git merge --no-ff 8644de0cbe7fe1a711f97ab363ed41426b2010a3

node scripts/docs-audit/affected-docs.mjs --json e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 17:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit b9e9609 Sep 24, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19986-explain-read-scope branch September 24, 2026 17:53
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s with enforcement throws (objectstack-ai#20030)

Fixes objectstack-ai#20002

Clause-②: no

The `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 objectstack-ai#19963 and objectstack-ai#19986 (PR objectstack-ai#19984, PR objectstack-ai#20000):
explain and enforcement give two different answers. Enforcement is not
touched. This PR changes the report only.

## Measurement first (on `origin/main` `fc6ddb87a4`, before any fix)

The stack is the real `SecurityPlugin` + `SharingService` + both
middlewares over one in-memory engine, in the shape of PR objectstack-ai#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.

| Site | Caught into | What explain reported when the dependency failed
| What enforcement did | Verdict |
|---|---|---|---|---|
| `: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. |
| `: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. |
| `: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. |
| `: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. |
| `: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. |
| `:1128` `resolveSets` | `[]` | `allowed: false`, `decidedBy:
object_crud` | 403 `PERMISSION_DENIED` | Fails toward deny. Left as is.
|
| `: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. |
| `:1147` delegator `resolveSets` | `[]` | `allowed: false`, `decidedBy:
object_crud` | The find throws | Fails toward deny. Left as is. |

Two storage-level controls from the same probe:

- With the share store down, an `org`-depth reader's read filter answers
`null` before it reads a share, so both sides admit.
- 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.

## What changed

In `packages/plugins/plugin-security/src/explain-engine.ts`:

- **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.
- **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'`.
- 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.
- It is checked before the OWD, because the sharing middleware calls it
whatever the OWD is.
- **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.
- **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`.
- **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.

**Outcome 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.

**Not changed:**

- `security-plugin.ts`. PR objectstack-ai#20012 has since landed there, and this
branch merges it.
- `default-permission-sets.ts`.
- `packages/spec`.
- Enforcement.

## Tests

The 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:

- **Explain:** `record.visible === false` with the named `decidedBy`,
and the layer is `not_evaluated` with no `rowFilter` and no
`matchesRecord`.
- **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.

The fault cells:

- Share store down × unshared / shared / owned row (read), and
`buildReadFilter` failing on a `public_read` object.
- `canEdit` failing × owned row (private and `public_read`).
- `computeLayeredRlsFilter` failing × read-shared / owned / org-depth
row. These cells also assert `allowed: false`.
- Delegator's grant store down (`sys_user_position`): `allowed: false`,
and `principal` and `object_crud` deny.

The controls:

- A healthy shared row: admitted, `decidedBy: sharing`.
- A healthy unshared row: excluded.
- An org-depth reader whose filter answers `null` while the share store
is down: still admitted, because only a failure is a fault.
- A healthy update gate, a healthy tenant wall, and a healthy delegator
(`principal` `neutral`).

**Ablation 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.

| Leg | Catch put back | Red | Green |
|---|---|---|---|
| A1 | `:958` `.catch(() => null)` | 4, the read-filter cells
(`record.visible`: `expected true to be false`) | 12 |
| A2 | `:966` `.catch(() => undefined)` | 2, the write-gate cells | 14 |
| A3 | `:858` `.catch(() => ({ layer0: null, layer1: null }))` | 3, the
layered-RLS cells | 13 |
| A4 | `:1140` `.catch(() => ({ kind: 'none' }))` | 1 (`allowed`:
`expected true to be false`) | 15 |

The 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.

**Runs on HEAD `e8e677786a`**, after merging `origin/main` at
`44639665ee`:

- `@objectstack/plugin-security`: `typecheck` exits 0, and
`tsconfig.test.json` lists the new file. The full suite passes: **130
files, 2541 tests**.
- Consumers of the explain route, found with `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.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` on this HEAD derives 71 commands,
and all 71 ran.
- `check-changeset-fixed`, `check:error-code-casing` and
`check:filter-alias-parity` also ran, because their rosters sit under
paths this diff touches.
- Reconciliation, with every line recorded with its exit code: `Run
reconciliation — 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN`.
- `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`.
- **Lint, narrowed to the changed files.** `eslint --no-inline-config
--format json` on the two changed TS files: 2 files, 0 errors, 0
warnings.
- The changeset and the JSON ledger are outside eslint's configured file
set ("File ignored because no matching configuration was supplied").
- The config never enables type-aware linting (no
`parserOptions.project`), so this diff cannot change the lint result of
any other file.

## Also in the diff

- `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.
- `.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.

## Acceptance notes

- **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:
- The explain engine has no logger dependency, and wiring one would mean
editing `security-plugin.ts`, which is out of scope here.
- 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.
- **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.
- **`: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.
- **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.
- **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.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant