fix(plugin-security,core): security/explain answers enforcement's refusal at the object level, and explains another user in their organization (#20604) - #20629
Conversation
…r every verdict position (red on the unfixed tree) One table drives each explain verdict position (object-level allowed and readFilter, record-grained visible for read / update / delete, the principal's permission sets) against the same principal's own request through the real SecurityPlugin, ObjectQL and better-sqlite3, and asserts enforcement's own outcome as well. Measured on 889139c, before any fix (45 of 103 rows red): - object-level read / update / delete / create under a cross-class row-level predicate answer allowed: true while find refuses INVALID_FILTER / 400 (both orderings of the pair); - a missing record id under that predicate answers visible: false with no decider while find refuses INVALID_FILTER / 400; - a current member explained by an administrator: denied a tenant object under isolated (their find reads it), and an organization-scoped permission set missing under isolated, group and single. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…at object level, and resolves the explained user in their organization - The object-level pass refuses a composed row filter the find cannot run (a field-to-field comparison of no shared comparison class) with the record matcher's own refusal, INVALID_FILTER / 400, the one the record-grained pass already gives. It used to answer allowed: true, rls narrows and the predicate as readFilter, for every operation. The refusal runs before the record-grained pass, so a record id no row carries is refused too, as its find is. A request the capability or CRUD gate denies is still answered as denied there. - explainAccessForCaller sets the explained context's tenantId to the organization vetOrganizationClaim resolved the user in, as resolveDelegatorContext sets a delegator's. A current member was explained with no organization: denied a tenant object under isolated, and missing a permission set their organization authored. - One reading of an object's declared columns (declaredComparisonColumns) for the row-level write check and for explain. - The removed-member keep-pin now asserts parity with enforcement for a current member, instead of an unchanged explanation. - The parity table marks two measured divergences it does not fix. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…zationClaim for its membership rule No behaviour change. The key arm spelled the rule inline (a walled posture, and the key's organization absent from accessible_org_ids); it now asks the function the session arm and security/explain ask. Only the consequence stays the arm's own: an unbacked key is refused, where a session's claim is dropped. The key-arm cases pass unchanged. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…rce closeout Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…et list as the type declares it Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 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 fd4bf99aa7012628355dab1a4b3d7101b5659e75 && git checkout fd4bf99aa7012628355dab1a4b3d7101b5659e75
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3f45b6cc13fb4646fab723a517225aa911cf17b8 b8fe3069cbb780b5686984677357126741f6bea4 && git checkout -B drift-repro 3f45b6cc13fb4646fab723a517225aa911cf17b8 && git merge --no-ff b8fe3069cbb780b5686984677357126741f6bea4
node scripts/docs-audit/affected-docs.mjs --json 3f45b6cc13fb4646fab723a517225aa911cf17b8
|
… as absence The #20002 row spelled enforcement's un-enveloped failure as a code string, which the error-code casing guard reads as a lowercase code in a code position. Envelope's code and status are now optional, envelopeOf answers undefined for an error that carries none, the row states no code and no status, and the comparison treats no envelope as its own value: enforcement's envelope is compared with both halves spelled, and an explain refusal answering an un-enveloped failure must carry none either. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…lifecycle hooks unfired, so no bootstrap read races teardown Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…s that decided them (objectstack-ai#20634) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the third stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/plugins/plugin-auth/src/**` and nothing else. By census, it is the largest package in the lane that no open PR or in-flight claim holds (the claim, `5888562941`, gives the order). Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 and 2 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`). That is **95 sites on 95 lines in 31 files, covering 16 numbers**: - the 52 census sites (all of this package's census sites); - 38 sites in test comments, which the census defers; - 5 sites in the hyphen-joined spelling `objectstack-ai#13398-class`, which the gate's extractor does not match at all (see Acceptance notes). Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and it says in its own words what that commit decided. No ADR or ruling-record file records the decision behind any of the 16 numbers, so every anchor is a commit: **15 distinct shas** (`objectstack-ai#11477` and `objectstack-ai#12029` share one, because `objectstack-ai#12029` was the pull request that settled `objectstack-ai#11477`). No number was dropped. Only comments changed. Every touched source file keeps its line count (107 lines out, 107 in, over 31 files), so no line citation into these files moves. 12 of those 107 lines hold no dead citation; they are reflow or a lost referent, listed under Wordings below. No code token moves (see the guard below). **No citation number is added.** Every tracker number on an added line was already on the line it replaces. Over the whole diff, added minus removed is 0 or negative for every number (the gate's own `extractCitations` over the diff: 103 citations removed, 13 added, all 13 kept resolving numbers), and no number is new to the diff. No PR number stands on an added line. Twenty-one dead sites are left on purpose, all of them test titles (see the list below). One more file: a `patch` changeset for `@objectstack/plugin-auth`, because the rewritten docblocks ship (see Changeset below). ## Census: `plugin-auth`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/plugins/plugin-auth/`. Each run counts as a reading only because its board frontier equals the newest issue number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | plugin-auth sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `b80ab579d`, run 2026-09-29T10:43:31Z to 10:47:03Z | enumerated, 185 pages, frontier objectstack-ai#20629 (newest objectstack-ai#20628 before, objectstack-ai#20629 after), 18,456 numbers | 1,955 | **52** | 52 | 13 | 12 | | after | head `5ae64e8b8`, run 11:12:05Z to 11:15:37Z | enumerated, 185 pages, frontier objectstack-ai#20630 (newest objectstack-ai#20630 before and after), 18,457 numbers | 1,903 | **0** | 0 | 0 | 0 | The before count matches the 52 that census `5884031174` read at `f11b5f20`. The whole-repo drop is 52, exactly this diff's census sites. The `resolves` tally is 32,832 in both runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did not move either. No run was truncated or discarded: all three enumerations in this stage (two census runs and the supplementary board below) read 185 pages at the newest frontier. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `classifyCitation` over every `.ts` file under `plugin-auth/src` (178 files). It uses one board, enumerated by the gate's own `enumerateBoard` at 10:50:47Z (185 pages, frontier objectstack-ai#20629, equal to the newest). | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `b80ab579d` | 2,150 | **111** | 52 | 38 | 0 | 21 | | after, `9fd0ebf10` | 2,060 | **21** | 0 | 0 | 0 | 21 | Its src-comment column equals the census's 52, which is the control on the second instrument. The 1,966 resolving, 46 pull-request and 27 cross-repo citations are the same in both readings. Neither instrument sees the 5 `objectstack-ai#13398-class` sites; a plain grep for the 16 numbers over `plugin-auth/src` at the head finds only the 21 test titles (and the digits `11477` inside test fixture e-mail addresses and a password, which are code tokens, not citations). ## Per-number table Sites and files count all dead sites the gate sees in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#8676` | 22/6 | 18/4 | `d6e80b28b`: `sys_account.password` and `previous_password_hashes` are flagged `internal: true`, and every reader is recovered through the engine's privileged accessor (the adapter readback table gains `password`; plugin-auth's own raw-engine reads get `recoverInternalFieldsForSystemRead`). Its subject names `objectstack-ai#8676` | | `objectstack-ai#8734` | 4/2 | 3/1 | `f8eb73601`: the last-admin guard's standing-key lists are bound to what `resolveAuthzContext` actually reads (`STANDING_KEYS_BY_TABLE` / `STANDING_KEY_EXCLUSIONS` and the correspondence gate). Its subject names `objectstack-ai#8734` | | `objectstack-ai#10165` | 1/1 | 1/0 | `801296050`: lifecycle `ttl` gains an `onlyWhen` row filter (maintainer ruling option A on `objectstack-ai#10165`, quoted in its message). The same anchor the spec stages gave this number | | `objectstack-ai#10366` | 3/2 | 2/1 | `bbe643c08`: the localhost trusted-origin substitution is gated to non-production. Its diff writes both rewritten lines and its changeset names `objectstack-ai#10366` | | `objectstack-ai#11343` | 19/8 | 18/1 | `c0714eb5d`: walled platform-admin elevation requires a VERIFIED owner-email match (a fail-closed allow-list over `email_verified`), the bootstrap replays on the verifying `sys_user` update, and the dev-admin seed stamps its account verified. Its message names `objectstack-ai#11343` as the card it completes | | `objectstack-ai#11477` | 6/3 | 3/3 | `6dd3e6968`: `/admin/remove-user` gets the raw-mount shading `/admin/ban-user` has, so authorization runs before the break-glass guard (ruled option A on `objectstack-ai#11477`, as its message records) | | `objectstack-ai#11626` | 1/1 | 1/0 | `a6eca9223`: `check:engine-double-contract` admits a single-verb engine double on the contract it DECLARES, a second admission route beside sibling inference. Its diff names that route `objectstack-ai#11626` | | `objectstack-ai#11640` | 11/6 | 7/4 | `bf8d129b5`: a walled deployment whose declared owner has no verification path gets a loud, named warning at boot, and boot proceeds (maintainer ruling 2026-08-25, option A). Its subject names `objectstack-ai#11640` | | `objectstack-ai#11741` | 4/2 | 2/2 | `b706af987`: `SendEmailInput` gains an optional `organizationId`, threaded from the producers that hold one (the invitation among them). The same anchor stages 1 and 2 and the spec stages gave this number | | `objectstack-ai#11757` | 4/4 | 4/0 | `4d25d22d4`: the rc.1-era `sys_scim_provider` platform object is retired. Every `objectstack-ai#11757` site in the tree before it says the object "retires under objectstack-ai#11757" | | `objectstack-ai#12029` | 2/2 | 2/0 | `6dd3e6968`: `objectstack-ai#12029` was the pull request itself; this is its squash commit, the gate-then-delegate mount on `/admin/remove-user` | | `objectstack-ai#13398` | 6/2 | 3/3 | `e238c79f0`: the published-sink ruling, that raising a log level must never widen a published sink. No record of the ruling exists in the repo; this commit's pin is the earliest text in history that records it (see Wordings) | | `objectstack-ai#14762` | 21/4 | 19/2 | `35e94c96b`: auth OTP SMS and auth mail read the recipient's own `sys_user.locale`, one rung above the request and the deployment default, in the order ruled for `objectstack-ai#14788`. Its diff carries `objectstack-ai#14762` 24 times | | `objectstack-ai#14902` | 3/2 | 3/0 | `61821e54c`: a plain unique index over duplicate rows is loud and non-fatal (the boot continues), and `os migrate plan` stops calling it `safe`. Its message names `objectstack-ai#14902` as the card it ends | | `objectstack-ai#14998` | 2/1 | 2/0 | `f1e91595f`: the batch-6 admin endpoint graphs load at module top, not inside each clocked case, which removed the cold-import timeout flake | | `objectstack-ai#15092` | 2/1 | 2/0 | `9e9f03abe`: `settleSelfRegistrationGrant`'s trailing filter no longer silently DROPS a malformed permission-set row; it refuses. The only commit in history that names `objectstack-ai#15092` | Plus 5 `objectstack-ai#13398-class` sites the gate does not extract, anchored like the other `objectstack-ai#13398` sites: `boot-sign-in-reachability.ts:109`, `:512`, `boot-sign-in-reachability.test.ts:595`, `tenancy-service.ts:249`, `:257-258`. Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each), and every one is an ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 15; the history is complete, `--is-shallow-repository` false, 15,083 commits). A line-origin pickaxe (`git log -S` on each dead line's exact text) found each line entering either in its anchor commit or in a later commit that cites that commit's decision: for example `4d5b4f832` (the operator-provisioned stamp) and `4f65837a7` (the L3 re-anchor) cite `c0714eb5d`'s verified-owner rule, `f074616e6` (invitation locale) cites `35e94c96b`'s stored rung, `8064e6da1` (the has-permission mount) cites `6dd3e6968`'s seam, and `9bd4344e4` carries the `account-identity-preflight` text that cites `61821e54c`. ## Wordings to check - **`objectstack-ai#13398` → `e238c79f0`, and not stage 2's `953a81f4a`.** Stage 2 anchored its one `objectstack-ai#13398` site at `953a81f4a` (2026-09-02) as the earliest application of the published-sink ruling. In this package, `e238c79f0` (2026-08-31) already records it: its pin in `durability-swallow-repair.test.ts` says raising the level "means widening a published sink — refused as actively harmful by the maintainer's" ruling. It is earlier, and it is in this package, so it is the anchor here. Its own commit message still calls the level "objectstack-ai#13398's question", which is why the lines say "the published-sink ruling (commit e238c79)" rather than claiming that commit made the ruling. - **Reflow, 11 lines with no dead site** (every file keeps its line count): - `auth-manager.ts:7554-7557`: 「routes that LEVEL question to the published-sink ruling (commit e238c79) and tells this batch to fix the SILENCE only」, the rest of the paragraph reflowed unchanged (3 lines). - `durability-swallow-repair.test.ts:36-40` (4 lines) and `:527-529` (2 lines): the same substitution, and 「which routes that question there」 became 「which keeps that question」, because "there" pointed at the number. - `tenancy-service.ts:257-258`: 「exactly what the sink ruling (commit e238c79) forbids」 (1 line). - `find-envelope-limb-removal.test.ts:47-48`: 「also carried the silent-DROP shape, and commit 9e9f03a fixed it in the OPPOSITE direction」 (1 line). - **A lost referent, 1 line.** `auth-plugin.ts:2738-2739`: 「(the objectstack-ai#12029 worked reading — a shadow is accounted for …)」 became 「(as it read commit 6dd3e69's remove-user mount — a shadow is accounted for …)」. `check:auth-mount-ledger` has counted a shadowing mount since `26dea1495`; the "worked reading" was that PR's application of it to `/admin/remove-user`, which `6dd3e6968` mounts. - `sys-session-ttl-sweep.test.ts:230`: 「the naive policy commit 8012960 existed to make avoidable」, where `801296050` is the `ttl.onlyWhen` filter the ablation removes. - `durability-swallow-repair.test.ts:62`: the flake report became a pointer to the commit that removed the flake (`f1e91595f`), with `objectstack-ai#15603` kept beside it. - `auth-manager.ts:5629`: 「the pre-objectstack-ai#14762 deployment-default behaviour」 became 「the deployment default, as before commit 35e94c9」. ## The 21 sites left - **Test titles (21 sites).** `describe` / `it` titles, which are string tokens: `admin-remove-user-gate-ordering.test.ts:207`, `:263`, `:298` (`objectstack-ai#11477`), `auth-email-locale.test.ts:528` and `auth-manager.test.ts:2545` (`objectstack-ai#14762`), `auth-manager.test.ts:1562` (`objectstack-ai#10366`), `:2866`, `:2880` (`objectstack-ai#11741`), `:4105` and `internal-field-readback.test.ts:219`, `:230`, `:286` (`objectstack-ai#8676`), `auth-plugin-walled-owner-verification-path.test.ts:87`, `:193`, `:317`, `:384` (`objectstack-ai#11640`), `durability-swallow-repair.test.ts:159`, `:567`, `:670` (`objectstack-ai#13398`), `last-admin-standing-keys.test.ts:61` (`objectstack-ai#8734`) and `walled-owner-operator-stamp.test.ts:355` (`objectstack-ai#11343`). Tokens, left as they were, as stages 1 and 2 left theirs. - There is no non-test string, no generated file and no quoted ruling carrying a dead number in this package. ## Mechanical guard: no code token moves The guard compares the TypeScript parser's leaf nodes, with comments as trivia and JSDoc nodes excluded, base `b80ab579d` against head. Template literals are therefore read in context. It ran over all 31 touched `.ts` files. - Real run: 158,646 base tokens, **0 files with a token change** (exit 0). - Comment control in `auth-manager.ts` (`As above — the flagged column` to `Likewise — the flagged column`): 0 files changed, as expected (exit 0). - Positive control, a code token changed in `auth-manager.ts` (a fourth element added to the `fields` projection of the password-reuse read): DIFFER (exit 1). - Positive control, one digit changed inside a kept test title (`admin-remove-user-gate-ordering.test.ts:207`): DIFFER (exit 1). Every mutation went through `scripts/ablation-replace.mjs`, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`c0bdef025a39`, `ec83f09f556e`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/plugin-auth` (`.changeset/20596-plugin-auth-provenance-anchors.md`) is included. It says only that the provenance comments were re-anchored. Measured on the built package (A3): `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After the build, the rewritten comments reach `dist`: `35e94c96b` appears 8 times in each of `dist/index.d.ts`, `index.d.mts`, `index.js` and `index.mjs`; `f8eb73601` twice in each declaration file; `bf8d129b5` and `e238c79f0` once in each of the four; `d6e80b28b` and `4d25d22d4` twice in each runtime file; `c0714eb5d` and `61821e54c` once in each declaration file; `b706af987` once in each runtime file. Positive control: the unchanged line 「read best-effort off the identity row.」 beside a shipped rewrite is found once in `index.d.ts` and once in `index.js`. A never-written negative phrase appears nowhere. No dead number of the 16 is left anywhere in `dist`. ## Gates (head `5ae64e8b8`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` (self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged 5 citations across 14 files, and all 5 resolve. - **Doc authoring:** `pnpm check:doc-authoring` exits 0, with the sibling-package prose ids at their baseline and no growth. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `5ae64e8b8` derived 65 commands: all 57 derived at dispatch, plus `check:duration-unit-keys`, `check:engine-double-contract`, `check:logger-receiver-detach`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. It was re-derived after a fresh `git fetch` (`origin/main` `a918fe7fd`, 2 commits ahead, neither touching `plugin-auth`): the same 65. Each ran with its exit code captured before any pipe, and all 65 exit 0. `--ran`, fed each command with its exit code, reports 65 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Tests and typecheck, under the verify lock:** - `pnpm --filter @objectstack/plugin-auth test`: 115 files and 2,464 tests pass. That is every test file in the package, the 17 touched ones included. - `pnpm --filter @objectstack/plugin-auth typecheck` exits 0 (`tsc` main, `tsconfig.examples.json`, and `check:test-typecheck` held at its ledger). The main program reads 63 non-test files; the `tsconfig.test.json` program reads all 178 files under `src/`, the 115 test files included, and all 31 touched files are in it (`--listFiles`). - **Lint, as a proven narrowing:** `eslint --no-inline-config --format json` over the 31 touched `.ts` files gives 31 files, 0 errors and 0 warnings. All 31 are in eslint's own population (`isPathIgnored` is false for each). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own line 328 states), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 32 changed files for control bytes finds none. ## Acceptance notes - **The gate's extractor does not see a hyphen-joined number.** `CITATION_RE` ends in a lookahead that refuses a following hyphen, so `objectstack-ai#13398-class` is not a citation to either the diff gate or the census, dead or alive. This stage rewrote the 5 such sites in `plugin-auth` because they are the same dead number in the same comment prose. At the head, 10 dead `#N-word` sites remain in `packages/**/src` (a raw line scan of `.ts` files against the cached board): `service-automation` 5 (all `objectstack-ai#13398-class`), `rest` 2, `plugin-security` 1, `runtime` 1, `spec` 1. The census cannot count them, so a later stage reaching those packages has to look for them by hand. No instrument change here. - **The census instrument did not truncate in this stage.** Three enumerations read 185 pages each at the newest frontier. - **Anchors the next stages can reuse.** These numbers stand elsewhere on the census at the head: `objectstack-ai#11343` in `plugin-security` (6) and `types` (2), anchor `c0714eb5d`; `objectstack-ai#14902` in `driver-sql` (7) and `cli` (1), anchor `61821e54c`; `objectstack-ai#13398` in `service-automation` (4, plus the 5 hyphen-joined sites), anchor `e238c79f0`; `objectstack-ai#8734` in `core` (2), anchor `f8eb73601`; `objectstack-ai#10165` in `objectql` (2) and `platform-objects` (1), anchor `801296050`; `objectstack-ai#11757` in `platform-objects` (2), anchor `4d25d22d4`; `objectstack-ai#11741` in `plugin-email` (2), anchor `b706af987`; `objectstack-ai#8676` in `platform-objects` (1), anchor `d6e80b28b`. - **Base.** The branch is 2 commits behind `origin/main` (`a918fe7fd`, read at 11:20Z). Neither touches `plugin-auth`, this changeset or any of these 16 numbers, so there was no merge. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20604
Clause-②: no
What was wrong
POST /api/v1/security/explaingave answers that disagreed with what the same principal's own request gets from enforcement. Measured onmainat889139cewith the pin this PR adds (45 of 103 rows red before any fix):record.status != record.amount, and the other orderingrecord.amount > record.status). The find refuses every read it scopes withINVALID_FILTER/ 400. A by-id update or delete fails closed with 403, and an insert is refused withINVALID_FILTER/ 400. An object-level explanation (norecordId) answeredallowed: true, therlslayernarrows, and the predicate asreadFilter, for read, update, delete and create. That is 10 rows.recordIdthat no row carries was answeredvisible: falsewith nodecidedBy, while the find for that id refuses withINVALID_FILTER/ 400. REACHED.isolated, on a tenant object, the explanation answeredallowed: false,rlsdenies, and the fail-closed sentinel asreadFilter. The member's own find returns their organization's row. REACHED underisolatedonly: undergroup, the wall readsaccessible_org_ids, which the context already carried.sys_permission_setrow scoped to that organization did not load for the explained user, underisolated,groupandsingle. Enforcement resolves it. REACHED under all three postures.What changed
explain-engine.ts, the object-level pass. Before any verdict is computed, the composed row filter (the caller's, and a delegator's) is judged the way the find is judged: by the record matcher, with the object's declared columns. A comparison the spec's classification refuses now fails the explanation with the matcher's own refusal. This is the same function, envelope (INVALID_FILTER/ 400), message andcausethat the record-grained pass has given since PR fix(plugin-security): security/explain answers enforcement's refusal for a row-level policy comparing two fields of no shared comparison class #20598. No second refusal dialect is added. The matcher judges declared columns before it reads a record, so it is asked with no row. It is asked only whenfindCrossFieldClassRefusalfinds a refused comparison, so no filter the find runs is evaluated there. The refusal runs before the record-grained pass, so position 2 gets the find's answer too. A request that the capability or CRUD gate denies is still explained as denied there, as enforcement denies it (see the boundary row). The helper that names the refused policy now also walks the$andthat puts the tenant wall next to a policy.security-plugin.ts,explainAccessForCaller. The explained context'stenantIdis set to the organizationvetOrganizationClaimresolved the user in. That is the same value, and the same assignment, thatresolveDelegatorContextmakes for a delegator. A removed member (claim dropped) keeps no organization, as enforcement resolves them.declared-comparison-columns.ts(not exported from the package), holdsdeclaredComparisonColumns. It is now the one reading of an object's declared columns for both the row-level write check (writeCheckFieldOptions) and explain.@objectstack/core, cross-lane notice fordomain:engine. Inpackages/core/src/security/resolve-authz-context.ts, the API-key arm ofresolveAuthzContextchanges fromif (keyPrincipal?.tenantId && input.tenancyPosture) { if (postureEnforcesWall(posture) && !grants.accessible_org_ids.includes(keyPrincipal.tenantId))toif (keyPrincipal?.tenantId) { if (vetOrganizationClaim(keyPrincipal.tenantId, grants.accessible_org_ids, input.tenancyPosture) === undefined). This is a refactor with no behaviour change. The two conditions are equal term by term: a truthy claim, a posture present, a walled posture, and the claim absent fromaccessible_org_ids. The consequence (the refusal, [decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring underisolated— the wall compares against the caller's own unvetted claim #15256 2A) is unchanged. ThevetOrganizationClaimdocblock names the key arm as a reader, so its sentence "Nothing else spells this rule" stays true. Agit grepforaccessible_org_ids.includes/accessibleOrgIds.includesinpackages/*/src(tests excluded) finds one spelling, insidevetOrganizationClaim. No export, signature or error changed. The existing key-arm cases pass unchanged.security.explainshows a member removed from an organization that organization's grants, which enforcement refuses since PR #20540 (explain ≠ enforce, split from #20431) #20580 keep-pin, re-ruled in place (explain-removed-member-principal.test.ts). "A current member's explanation is unchanged" is now "a current member's explanation matches enforcement". It asserts that the member's sets equal enforcement's resolved sets (with enforcement'stenantIdasserted asorg_alpha), and thatallowedequals whether the member's own read is admitted. Nothing is deleted..changeset/20604-explain-enforce-closeout.md:patchfor@objectstack/plugin-securityand@objectstack/core.The enumeration pin (
explain-enforce-parity.test.ts)One table, 104 rows, one invariant function (
expectParity). For each row, the explanation and the same principal's own request run through the realSecurityPlugin, a realObjectQL, and better-sqlite3. Where needed, the realSharingServiceand its middleware run too. Each row also asserts enforcement's own outcome. The positions are: object-levelallowed, object-levelreadFilter(compared by the rows it admits as a system read), record-grainedvisiblefor read / update / delete, and the principal's permission sets. The rows are the family's shapes: #19963 (write depth), #19986 (read depth), #20002 (a throwing sharing read filter), #20431 (the record matcher under a cross-class comparison, both orderings), #20580 (removed member, underisolated/group/single), and this card's positions 1 to 3. There is also a boundary row: under the cross-class policy, a principal with no CRUD grant getsallowed: false, not a refusal, because enforcement denies at the CRUD gate. It is a test, not a gate. It runs on better-sqlite3 only; the per-driver coverage stays inexplain-cross-class-refusal.test.ts.Two measured divergences are findings of this card. They are not fixed here. Their rows assert the disagreement itself, so each row turns red the day either face moves (see Acceptance notes).
Ablations (fix removed, then restored)
All three were run with
scripts/ablation-replace.mjsin WRAP mode, inside a driver script withtrap 'git checkout HEAD -- ABS_PATH' EXIT INT TERM, at6d23ba1ca. The three source blobs are unchanged at the final head.explain-engine.ts). Anchor 1 → 0, blobfe299ae87b2d→0f870a48cdda. 11 rows red: the 10 position-1 rows and the position-2 row. First red row:explain answered allowed: true, rls: narrows, readFilter: {"status":{"$ne":{"$field":"amount"}}}; enforcement {"kind":"refused","code":"INVALID_FILTER","status":400}: expected 'answered' to deeply equal { code: 'INVALID_FILTER', status: 400 }. Restore: blob == HEADfe299ae87b2d,git diff HEADempty.security-plugin.ts). Blobfaa10fb1e8ae→e98f01b20caf. 21 rows red, all position-3 rows underisolated,group,single, plus the A4 current-member rows. Examples:the member user's permission sets ... enforcement {"kind":"sets","names":["member_default","qa_parity_reader","qa_parity_alpha_notes"]}: expected [ Array(2) ] to deeply equal [...], andthe member user, object-level read of LEDGER · object.allowed: explain answered allowed: false, rls: denies ...; enforcement {"kind":"rows","ids":["l_alpha"]}. Restore: blob == HEADfaa10fb1e8ae, diff empty.false. Blobcc4b63ba7b11→b8fe722cd614. 5 existing key-arm cases red, among them "refuses a key whose owner is no longer a member of its organization" and "the same key undergroupis refused too". So those cases do exercise the refactored line. Restore: blob == HEADcc4b63ba7b11, diff empty.These tests import plugin-security from
srcand core's suite imports core fromsrc, so no ablation leg depends on adist/build.Verification (head
571cf85e3)pnpm --filter '@objectstack/plugin-security...' build, which includes@objectstack/core. Exit 0. The rebuilt coredist/index.jscarriesvetOrganizationClaim(keyPrincipal.tenantId(count 1).pnpm --filter @objectstack/plugin-security --filter @objectstack/core run typecheck: exit 0. plugin-security: test layer0 file(s) / 0 error(s)held in the debt ledger. core:4 file(s) / 4 error(s), unchanged.@objectstack/plugin-securityfull vitest: 147 files, 3202 passed, 23 skipped.@objectstack/corefull vitest: 59 files, 1570 passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 67 commands against this diff, the same 67 as at dispatch. 64 exited 0. 3 are NOT MEASURED:check:dual-build-cjs-loads,check:i18nandcheck:type-check-debt. Each printedPREREQUISITE NOT MET(exit 3), because it reads a whole-tree build and this worktree built only the plugin-security closure; CI builds first.--ranreconciliation:67 derived famil(ies) accounted for — 64 run, 3 NOT-MEASURED,0 UNRUN.eslint --no-inline-config --format jsonover the 6 changed.tsfiles returned 6 file results, 0 errors and 0 warnings. None were ignored (an ignored file reports a warning).eslint.config.mjsenables no type-aware linting (its own note, near line 326: "never enables type-aware linting (noparserOptions.project...)"), so this diff cannot change the verdict for any untouched file. The fullpnpm lintis CI's.Measured, not assumed (A4, A5)
org-scopingwithout atenancyservice. Admission reads no posture, so a removed member'sorg_alphaclaim is kept. Their own reads returnorg_alpha's ledger row, the global probe, and the organization-authored set's object. Meanwhile, this plugin walls Layer 0 atisolated, and explain vets the claim under that walled posture and drops it. The defect is enforcement's: a claim is never dropped while Layer 0 enforces a wall. Per the dispatch it is not changed here. See Acceptance notes for its reach.fields. A probe that registered an object withfields: [...]got back a keyed map ("0","1", plus the injected system fields), so the write check's array branch cannot differ from explain's reading. The write check's metadata fallback is reached only when the registry holds no field map for the object, which is an object the find cannot compile against. With the shared function, both judges read one declaration one way. The plugin-security:security.explainreports a record visible under a row-levelusingthat compares two fields of different classes, whilefindrefuses the same read withINVALID_FILTER/ 400 #20431 suite (explain-cross-class-refusal.test.ts) and the table's cross-class rows stay green on it.Acceptance notes
NATIVE_SCOPING_UNDER_SINGLE(explain's side, not fixed). Undersingle, the engine still stamps the context's organization on a tenant object's read (driver-native tenant scoping,engine.ts, thehasTenantbranch). A caller whose context carriesorg_alphadoes not seeorg_beta's row. Explain's tenant layer contributes nothing undersingle, so it reportsreadFilter: nullandrecord.visible: truefor theorg_betarow. This was measured for an administrator explaining another user, after this PR's position-3 change. The same holds by construction for a caller explaining themselves, which was not measured. It needs rows of more than one organization undersingle, and no producer of that data is named here.CLAIM_KEPT_UNDER_A_WALL(enforcement's side, not changed). It was measured in-process only. I found no public door that reaches it: without plugin-auth there is no session (noauthservice), and no API key, becausesys_api_keyis registered by plugin-auth's manifest. So no request carries an organization claim in that composition.refusedPolicyNamesOfand the write check's inline log attribution. Explain's now also walks$and. That affects log and message attribution only. The next PR that touches the write check insecurity-plugin.tscould carry it.POST /api/v1/security/explainanswers a service refusal carryingINVALID_FILTER/ 400 as500 EXPLAIN_FAILED— the route's catch maps only PERMISSION_DENIED and OBJECT_NOT_FOUND #20603 remains open: the REST route answers explain's refusals as 500. After this PR the object-level refusals reach that same door.Patch round 1 (the seat's append; the dev writes a body only once)
5888953451FAILed on571cf85e.Lint & Repo Gateswas red at the error-code casing guard, because the plugin-security: explain reports a record VISIBLE when the sharing read filter throws —explain-enginecatches the rejection intonulland the record matcher readsnullas "no filter", while enforcement refuses the same read #20002 row of the parity table spelled an absent error envelope as the string code'undefined'.Envelope.codeandEnvelope.statusare optional, andenvelopeOfanswersundefinedfor an error that carries no envelope.enforced: { kind: 'refused', code: undefined }, and both halves of an envelope are compared strictly, so "no envelope" is its own value.visible: falsefor it.571cf85e(exit 1 at:841).pnpm check:error-code-casingexits 0 atb8fe3069.b8fe3069: all 67 derived gates exit 0, including the 3 whole-tree gates, now measured after a full build.--ranreconciles 67 / 0 / 0. The three ⛔-marked roster families exit 0. TheLint & Repo Gatessteps from the casing guard onward were run locally: 76 of 77 exit 0, and the last one reports on CI's own step outcomes, so it has no local run.plugin-security3202 passed,typecheckexit 0.Generated by Claude Code