Repository navigation
fix(plugin-security): explain fails closed when a dependency it shares with enforcement throws - #20030
Conversation
… rejects Measurement first, on origin/main fc6ddb8, before any fix. The new file wires the real SecurityPlugin, SharingService and both middlewares over one in-memory engine; its 10 fault cells are RED here and its 6 controls green. What explain reported when each caught dependency rejected, against what the same request did through the real middlewares (throwaway probe, same stack): :848 fetchRecord -> null. Record reported not found, visible false; the find throws on the same store fault. Fails toward not-visible. Leave. (The plugin's own binding already catches to null.) :858 computeLayeredRlsFilter -> {layer0: null, layer1: null}. allowed false (the object-level catch denies) but record.visible TRUE for a shared, an owned and an org-depth row; the find throws. FAIL OPEN. :941 listRecordShares -> []. Attribution only: the read filter still decides; enforcement never calls it. Can only drop an admits rule. Leave. :958 sharingReadFilter -> null (the card). Share store down, own-depth reader: visible TRUE on unshared, shared and owned rows, sharing admitted with rowFilter null; the find throws. FAIL OPEN. :966 writeGate -> undefined. canEdit rejects: update on an owned row visible TRUE (private and public_read); the PATCH throws. FAIL OPEN. :1128 resolveSets -> []. allowed false, decidedBy object_crud; the find answers 403. Fails toward deny. Leave. :1140 resolveDelegatorContext -> {kind: none}. Delegator grant store down: allowed TRUE, principal neutral, the D10 intersection dropped; the find answers 503 SERVICE_UNAVAILABLE. FAIL OPEN. :1147 delegator resolveSets -> []. allowed false; the find throws. Fails toward deny. Leave. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…hrows
security/explain calls the functions the enforcement middleware calls, and
enforcement runs them un-caught: when one rejects, the request fails. The
engine caught the same rejection into a value its matcher reads as an
answer, so a request that fails was explained as one that succeeds.
A `settle` helper now keeps a rejection apart from every value the call
could resolve to, at the four sites where the fold widened the report:
- the sharing read filter (null read as "no filter");
- the sharing per-record update/delete gate ("no gate wired");
- the layered RLS composition ("no tenant wall, no business RLS");
- the on-behalf-of delegator resolution ("no delegation").
Each reports its layer not_evaluated with no rowFilter and no
matchesRecord, the record not visible (decidedBy sharing / rls), and for
the delegator a principal and object_crud denial. No new response keys;
not_evaluated is an existing outcome value. The four fall-backs that
already failed toward deny are left as they were.
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…gine double check:engine-double-contract (RETAINED) asks for the coverage ledger to learn the new pinned double; regenerated with --write, one row added, none lost. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
check:objectql-double-limit could not judge a find that threw from its own body. The outage now lives in the table (its filter throws), so find is an ordinary double that applies limit by presence, after the filter. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Brings in the plugin-security / objectql by-id update change that landed on main, before the PR opens (AGENTS.md multi-agent section 10). Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 67fc7fe2eecaf1ec6df85c828af4e8703cf53910 && git checkout 67fc7fe2eecaf1ec6df85c828af4e8703cf53910
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 26550c6603a920c28f04ca94b039388069ecbaad e8e677786ae7f15ecf64426657874d962f528a2d && git checkout -B drift-repro 26550c6603a920c28f04ca94b039388069ecbaad && git merge --no-ff e8e677786ae7f15ecf64426657874d962f528a2d
node scripts/docs-audit/affected-docs.mjs --json 26550c6603a920c28f04ca94b039388069ecbaad |
…w a write stores (objectstack-ai#20013) (objectstack-ai#20043) Fixes objectstack-ai#20013 Clause-②: no (narrowing) ## What this fixes Step 3.7 of the security middleware is the Layer 0 tenant write wall (ADR-0095 D1, ADR-0105 D5). Its contract, `packages/plugins/plugin-security/src/security-plugin.ts` lines 3239-3248 on `origin/main` `246314dffe`: > Both close identically here: a SUPPLIED (non-empty) `organization_id` in the write payload must satisfy the SAME Layer 0 filter the read side uses (isolation active, tenant object, platform-admin posture exemption, fail-closed on a missing active org). For UPDATE this makes `organization_id` effectively immutable in non-platform user contexts: the only value that passes is the caller's active org (which — since the pre-image already scoped the target to that org — equals the row's current org), so a re-point to any OTHER tenant is denied. A bulk update carrying a cross-tenant `organization_id` change-set is caught too (the check inspects the change-set value, not a per-row post-image). The wall judged `opCtx.data`, the payload as the caller sent it, before `next()` runs the engine's `beforeInsert` / `beforeUpdate` chain. A value a hook wrote into `organization_id` was never judged by the wall, so the row was stored in whatever organization the hook named. The engine records the insert twin of the same gap at `packages/objectql/src/engine.ts` lines 11846-11847 on `246314dffe`: 「The Layer 0 tenant wall still judges the PRE-hook image — filed separately, and the fix's host is this seam.」 The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`). The refusal is the wall's existing one: `PERMISSION_DENIED` / 403, nothing stored. The judgement of the payload as sent stays, so this change only ever refuses more. No engine change. ## Mechanism hypotheses (dispatch Section 2), measured Base: `origin/main` `26550c6603`. Real `SecurityPlugin` and `ObjectQL`, `isolated` posture, driver-sql (better-sqlite3) and driver-sqlite-wasm. The fixture is a tenant object whose `beforeInsert` and `beforeUpdate` hook copies another payload field into `organization_id`, and a caller whose active organization is `org_a`. 1. **Held.** Step 3.7 judged only rows whose `organization_id` was present in `opCtx.data` (the `suppliedRows` filter), before `next()`. 2. **Reproduced on both drivers, all three legs** (identical on both): | leg | outcome on the base | stored `organization_id` | |:--|:--|:--| | control: by-id update supplies `org_b` | refused, `PERMISSION_DENIED` / 403 ("the update would place 'qa_account' in another tenant") | `org_a`, unchanged | | control: insert supplies `org_b` | refused, `PERMISSION_DENIED` / 403 | no row | | by-id update, hook writes `org_b` | **admitted** | **`org_b`** | | insert, hook writes `org_b` | **admitted** | **`org_b`** | | array insert, hook writes `org_b` on the second row | **admitted** | first row `org_a`, second **`org_b`** | | predicate update (`multi: true`), hook writes `org_b` | **admitted** | **`org_b`** | | control: by-id update, hook writes `org_a` | admitted | `org_a` | 3. **The seam is installed only where a business `check` applies**, measured: with no row-level policy on the object, an inner middleware saw `postHookWriteImageCheck` absent on every leg above. So the wall gets its own installation condition: a walled posture, a tenant object, a caller Layer 0 does not exempt (that is, `computeWriteTenantCheckFilter` returns a filter). When step 3.6 also installed its judgement, the two are composed into the one handle the engine runs, in the order the middleware judges in (the `check`, then the wall). **A second finding shaped the fail-closed guard.** The post-`next()` guard now covers the new installation. The ADR-0094 permission-set data door executes an insert or update of `sys_permission_set` itself, through the metadata protocol, and never calls `next()`. Measured on the base with a platform administrator (active organization `org_a`) under `isolated`: Layer 0 walls the object (`organization_id = org_a`), the insert is admitted, and the engine's write never runs. A guard-covered wall seam alone would turn that into a 403. No engine write runs there, so no hook chain runs, and the payload judgement has already cleared the only `organization_id` such a write can carry. The guard therefore stands down for a seam that carries the wall alone on a write the door executed itself. The fact is observed by a wrapper around the door's registration, which records a write the door never passed to `next()` in a plugin-private `WeakSet`. No operation field is involved that another middleware could set. A seam carrying a row-level `check` is not stood down: the data door keeps failing closed under one, exactly as before. **ADR-0095 D1 "Not touched"** records that D1 added no tenant post-image check to `computeWriteCheckFilter`. Its reason is that such a check "would risk denying legitimate inserts before the auto-stamp runs". This change does not touch `computeWriteCheckFilter`, and it judges an image only when the image names an organization. An absent value (the auto-stamp's to fill) is never judged, and a control pins that. ADR-0105 D5 affirms the direction: an explicit value is validated against the membership set or equality. ## What changed - `packages/plugins/plugin-security/src/security-plugin.ts` - Step 3.7 computes the wall when a payload names an organization (as before) or the posture walls. A `single` posture pays for nothing new. - One refusal (`denyTenantPlacement`) serves both halves. The step installs the stored-row judgement whenever the wall applies (insert, or a non-array update, as step 3.6 scopes it), composed after step 3.6's judgement when one is installed. - The post-`next()` guard covers the new installation with the data-door stand-down above. Its developer message names what was not evaluated: the business-check sentence is unchanged byte for byte, and a wall-only seam gets its own sentence. - The data door is registered through the observing wrapper. - `packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts` (new): 34 cells, 17 per driver. - 11 existing plugin-security test files: engine doubles (see "Surface beyond the claim"). - `.changeset/20013-tenant-wall-post-hook.md`: `@objectstack/plugin-security` minor, BREAKING, remedy, `not-required (no-migration-prescription)`. ## Tests New file, real `SecurityPlugin` and `ObjectQL` on both SQL drivers, `isolated` posture unless a cell says otherwise. Every refusal asserts `code` `PERMISSION_DENIED`, `status` 403 and the wall's message ("the insert/update would place 'OBJECT' in another tenant"), then reads the table straight off the driver. - **Negative cells:** - a hook-written out-of-scope organization, refused on four paths: a by-id update, an insert, an array insert (the whole write refused) and a predicate update; - the same with a business `check` installed and passing (one composed seam); - the composed seam still runs the `check` (an in-scope insert the check refuses is refused); - `group` posture: outside the membership set refused, inside admitted; - fail-closed: a host that strips the installed wall-only seam is refused, with the guard's wall sentence. - **Controls:** - an in-scope hook write is admitted on all three paths; - a supplied out-of-scope value is refused before the hook chain, as before; - only refuses more: a supplied out-of-scope value stays refused when a hook would replace it with an in-scope one; - an update that does not touch the column is admitted on both update paths; - an insert that leaves the column absent is admitted and lands in `org_a`; - a platform administrator on a `private` object is exempt; - a system-context write is ungated; - the `single` posture is unchanged; - the data door under `isolated`: admitted, and the engine's write never ran. **Failing first.** The file was committed before the fix (`8afaec51a1`). On the base it gave 14 red (the 7 negative cells that existed then, × 2) and 18 green. Every red read "expected a refusal, got a completed write". **Ablations**, each through `scripts/ablation-replace.mjs` in WRAP mode, with the anchor hit 1 → 0 and a blob change reported by the tool. Each ran under an outer `trap` restoring from `HEAD`, and was then proven restored (blob == HEAD `278b25f19e`, empty `git diff HEAD`). They ran on `e43cda19b4`, whose blobs for `security-plugin.ts` and the pin file equal the final head's. The subject is imported relatively by the test file and `@objectstack/objectql` is aliased to `src/` in this package's `vitest.config.ts`, so no `dist/` sits between a mutation and the run. | # | mutation | red (of 34) | |---|---|---| | A1 | the stored-row wall judgement never refuses | 12: by-id, insert, array insert, predicate, composed-with-check, `group`, × 2 | | A2 | the wall's seam installed only when a business `check` is (the old condition) | 12: by-id, insert, array insert, predicate, `group`, fail-closed, × 2 | | A3 | the guard stands down for every wall-only seam | 2: fail-closed, × 2 | | A4 | no data-door stand-down | 2: the data door, × 2 | | A5 | the payload judgement dropped | 4: supplied-value control and only-refuses-more, × 2 | | A6 | an absent `organization_id` judged too | 2: the absent-value insert control, × 2 | | A7 | the composed seam drops the business `check` | 2: the composed-check cell, × 2 | A5 has an extra reading. Without the payload judgement, a *supplied* out-of-scope value is admitted but lands nowhere. The engine's static `readonly` strip drops a caller-sent `organization_id`, which the registry injects as `readonly: true`, while a hook-written value is exempt from that strip. That exemption is why the hook path reached the store, and why the payload refusal is the loud half of the supplied case. **Suites** (final head `cce969cf4d`, after merging `origin/main` `7766b62282`, closure rebuilt): - plugin-security: 133 files, 2648 tests passed; - plugin-auth: 114 files, 2439 tests passed; - runtime: 278 files, 3962 passed, 1 skipped; - plugin-security `typecheck`: exit 0; the test layer compiles all 131 test files (`--listFiles`), and its ledger holds 0 files / 0 errors. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on `cce969cf4d` derived 62 families: the dispatch-time 49 plus 13 more. The 13 are `check-adr-0087-registration` and `check-empty-changeset` (each with its self-test), `release-rehearsal-clone --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All 62 ran, and `--ran` reconciled 62 derived / 62 run / 0 NOT-MEASURED, every line carrying its exit code, all 0. `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3) and were re-run green after a full workspace build. The board-probing `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` passed: 9 citations resolve. Narrowed lint: `eslint --no-inline-config --format json` over the 13 touched `.ts` files reported 13 files, 0 errors, 0 warnings. It is a full measurement for these files for three reasons. The population is the `**/*.{ts,…}` and `packages/**` blocks of `eslint.config.mjs`. The count, 13, comes from the JSON output. The config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict for any untouched file. The repo-wide `pnpm lint` and the Dogfood Regression Gate are left to CI. ## Surface beyond the claim, with reasons The new installation runs on every walled insert and update. So every plugin-security test harness that stands in for the engine with a terminal that skips the seam, under a walled posture, was refused by the fail-closed guard (48 red on the first full run). Each double now runs the seam the way the engine does: an insert's rows as sent (no hooks in a double), and on an update the by-id row or the matched rows merged with the payload, through the producer's dispatch predicate where the double dispatches updates. - The 10 files that went red on the first run: `authz-matrix-gate`, `can-write-object-admission`, `check-only-write-scope`, `controlled-by-parent-detail-write-authority`, `controlled-by-parent-master-widener`, `explain-write-verdict-inputs`, `no-active-organization-write-refusal`, `row-write-widener-composition`, `select-only-write-visibility` and `tenant-layer0-verdict-on-operation`. - `explain-dependency-fault`, which arrived with the merge of `origin/main` (PR objectstack-ai#20030) and went red on the merged tree for the same reason. - `check:engine-double-contract` is green, with no ledger change. - Census outside the package: `grep -rln postHookWriteImageCheck packages --include=*.test.ts` names only plugin-auth's `sys-user-self-service-route.test.ts` (PR objectstack-ai#20012's). The hand-made SecurityPlugin hosts under a walled posture are runtime's `share-links-enforcement-context` and `standalone-stack-seeder-declaration-copy`. All pass unchanged (plugin-auth and runtime suites green), so none is touched. ## Behaviour that changes (all in the refusing direction) - Under `isolated` or `group`, an insert, by-id update or predicate update whose hook chain leaves `organization_id` outside the caller's organization scope (or the delegator's, ADR-0090 D10) is refused, and nothing is stored. - A walled write on a host that installs the wall's judgement and never runs it is refused after the write, with an `error` log. The ADR-0094 data door is the stated exception, for a wall-only seam. ## Pending changesets This change makes no sentence in a pending changeset false. None says the tenant wall is unchanged. The "admitted as before" and "judged exactly as before" sentences in `19950-rls-check-multi-row-writes.md` and `19989-by-id-update-post-hook-check.md` are scoped to the row-level `check`, whose judgement this change does not alter. So no deliberate correction was made, and `check-empty-changeset` is green. ## Acceptance notes - `packages/objectql/src/engine.ts` lines 11846-11847 still read "The Layer 0 tenant wall still judges the PRE-hook image — filed separately". After this change that sentence is false: the wall judges the stored row through that same seam. The file was held by the engine lane at dispatch, and this PR does not touch it. Suggested replacement for the engine lane: "(The Layer 0 tenant wall judges this row too, through the same seam; objectstack-ai#20013.)". - Cost: - Under a walled posture, every non-system insert and update now computes the Layer 0 filter. - A walled predicate update now always pays the engine's memoized matched-row read, because the seam receives matched rows merged with the payload. On an object with an update hook or a row-reading rule that read already happened. On one with neither it is new. There it is one `driver.find` over the composed `where`, with no row ceiling on that path. The row-level `check` seam (objectstack-ai#19950) already imposes the same cost where a `check` applies. NOT MEASURED: bulk-update latency or memory. - An absent or emptied `organization_id` is not judged by either half. That mirrors step 3.7's "supplied (non-empty)" scope and ADR-0095 D1's reason. A hook that clears the column on an update therefore lands a row with no organization. It is not measured here, and no declared contract covers it. - An array UPDATE payload gets no stored-row wall judgement, as step 3.6 gets none (the engine takes one payload per update). The payload judgement still covers it. - A by-id update is now judged by the wall twice: the payload as sent, before `next()`, and the stored row, in the engine. The first is what keeps this change refusing only more (A5). - The merge commit `fe1579223a` carries no `Claude-Session` trailer; the other commits carry the model-free pair. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…for a row-level policy comparing two fields of no shared comparison class (objectstack-ai#20598) Fixes objectstack-ai#20431 Clause-②: no ## What was wrong A row-level policy can compare two fields that share no comparison class, for example a text field against a number field. Enforcement refuses every request such a policy scopes. The find answers `INVALID_FILTER` / 400. A by-id update or delete fails closed at its row-level gate (403), because that gate's pre-image read is the same refused read. The record-grained explanation judged the same predicate in-process without the object's declared columns. It compared the two raw values and reported a record verdict: `visible: true` for one ordering of a pair, and `visible: false` (rls `excluded`) for the other. Both answers covered a request that enforcement refuses. ## What changed The landing point is `packages/plugins/plugin-security/src/explain-engine.ts`, as the dispatch expected; the other files are the pin file and the changeset. There are no changes to `security-plugin.ts`, `packages/formula`, `packages/spec`, the REST layer, or enforcement. - The record matcher (`matchesFilterCondition`) now receives the object's declared columns (`options.fields`), as the RLS write check does. They are read from `ql.getSchema(object)`: the schema the engine already reads for the OWD, and the ObjectQL registry that the find's driver compiles against. A schema that cannot be read hands over no columns, and the matcher judges values only, as before. - With the columns, the matcher refuses the comparison. Explain answers with that refusal: the explanation fails with `INVALID_FILTER` / 400 (the matcher's code and status, the envelope the find answers with), and no record verdict is reported. The message names the policy and both fields with their declared types. The matcher's own error rides as `cause`. - Naming the fields discloses nothing new. The report explain gives the same caller for the same object already publishes that predicate (`readFilter`, or the `rls` layer's `rowFilter`). ## Why a refusal and not a fail-closed report: dispatch assumption A3 did not hold A3 said to reuse PR objectstack-ai#20030's shape (layer `not_evaluated`, `record.visible: false`) for "enforcement refuses this read". Measurement on `main` says otherwise: - Explain already answers the matcher's other `INVALID_FILTER` refusals as a refusal. - `rls-stored-list-ordering-fails-closed.test.ts` (landed in `de091b50`, PR objectstack-ai#20310) pins it: "explain read 400 = find 400; explain update 400, the by-id update 403". One of its cells is a field-to-field comparison against a list-holding field. - PR objectstack-ai#20030's shape covers a dependency call that fails, not a predicate the matcher refuses. My first commit used the report shape. The full `plugin-security` suite then turned 2 cells of that landed pin red, because its field-to-field cell is now caught first by the comparison-class rule. Keeping the report shape would have added the second refusal dialect the dispatch forbids. So this PR follows the ruling's intent: "the read is refused … both orderings answer the same refusal as find". ## Measurement: before and after (better-sqlite3, the same stack as the pins) | policy class | find | by-id update / delete | explain read / update / delete, before | after | |---|---|---|---|---| | text vs number | 400 `INVALID_FILTER` | 403 `PERMISSION_DENIED` | `visible: true`, `decidedBy: 'rls'`, rls `admitted` | refused, 400 `INVALID_FILTER` | | number vs text (the other ordering) | 400 `INVALID_FILTER` | 403 `PERMISSION_DENIED` | `visible: false`, `decidedBy: 'rls'`, rls `excluded` | refused, 400 `INVALID_FILTER` | | text vs text (control) | the row | admitted | `visible: true`, `decidedBy: 'rls'` | unchanged | ## Tests New file: `packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts`. It uses the real `SecurityPlugin`, `ObjectQL` and SQL drivers (better-sqlite3 and sqlite-wasm; PostgreSQL when `OS_TEST_POSTGRES_URL` is set), on PR objectstack-ai#20427's harness. Every refused cell asserts both halves with their envelope `code` and `status`: explain's answer, and the caller's real request. - Five cells: text vs number, text vs image, text vs formula, text vs json, and number vs text. Each checks read, update and delete. Explain answers `{ code: 'INVALID_FILTER', status: 400 }` and its message names the policy and both fields. Find answers `INVALID_FILTER` / 400, update and delete answer `PERMISSION_DENIED` / 403, and nothing is stored. - Both orderings of one pair get `{ find: INVALID, explain: INVALID }`. - Control, same class: find returns only the matching row. Explain reports `visible: true` / `admitted` for it and `visible: false` / `excluded` for the other row. The update is admitted and matches explain. Pre-fix run: `main`'s `explain-engine.ts` restored from the base blob `92716c91`, under a trap whose restore is proven by the HEAD blob and an empty `git diff HEAD`. Result: `Tests 12 failed | 2 passed | 7 skipped (21)`. The 2 passes are the controls. **Ablation:** only the declared-columns argument was removed, through `scripts/ablation-replace.mjs`. The anchor hit 1 → 0 and the blob went `a46456db` → `5a314958`. Result: `Tests 12 failed | 2 passed | 7 skipped (21)`. Every refused cell on both drivers failed: ```text AssertionError: expected 'answered' not to be 'answered' // Object.is equality AssertionError: expected { find: { …(2) }, explain: 'admitted' } to deeply equal { find: { …(2) }, explain: { …(2) } } ``` Restore: `ok restored: blob == HEAD (a46456d) and git diff HEAD is empty`. All figures below were measured at `5e48f52c`, the head after merging `origin/main` `c876a742`: - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: `Test Files 144 passed (144)`, `Tests 3066 passed | 23 skipped (3089)`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, with the test layer OK. `tsc -p tsconfig.test.json --listFiles` counts the new file once. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 64 commands, and all 64 ran with exit 0. Three first answered exit 3 `PREREQUISITE NOT MET` (`check:dual-build-cjs-loads`, `check:i18n`, `check:type-check-debt`). I rebuilt with `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (71/71 tasks) and re-ran them; all three answered exit 0. `dispatch-gates --ran`: `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`. - Lint, narrowed: `eslint --no-inline-config --format json` over the two touched `.ts` files gives 2 files, 0 errors, 0 warnings. `eslint --print-config` shows no `parserOptions.project` / `projectService`. Linting is not type-aware, so this diff cannot move any untouched file's verdict. ## Acceptance notes - **The REST door answers 500 for this refusal.** The explain route's catch maps only `PERMISSION_DENIED` → 403 and `OBJECT_NOT_FOUND` → 404; every other throw becomes `500 EXPLAIN_FAILED`. I measured it through the real handler (`security-explain-envelope.test.ts` harness): a service refusal carrying `INVALID_FILTER` / 400 comes back as `{ status: 500, error: { code: 'EXPLAIN_FAILED', message: … } }`. The refusal's message survives. PR objectstack-ai#20310's refusals were already answered this way. It lives in `packages/rest/src/rest-server.ts`, outside this card's surface, so it is reported, not fixed here. - **The object-level answer is unchanged.** An explanation without a `recordId` runs no record matcher. For a read under such a policy, it still reports `allowed: true` and rls `narrows`, where the find answers 400. This PR does not change that; it is reported separately. - **Missing record, not measured.** When the record does not exist, the matcher never runs, so explain keeps its missing-record answer (`visible: false`, no `decidedBy`) for a policy the find would refuse. - **Duplicated attribution.** The policy-name attribution (`refusedPolicyNamesOf`) copies the RLS write check's attribution in `security-plugin.ts`. That file is held by objectstack-ai#20555, so one shared helper is left to whoever next touches both files. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20002
Clause-②: no
The
security/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.Measurement first (on
origin/mainfc6ddb87a4, before any fix)The 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 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.:848fetchRecordnullvisible: false, nodecidedBynull, so in this wiring this catch is never reached.: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 rowallowed. Changed.:941listRecordShares[]rules[]is emptylistSharesadmitsrule. Left as is.:958sharingReadFilter(the card)nullvisible: trueon the unshared, shared and owned rows. Sharing isadmittedwithrowFilter: null:966write gate (canEdit/canDelete)undefined("no gate wired")visible: true(private andpublic_read). On a row with only a READ share, sharing reportedadmitted:1128resolveSets[]allowed: false,decidedBy: object_crudPERMISSION_DENIED:1140resolveDelegatorContext(not on the dispatch list, found by the same sweep){ kind: 'none' }("no delegation")allowed: true,principalneutral. The D10 intersection was dropped (a healthy delegator givesallowed: false)SERVICE_UNAVAILABLEallowed). Changed.:1147delegatorresolveSets[]allowed: false,decidedBy: object_crudTwo storage-level controls from the same probe:
org-depth reader's read filter answersnullbefore it reads a share, so both sides admit.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:settlehelper 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.not_evaluated, with norowFilterand nomatchesRecord. Itsdetailsays the layer could not be evaluated and that the request fails on the same call.record.visibleisfalse, withdecidedBy: 'sharing'.tenant_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.delegatorUnresolvedflag fails closed like a missing delegator.principalandobject_cruddeny with their own wording, andallowedisfalse.Outcome 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.Not changed:
security-plugin.ts. PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 has since landed there, and this branch merges it.default-permission-sets.ts.packages/spec.Tests
The new file
packages/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:record.visible === falsewith the nameddecidedBy, and the layer isnot_evaluatedwith norowFilterand nomatchesRecord.SERVICE_UNAVAILABLE/ 503.The fault cells:
buildReadFilterfailing on apublic_readobject.canEditfailing × owned row (private andpublic_read).computeLayeredRlsFilterfailing × read-shared / owned / org-depth row. These cells also assertallowed: false.sys_user_position):allowed: false, andprincipalandobject_cruddeny.The controls:
decidedBy: sharing.nullwhile the share store is down: still admitted, because only a failure is a fault.principalneutral).Ablation of every negative pin, on HEAD
e8e677786a. 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.:958.catch(() => null)record.visible:expected true to be false):966.catch(() => undefined):858.catch(() => ({ layer0: null, layer1: null })):1140.catch(() => ({ kind: 'none' }))allowed:expected true to be false)The test reaches the code under test through relative
srcimports (./security-plugin.js→./explain-engine.js), so nodistsits between the mutation and the run.Runs on HEAD
e8e677786a, after mergingorigin/mainat44639665ee:@objectstack/plugin-security:typecheckexits 0, andtsconfig.test.jsonlists the new file. The full suite passes: 130 files, 2541 tests.grep -rln "security/explain\|explainAccess" packages --include=*.test.ts:security-routes,security-explain-envelope,rest-write-response-internal-fields.tripwire: 45/45.sharing-service: 131/131.client.test: 217/217.api-key-owner-revoke,owd-public-read-write-write-floor,showcase-d7-default-profile: 23/23.type-alias-convention.pin,explain-zero-rows-sentinels.pin: 11/11.engine-middleware-operation-vocabulary: 5/5.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandson this HEAD derives 71 commands, and all 71 ran.check-changeset-fixed,check:error-code-casingandcheck:filter-alias-parityalso ran, because their rosters sit under paths this diff touches.Run reconciliation — 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN.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.eslint --no-inline-config --format jsonon the two changed TS files: 2 files, 0 errors, 0 warnings.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
security-plugin.ts, which is out of scope here.not_evaluated, a detail, and a fail-closed verdict. The real request's failure shows up on the request that fails.: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.tenant_isolationlayer-levelverdictstaysnot_applicableunder a layered-RLS failure. That code is unchanged, and the verdict enum has no "unknown". The record attribution carries the failure.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.Generated by Claude Code