Repository navigation
fix(plugin-security): a USING-only RLS policy holds INSERT and UPDATE rows to its using - #19952
Conversation
…t-image When no applicable policy declares `check`, the write check compiles each applicable policy's `using` as its check, matching the published RowLevelSecurityPolicySchema.check default. Declared-check composition is unchanged; the platform ownership floor stays a pre-image construct. Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…pdate Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…e pre-image kept Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…mposition Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…faulted check Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…using on writes Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 c6e3661a6abec88181444dcb04ded8a1cbb2278a && git checkout c6e3661a6abec88181444dcb04ded8a1cbb2278a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c1641868a3da71537c9c6c572d2bd2044103236b 62d12779800c9baa147fc5e3367fffca38b0aac3 && git checkout -B drift-repro c1641868a3da71537c9c6c572d2bd2044103236b && git merge --no-ff 62d12779800c9baa147fc5e3367fffca38b0aac3
node scripts/docs-audit/affected-docs.mjs --json c1641868a3da71537c9c6c572d2bd2044103236b
|
…or, BREAKING, ADR-0087 disposition) Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: The review ran in two rounds, by an isolated reviewer at the served tier. The reviewer saw only card #19942 (triage 5806292861, claim 5806865969, os-dev report 5807642793), this PR, and the repository rules. The
① Derived judgments
② Semver level
Gates at this head:
③ Boundary flagsThe PR touches six files: the changeset, 4 files under Implemented-by: VERDICT: PASS Generated by Claude Code |
…aster-gate ruling; pin the post-image own-row read Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
…ing, not an unresolvable number Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: What this record covers: this head, ① Derived judgments
② Semver levelUnchanged and correct: ③ Boundary flagsThe full diff is 7 files: the changeset, 4 plugin-security source files, the dogfood test, and the plugin-auth test. It touches zero Implemented-by: VERDICT: PASS Generated by Claude Code |
… of an array insert and a predicate update (objectstack-ai#19988) Fixes objectstack-ai#19950 Fixes objectstack-ai#19964 Clause-②: no (narrowing) ## What this fixes A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored (ADR-0058 D4: "on the write pre-image path that already exists for by-id writes … and on the AST-injected bulk path"). The write gate enforced it for a single-row insert and a by-id update, and not for the two multi-row write shapes: - **objectstack-ai#19964, array insert.** Step 3.6 excluded an array payload, so no judgement was installed and every row was stored unjudged, including under configurations that refuse every single-row insert. - **objectstack-ai#19950, predicate update** (`multi: true`, no row address). Step 3.6 skipped the new-row check and logged "governed by the using-scoped where". A policy that declares only `check` scopes nothing, and a scoped `where` says nothing about the new row in any case. The skip was unconditional, so it also covered a declared `check` that differs from `using` and a `check` defaulted from `using`. Both shapes are now judged row by row with the existing refusal (`PERMISSION_DENIED` / 403, nothing stored). One failing row refuses the whole write. The judgement is the existing `satisfiesCheck` over `matchesFilterCondition`: no predicate is compiled differently, and nothing is lowered into the `where`, so no compile surface moves. ## Landing: two packages, and why the engine is one of them - `packages/plugins/plugin-security/src/security-plugin.ts`, step 3.6 plus the seam declaration and the post-`next()` fail-closed guard (generalised from "the insert" to the operation). The by-id update branch is unchanged. The `explainAccessForCaller` wiring is not touched. - `packages/objectql/src/engine.ts`, the predicate-update branch of `update()` (`domain:engine`), plus the seam's doc comments. **Why the producer is the engine (measured, not assumed):** the rows a predicate update changes are the rows the middleware-COMPOSED AST selects. That AST is complete only after every middleware has run: step 3 of this middleware composes its own scope after step 3.6, and plugin-sharing composes its editable-rows filter onto the same AST (`sharing-plugin.ts:1405`), in plugin order. The suggested route, reading "under the caller's context" in the middleware, was measured two ways and does not hold: - A caller-context read applies READ scope and field masking. Where the read scope is narrower than the write scope, written rows go unjudged (fail-open). Where it is wider, rows that will not be written get judged (false refusals). Ablation 3 below shows the second: a judgement that is not over the actual matched rows falsely refuses the USING-only in-scope control. - The engine already holds the exact set: the D7 matched-row read (`readPriorRows`, bound to the composed AST), which the ruling says is read once and reused, and which already serves validation, the `readonlyWhen` strip and both per-row hook phases. So the security layer installs its judgement on the existing `OperationContext.postHookWriteImageCheck` seam (the one objectstack-ai#19952 built for inserts), and the engine calls it on the predicate branch. It hands over every matched row merged with the payload (the same shape as the per-row `afterUpdate` `result`), placed after `assertNoStrictDrops()`, where the payload is final. That placement follows the insert seam's contract review: "the row the seam judges must be the row that is stored". The readonly strips run earlier on this branch, so a pre-strip placement would judge values that never land. ## Mechanism hypotheses (dispatch Section 2), as measured on `2c1011b01b` 1. **Held.** Line 3000 carried `!Array.isArray(opCtx.data)`. Lines 3078-3083 set `postImage = null` for `extractSingleId(opCtx) == null` and logged "governed by the using-scoped where". 2. **Held.** `engine.ts` (the `postHookWriteImageCheck` call in `insert()`) hands `evaluate` every live row of an array insert. The array fix is plugin-side only. 3. **Refined.** The memoized `getCallerPreImage` is by-id and caller-context, so it is not reusable per row for the reasons above. The engine's memo serves instead, at no extra read wherever per-row hooks already read it. 4. **Held.** The skip was unconditional. A cell pins a `using` plus a differing `check`. One further hole, found and closed: the middleware treats a falsy scalar id (`''`, `0`) as a row address, and the engine does not (`resolveEngineUpdateDispatch`). A falsy payload id therefore carried a bulk update past the per-row judgement, admitted on a change-set-only image. The seam is now installed whenever the engine will not treat the write as addressing one row. The falsy case keeps its by-id judgement too, so it only refuses more. ## Surface beyond the claim, with reasons - `packages/objectql/src/engine.ts`: see above (cross-lane, `domain:engine`). - Existing plugin-security tests: `check-only-write-scope.test.ts` and `security-plugin.test.ts` carry engine doubles that must now honour the seam on a predicate update, the way the real engine does. Otherwise the fail-closed guard refuses them, which is the intended behaviour. One test title and comment said step 3.6 "declines to check" the bulk path; it now says 3.6 can refuse a bulk write but never scope one. That pin still discriminates a site-1 revert, now by refusal. Two comment-only edits (`rls-check-defaults-to-using.test.ts`, `rls-phantom-column-negation.test.ts`) stated the array exclusion as a fact. - **A pending release note, corrected in place: `.changeset/rls-check-defaults-to-using.md`** (from objectstack-ai#19952, not yet released). Its "What does not change" list said bulk updates "are not checked row by row", which this PR makes false. The bullet now says their new rows are checked row by row too, by this PR's entry. `check-empty-changeset` is RED on this by design: it is the DELIBERATE CORRECTION class (ruling D on objectstack-ai#17712). Its prescribed remedy is to keep the correction and get it confirmed on the PR; restoring the file would publish a false sentence. Under the landing rule objectstack-ai#19970 set, 「DELIBERATE CORRECTION 红:同 head 达档复核 PASS 记录即确认,⛔ 不等维护者」, the confirmation is a same-head at-tier review record with a PASS verdict, not the maintainer. The red is expected until that record is on the PR for the landing head. ## Behaviour that changes (all in the refusing direction) - A predicate update under a check-only policy is refused when any matched row's new image fails the check. - A predicate update that moves a matched row out of a policy's `using`, when no applicable policy declares `check`, is refused: the defaulted check, which is the answer the by-id update has given since objectstack-ai#19952. Triage's "已声明 `using` 的策略,行为保持不变" is held as: in-scope bulk updates under a `using` policy are admitted and scoped exactly as before (pinned). Only a bulk write that moves rows out of the `using` is newly refused, on the same terms as by-id. The seat confirmed this reading: by-id and bulk are two implementations of one operation and must not disagree (`RowLevelSecurityPolicySchema.check` 「defaults to USING clause if not specified」; ADR-0058 D4). - An array insert is refused when any row fails, including every configuration that already refused each single insert. - A host that installs the judgement on a predicate update and never runs it is refused (403, `error` log), as an insert already is. ## Tests **Patch round 2 (head `3247efeecd`, after merging `origin/main` `9bfbacbf8b` as merge commit `938b2acfde`):** `rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security suite 123 files, 2348 tests passed; objectql re-run because objectstack-ai#19979 touched that package: local project 155 files / 2434 tests + 154 files / 2758 tests, repo project 1 file / 5 tests; `typecheck` green for objectql and plugin-security (VERDICT command-exit 0 each). **Patch round 1 (head `9fff66cfdc`, after merging `origin/main` `3fd3a4f91b` as merge commit `a5ca1db166`):** `rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security suite 123 files, 2348 tests passed (VERDICT command-exit 0 each). The merge brought no change under `packages/objectql` or `packages/plugins/plugin-security`, so the objectql suite was not re-run; its last run is the one below, on a byte-identical `engine.ts`. **Round 1 (head `6d28dfe98d`):** New: `packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts`, 32 cells on driver-sql (better-sqlite3) and driver-sqlite-wasm, real `SecurityPlugin` + `ObjectQL`. - Failing first, on the unmodified tree: 18 red and 12 green (the controls). Every negative cell failed as "admitted" (`expected true to be false`). In the unresolvable-policy cells the single-insert leg was refused and only the array leg was admitted. - objectstack-ai#19950 cells: the check-only repro; per row not per change set (a matched row failing on an unchanged field refuses the whole write); `using` plus a differing `check`; fail-closed (an inner middleware strips the seam, and the write is refused with "the update on 'qa_ticket' was executed without the row-level CHECK being evaluated"); falsy payload id. Controls: over-fix admit, USING-only in-scope admit and scoped, `using`+`check` in-scope admit, by-id unchanged, and USING-only move-out refused on both bulk and by-id. - objectstack-ai#19964 cells: the `[admitted, refused]` repro; over-fix admit; single insert unchanged; three refuse-every-insert configurations (an unresolvable sole `using` on `insert` and on `all`, an unresolvable declared `check`). - Every refusal asserts `code` `PERMISSION_DENIED`, `status` 403 and the developer half naming the gate and verb, then reads the stored rows back under a system context. Suites: plugin-security 123 files, 2348 tests pass. objectql 309 files, 5182 tests pass (local project in two halves, plus the repo project). `typecheck` is green for both packages (plugin-security test-layer debt: 0 files, 0 errors). Ablations: each committed first, mutated through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed), restored with blob equal to HEAD and an empty `git diff HEAD`. Resolution path: the plugin is imported relatively, and `@objectstack/objectql` is aliased to `src/index.ts` in this package's `vitest.config.ts`, so no `dist/` sits between the mutation and the test. | # | mutation | result | |---|---|---| | 1 | restore the non-array guard for inserts | 8 red: the 4 array-insert negative cells x 2 drivers | | 2 | never install the seam on a predicate update | 12 red: the 6 bulk negative cells x 2 | | 3 | engine judges the payload alone, not the matched rows | 6 red: the per-row cell, the falsy-id cell, and the USING-only in-scope control (falsely refused) | | 4 | install only when the id is null (falsy counts as by-id) | 2 red: the falsy-id cell, admitted | | 5 | engine never calls the seam | 16 red: refusals now carry the not-evaluated message, controls refused | | 6 | disable the post-`next()` fail-closed guard | 2 red: the fail-closed cell, admitted | ## Gates **Patch round 2 (head `3247efeecd`):** the four comment lines this change rewrote now cite the surviving record, commit `a016f08b8a` (the insert-side check), instead of a card that answers 404, and say in words that the original card no longer resolves. `GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs` probes the board and exits 0: "every citation this change adds resolves (or is a declared cross-repo reference)", with 9 judged, 9 resolving and 0 unresolved added. `dispatch-gates --commands` derived the same 68 families from the same 9 paths against merge base `9bfbacbf8`. All 68 were run; `--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0. `check-empty-changeset` exits 1 on `.changeset/rls-check-defaults-to-using.md` only. **Patch round 1 (head `9fff66cfdc`):** `dispatch-gates --commands` derived the same 68 families from the same 9 paths, now against merge base `3fd3a4f91`. All 68 were run; `--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0. `check-empty-changeset` exits 1 on `.changeset/rls-check-defaults-to-using.md` only (the deliberate correction under "Surface beyond the claim"). The changeset gates the seat named: `check-adr-0087-registration --base origin/main` exits 0 ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"), `check-changeset-no-major --base origin/main` exits 0, and `pnpm check:changeset-gate-self-tests` exits 0. **Round 1 (head `6d28dfe98d`):** - `node scripts/pm/dispatch-gates.mjs --commands` derived 68 families from the 9 changed paths. All 68 were run with exit codes recorded. `--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". - 67 exit 0. One exits 1: `check-empty-changeset`, the deliberate correction above. - Three first answered `PREREQUISITE NOT MET` (exit 3): `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`. They pass after the workspace closure build. `check-engine-split-ratio` passes after deepening the shallow clone to its window. - Lint, as a proven narrowing: `eslint --no-inline-config --format json` over the 7 changed `.ts` files reports 7 files linted, 0 errors and 0 warnings. All 7 fall in the config's `**/*.{ts,…}` block. The config never enables type-aware linting (`eslint.config.mjs:326-328`), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **Refusal cost on the predicate path.** The judgement runs where the payload is final, after the per-row `beforeUpdate` hooks and after the credential channel (`encryptSecretFields`), which runs above the strips on this branch. A refused bulk update whose payload carries a `secret` field has therefore already minted its `sys_secret` row. A validation refusal two lines below pays the same cost today. Moving the credential channel below the strips is a separate engine change. A `check` naming a secret field judges the stored reference. - **Image timing differs between the two update paths.** A predicate update is judged on the post-hook image; a by-id update is still judged in the middleware on the pre-hook change set merged with the caller-visible pre-image. Where a `beforeUpdate` hook rewrites a checked field, the bulk path is the stricter of the two. The by-id path is untouched here. - **Read cost.** On an object with no per-row hooks, a checked predicate update now reads its matched rows. The read is unbounded by the per-row hook ceiling, which applies only when hooks dispatch. On a kernel with the usual global hooks the read already happens and is shared. - **Partial-row array insert** (`__partialRowErrors`): a failing row refuses the whole call rather than being reported per row. - **Version skew.** A plugin-security built from this change, run over an engine without the predicate-path call, refuses checked bulk updates (fail-closed). Both packages carry the changeset. - `.changeset/19950-rls-check-multi-row-writes.md` is declared a narrowing, per the seat's ruling and following objectstack-ai#19952: `minor` for `@objectstack/plugin-security` and `@objectstack/objectql`, a `!` headline, `Clause-②: no (narrowing)`, an ADR-0087 `not-required (no-migration-prescription)` disposition, and a **BREAKING** paragraph listing the newly refused writes and the remedy (declare `check` on the policy, or fix the data). - `origin/main` was merged twice with merge commits, both clean with no regeneration owed: at `3fd3a4f91b` (`a5ca1db166`) and at `9bfbacbf8b` (`938b2acfde`). PR objectstack-ai#19984 (objectstack-ai#19963, the explain wiring in the same file) had not landed by the second merge. - Patch round 2: citation fix only (four comment lines in `security-plugin.ts`); no behaviour change. --------- Co-authored-by: Claude <noreply@anthropic.com>
… applicable policies, as the runtime applies it (objectstack-ai#19962) Fixes objectstack-ai#19953 Clause-②: no Contract text only; no schema shape, accepted value or runtime behaviour changes (triage direction A, `5808181305`). The published `RowLevelSecurityPolicySchema.check` said it "defaults to USING clause if not specified", which reads per policy. The write gate (`writeCheckPolicies`, since objectstack-ai#19952) decides the default once per write operation across the applicable policies. The texts now say so. They also say the check runs on the new row of a single-record insert and of a by-id update. An array insert and a `multi: true` update are not post-image checked (objectstack-ai#19964, objectstack-ai#19950). Round 2 made that change after the review FAIL `5813145732`, and limited the old 「OR-combine (most permissive wins)」 wording to reads. - `packages/spec/src/security/rls.zod.ts`: the `check` describe and TSDoc. The two reference pages under `content/docs/references/security/` are regenerated from the describe. - `content/docs/permissions/rls.mdx`: two sentences. - `packages/lint/src/validate-rls-predicate-enforceability.ts` (`domain:devx`, declared on the claim): the header, and the consequence texts for `using` and `check`. - Changeset: `@objectstack/spec` and `@objectstack/lint` at `patch`. The dev measured the runtime composition before writing; the table is in report `5812757565`. The ADR-0058 D4 clarifying note was reverted out of this PR (seat ruling `5812826208`), because `docs/adr/**` is governed; it stays owed separately. Filed from the measurement: objectstack-ai#19964 (an array insert runs no row-level check) and objectstack-ai#19965 (a `check` on a `select` / `delete` policy is never evaluated). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
…es them (objectstack-ai#20268) Fixes objectstack-ai#19967 Clause-②: no Text only. No schema shape, accepted value or runtime behaviour moves. Every sentence below was re-measured against the code on `origin/main` at `0d3ec47137`, where PR objectstack-ai#19962 (`b5853da1ca`), PR objectstack-ai#19988 (`009da14713`), PR objectstack-ai#20012 (`44639665ee`, objectstack-ai#19989) and PR objectstack-ai#20167 (`b276d4463f`) are all merged. Each `git merge-base --is-ancestor` probe returned exit 0. ## What `main` enforces (the measurement the texts now state) - **Write `check` selection, per operation.** `writeCheckPolicies` (`packages/plugins/plugin-security/src/security-plugin.ts:890-899`) returns the policies that declare `check` when any applicable policy does. Otherwise it returns every applicable policy with a `using`, and keeps the platform ownership floor only where the write's row gate kept it. `compileFilter` (`rls-compiler.ts:600-602`) reads `check` for a policy that declares one and `using` for the rest, then OR-combines (`:676`). - **A USING-only `insert` policy's `using` is the insert check** when no applicable policy declares `check`. Pinned by `rls-check-defaults-to-using.test.ts`, case "an insert-class USING-only policy gates INSERT the same way", which is green on this head. - **A check-only `update` policy is legal and enforced.** The schema accepts it (`rls.zod.ts`, the at-least-one rule). The row gate derives the write scope from `select` when no write-class `using` applies (`security-plugin.ts:7018`), and the post-image check judges the written rows. Pinned by `check-only-write-scope.test.ts`, which is green. The showcase's `invoice_owner_immutable` is one such policy. - **A non-blank `check` on `select` / `delete` is refused** (`rls.zod.ts`, the `superRefine` at the `check` path, from PR objectstack-ai#20167). - **OR-combination.** On reads, and on the rows an update or delete may target, the applicable policies' `using` clauses OR-combine (`compileFilter`). On the written rows, the check is chosen first and then OR-combined. So "most permissive wins" is true on reads and false as a statement about writes. - **Post-image scope: every insert and update shape.** The seam is installed for every insert and for every update (`security-plugin.ts:3191`, `:3261`, `:3508`). The engine runs it: - on each live row of an insert, after `beforeInsert` (`packages/objectql/src/engine.ts:12470`); - on the by-id row merged with the final payload, after `beforeUpdate` (`:14085`, PR objectstack-ai#20012); - on each matched row of a `multi: true` update (`:14345`, PR objectstack-ai#19988). The by-id update is also judged in the middleware on the change set as sent (`security-plugin.ts:3116`). Pinned by `rls-check-multi-row-writes.test.ts`, `rls-check-by-id-update-post-hook.test.ts` and `insert-check-post-image.test.ts`, all green on this head. ## Sites, old text, new text, and the code that makes the new text true | Site | Old text | New text | True because | |---|---|---|---| | `rls.zod.ts` `check` describe | "matched against the new row of a single-record INSERT or a by-id UPDATE ...; an array insert and a `multi: true` update are not post-image checked." | "judged on every row an insert or an update writes ...: each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent." | `engine.ts:12470`, `:14085`, `:14345`; `security-plugin.ts:3116` | | `rls.zod.ts` `check` TSDoc | "Validation of the new row of a single-record INSERT or a by-id UPDATE. An array insert and a `multi: true` update are not post-image checked." | Every row an insert or update writes, with the per-shape image. It also names the engine-filled values that are not on an insert's judged image (autonumber, a `secret` field's stored reference, an absent tenant column). | the same lines; the engine's closed list after the insert seam (`engine.ts`, the comment above `:12470`) | | `rls.zod.ts` `check` TSDoc, floor | "takes part only where the by-id pre-image gate kept it" | "only where the write's own row gate kept it" | `computeWriteCheckFilter`: a `multi: true` update has no by-id gate and keeps the floor unless the OWD yields (`platformFloorYieldsToObjectWriteModel`) | | `rls.zod.ts` `using` describe | "Filter condition for SELECT/UPDATE/DELETE ... Optional for INSERT-only policies." | "Row predicate ...: the rows a `select` policy lets a caller read, and the existing rows an `update` or `delete` policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in as its check on every row written ...; on an `insert` policy that is its only effect. ... Needed on a `select` or `delete` policy (a `check` there is refused); optional on an `insert`, `update` or `all` policy that declares `check`." | `writeCheckPolicies:896`; `getApplicablePolicies:846`; the `check` refusal | | `rls.zod.ts` `using` TSDoc | "For INSERT-only policies, USING is not required (only CHECK is needed). For SELECT/UPDATE/DELETE operations, USING is required." | A per-operation list. `update` does not require `using`: a check-only `update` policy is accepted and enforced, and its target rows come from the other `update` / `all` `using` or from `select`. An `insert` policy's `using` filters nothing, but it is the insert check when none is declared. | `security-plugin.ts:7018`; `check-only-write-scope.test.ts` | | `rls.zod.ts` `superRefine` message | "... For SELECT/UPDATE/DELETE operations, provide "using". For INSERT operations, provide "check"." | The same head. Then: `select` / `delete` take "using"; `insert` takes "check", or a "using" alone as that check when no applicable insert policy declares one; `update` / `all` take either or both. | the schema accepts each prescription (pinned below) | | `rls.zod.ts` schema TSDoc | "combined with OR logic (union of results)" | OR on reads; on writes, the per-operation choice (see `check`) | `compileFilter`; `writeCheckPolicies` | | `rls.zod.ts` `priority` TSDoc | "Applicable policies OR-combine (... the doc above `RLSCompiler.compileFilter` and this schema's own former describe both say most-permissive-wins)" | No outcome depends on an order: OR on reads; on writes, the check is chosen per operation and then OR-combined | the same | | `rls.zod.ts` overview, "Default Deny" | "If no policy matches, access is denied" | Default deny among the policies that apply. When no policy applies, the policies restrict nothing (the tenant wall still applies). | `compileFilter:656` (`applicable === 0` returns `null`, no filter); `content/docs/permissions/rls.mdx` callout | | `migrations/registry.ts` `--from 16` prose (and regenerated `docs/protocol-upgrade-guide.md`) | "applicable policies OR-combine (most permissive wins)" | "no outcome depends on an order: applicable policies OR-combine on reads, and a write's check is chosen once per operation across the applicable policies, then OR-combined" | the same | | `liveness/permission.json` `check` / `using` evidence | `compileFilter` "`(policy as { check?: string }).check ?? policy.using`" (the expression is gone) | `security-plugin.ts#writeCheckPolicies` plus `rls-compiler.ts#compileFilter`, with the live expression | `check:liveness` resolves both anchors (green) | | `security-plugin.ts` `writeCheckPolicies` docblock | "The published contract is `RowLevelSecurityPolicySchema.check`: "defaults to USING clause if not specified"." | It points at `RowLevelSecurityPolicySchema.check` without quoting it, so it cannot go stale. PostgreSQL's per-policy rule is named as the starting point. | the describe itself | | `rls-check-defaults-to-using.test.ts` header | "A policy that declares no `check` holds the write post-image to its `using`, which is what `RowLevelSecurityPolicySchema.check` publishes: "defaults to USING clause if not specified"." | When no applicable policy declares `check`, each `using` stands in. The choice is per operation, not per policy. | `writeCheckPolicies` | | `content/docs/permissions/authorization.mdx:108-109` | "Multiple row policies for the same object/operation OR-combine" | They OR-combine their `using` on reads and on the rows a write may target. The written-row `check` is chosen per operation first. Links the fail-closed contract. | the same | ### In-place fixes beyond the claim's file surface (same defect class: the same stale sentences) The dispatch asked for a grep for other copies of the old sentences. These hits are not governed and not pending changesets, so they are fixed here. **File-surface supplement for the claim:** `content/docs/permissions/rls.mdx`, `content/docs/protocol/objectql/security.mdx` and `packages/spec/src/conversions/registry.ts` (TSDoc only). The two changesets below are also outside it. - `content/docs/permissions/rls.mdx:63`: "after a single-record insert or a by-id update; an array insert and a `multi: true` update are not checked" now says every row an insert or update writes. - `content/docs/protocol/objectql/security.mdx:144`: the same parenthetical. `using` is no longer "for SELECT/UPDATE/DELETE" only. - `packages/spec/src/conversions/registry.ts`: the `permission-rls-priority-removed` TSDoc said "most permissive wins". Its `summary` (which feeds `spec-changes.json`) is unchanged and not false. - `packages/spec/src/security/rls.test.ts`: the `priority` test comment said "(most permissive wins)". ## Pin sweep for the `superRefine` message ① The repo-wide grep for `At least one of` and `provide "using"` finds pins only in `packages/spec/src/security/rls.test.ts`: `toContain('At least one of')` and its negation. Both still hold, because the head is unchanged. No other package, doc or translation carries the message. ② New load-bearing pins, in the `RowLevelSecurityPolicySchema — the "at least one" refusal` block of `rls.test.ts`. For each of the five operations they assert the refusal's `code` (`custom`), `path` (`[]`) and head, that every operation is named, and that the false sentence is absent. They then prove each prescription against the schema itself: `select` / `delete` + `using`, `insert` + `check`, `insert` + `using` alone, and `update` / `all` with check only, using only, and both. All parse. ## Pending release notes corrected (DELIBERATE CORRECTION: `check-empty-changeset` is red by design) - `.changeset/19953-rls-check-default-composition-text.md` (from PR objectstack-ai#19962). It said: "The check runs on the new row of a single-record insert and of a by-id update. An array insert and a `multi: true` update are not post-image checked; those are tracked in objectstack-ai#19964 and objectstack-ai#19950, and the texts now say so". That would ship false. objectstack-ai#19988 and objectstack-ai#20012 land in the same release, and this PR changes the texts it says "now say so". The rewrite dates the old scope to when that change was written, and states the release's scope. - `.changeset/rls-check-defaults-to-using.md` (from PR objectstack-ai#19952). It said: "`RowLevelSecurityPolicySchema.check` reads "defaults to USING clause if not specified"". The describe no longer reads that in the release this note ships in (PR objectstack-ai#19962). Changed to "read ... (it now states the default per operation across the applicable policies, objectstack-ai#19953)". The triage on objectstack-ai#19967 raised this note for the dev to judge. The gate's remedy is to say so here and get it confirmed. Restoring either note from base would put the false sentence back. ## Reported only, not edited - **Governed (Tier H):** - `docs/adr/0066-unified-authorization-model.md:93`: "Multiple row policies for the same object/operation are OR-combined". This is the ADR sentence that `authorization.mdx` paraphrases, and it has the same false-for-writes reading. - `skills/objectstack-data/rules/security.md:72-75`: calls `using` the "read filter" and `check` the "write filter", and does not state the stand-in check. - `docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md:79-81`: about the read filter, and not false. - **Release-owned (never edited in a code PR):** `packages/spec/CHANGELOG.md:46934`, `:73373` and `packages/plugins/plugin-security/CHANGELOG.md:6610`, `:10225`. These say "(the schema's own describe says most-permissive-wins)". That was true of the describe when they shipped. - `packages/spec/spec-changes.json:79`, `:971`: the generated `summary` "policies OR-combine". It is shorthand and not false. It is regenerated from the conversion registry, which this PR leaves as is. ## Published surface - **`@objectstack/spec`**: `patch`. It ships `src/**/*.zod.ts`, `dist`, `liveness` and the `--from 16` prose. - **`@objectstack/plugin-security`**: no changeset. The two edits are a comment in a non-exported function and a test header. Measured: tsup strips in-body comments. A positive control in `packages/objectql/dist/index.js` shows the code line `postHookWriteImageCheck.honoured = true` present (1 hit) and the comment above it, "INSERT POST-IMAGE seam", absent (0 hits). `writeCheckPolicies` is not exported, so no `.d.ts` carries its docblock. ## Verification Every reading below was taken on head `e2bd8f3680`, the final commit, with a clean tree. - **Gate union.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived the list. `--ran` reconciles it: "116 derived famil(ies) accounted for — 112 run, 4 NOT-MEASURED". The 116 includes 2 families the derivation added after the regeneration commit (`pnpm --filter @objectstack/spec run check:generated` and `pnpm check:quick-reference-counts`). Both were run: exit 0. - **111 green.** - **1 red by design:** `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 with the DELIBERATE CORRECTION class, for the two notes named above. - **NOT MEASURED, each one a PREREQUISITE NOT MET (exit 3):** - `check:skill-examples` needs a built `@objectstack/client-react` (a 36-package closure); - `check:dual-build-cjs-loads` needs a full `pnpm build`; - `check:i18n` needs the built CLI closure; - `check:type-check-debt` needs a whole-workspace build. - **`@objectstack/spec` build:** exit 0, DTS included. `check:generated`: 2 of 15 stale (`docs/protocol-upgrade-guide.md`, `content/docs/references/**`). Regenerated with exactly `gen:upgrade-guide && gen:docs`, then "All 15 generated artifacts are up to date". - **`@objectstack/spec` tests:** `vitest run --project local --maxWorkers=2`: "Test Files 547 passed (547) · Tests 16086 passed | 2 todo". - **`@objectstack/plugin-security` tests:** `vitest run --maxWorkers=2`: "Test Files 139 passed (139) · Tests 2839 passed (2839)". This includes the semantics pins cited above: `rls-check-defaults-to-using` (22 cases), `check-only-write-scope` (21), `rls-check-multi-row-writes` (32), `rls-check-by-id-update-post-hook` (24) and `insert-check-post-image` (25), all green. - **`@objectstack/plugin-security` typecheck:** exit 0, test layer included ("check:test-typecheck: OK"). - **`@objectstack/spec` typecheck: declared narrowing.** The full `pnpm --filter @objectstack/spec typecheck` never got a turn on the shared verify lock in about 40 minutes of queueing: 8 attempts, each exit 99. Its three legs are covered as follows: - the src program: the build's DTS pass compiles the entry closure, which contains `rls.zod.ts`, `migrations/registry.ts` and `conversions/registry.ts`; - the test program: `check:test-typecheck` is green inside `check:generated` on this head, and `tsconfig.test.json` includes `src/**/*.test.ts`; - `check:scripts-typecheck`: no spec script is touched. CI's `TypeScript Type Check` runs the full command. - **eslint, a proven narrowing:** - ① Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus build output, which contains all 6 changed `.ts` files. - ② `pnpm exec eslint --no-inline-config --format json` over those 6 files reports 6 files, 0 errors, 0 warnings, exit 0. - ③ No config object sets `parserOptions.project`, so no rule is type-aware, and this diff cannot move a verdict on an untouched file. - **Control bytes:** `check:nul-bytes` is green. The self-scan `grep -naP` for C0 control characters and DEL over the changed files exits 1 with no match. ## Acceptance notes - **Whitespace-only `check`: the schema and the runtime disagree** (observation, not filed; no public-door measurement, no named producer). The at-least-one rule tests `!data.check`, so `check: ' '` satisfies it. The runtime and the `select` / `delete` refusal read a blank clause as absent (`policyDeclaresClause`). So a policy with only a whitespace `check` parses and is inert. The `using` describe's "needed on a `select` or `delete` policy" is worded as the runtime reads it. - **`@objectstack/lint` `rls-predicate-*` messages understate the scope** (`packages/lint/src/validate-rls-predicate-enforceability.ts:272`, `:318`, `:943`, `:969`). They say "the single-record INSERT check" and "every single-record insert and by-id update ... fails". Since PR objectstack-ai#19988 and PR objectstack-ai#20012 this is true but narrow: array inserts and `multi: true` updates are refused too. These are not false, and they are outside this card's file surface. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #19942
Clause-②: no (narrowing)
What changed
The runtime now does what the published contract says. When an applicable policy declares no
check, the write post-image check (step 3.6,computeWriteCheckFilter) compiles that policy'susingas its check. This applies to the INSERT row, which is judged afterbeforeInsert, and to the new row of a by-id UPDATE. The contract isRowLevelSecurityPolicySchema.check: "defaults to USING clause if not specified". PostgreSQL has the same rule for a policy withoutWITH CHECK.Before this change, the gate compiled only policies that declared
check. Measured on the repro: withusing: "record.status != 'closed'", an INSERT ofstatus = 'closed'was admitted and stored.Changes to
packages/plugins/plugin-security:security-plugin.ts: a new selector,writeCheckPolicies, decides which applicable policies the post-image check compiles.security-plugin.ts: step 2.7 (the by-id pre-image gate) records its ownership-floor decision (preImageFloorOpts), and step 3.6 reads it.security-plugin.ts: the two early floor drops incomputeLayeredRlsFilter(thepublic_read_writeOWD and the covering controlled-by-parent master gate) now ask a single predicate,platformFloorYieldsToObjectWriteModel. The post-image check asks the same predicate. The pre-image behaviour is byte-identical.rls-compiler.ts: thecompileFiltercomment ("defaulting tousingwhen omitted") now matches a reachable call path. "Declarescheck" is now tested withpolicyDeclaresClause, the same test the selector uses.Zero edits under
packages/spec,content/docs/**andcontent/docs/releases/**.How a defaulted
usingcomposes, exactlyThis covers one principal's check. The delegator's check (ADR-0090 D10) and the Layer 0 tenant post-image check are still AND-ed with it, unchanged.
check. Only the declared checks take part, OR-combined. That is today's result, byte for byte. A USING-only sibling adds nothing to the check.usingwould be OR-ed in. That can only widen the check: a grant spelledid != nullwould erase every declared check beside it.check. Every applicable write-class policy with ausingtakes part, itsusingcompiled as its check, OR-combined.owner_only_writes) takes part in case 2 exactly when the by-id pre-image gate kept it.allow(an edit-depth share,modifyAllRecords), on apublic_read_writeOWD, and under a covering controlled-by-parent master gate. Otherwise it stays in.updatepolicy is used as a WIDENER, OR-ed with the floor at the pre-image. Leaving the floor out of the check would turn that widener into a restriction on the creator's own rows.modifyAllRecordsnorsys_record_share.access_level— both declared write-widening mechanisms are inert #5492, Acontrolled_by_parentdetail's own ownership floor is never droppable, so a cross-creator by-id UPDATE of a child is refused before the master gate — Modify All Data included #8757 andassertControlledByParentWrite's master write-RLS leg keeps the platform ownership floor, so a principal who may PATCH a master by id is refused on its children (#8679 divergence, sibling leg) #8865 opened.selectpolicies never gate a write's new row.modifyAllRecordsbypass on private and platform-global objects returns before the selector runs.Census (the triage's p0 condition), tree
aeaaa44292Added by the seat after the contract review: the census also covers two published authoring factories in
packages/spec/src/security/rls.zod.ts:613-666.RLS.ownerPolicy(operation: 'all', USING-only) andRLS.allowAllPolicy(all,1 == 1) both emit write-class USING-only policies. Their defaults see no reachable change:1 == 1is always true, andowner_idis stamped and guarded by step 3.5. A caller who passes a customownerFieldtoRLS.ownerPolicyis now held to it on writes.Searched:
packages/**,examples/**,apps/**, the publishedskills/**, and the CLI andcreate-objectstackscaffolds. The table lists every write-class (insert/update/all) policy that declaresusingand nocheck.usingmember_defaultowner_only_writes(platform floor)org_membercreated_by == current_user.idcreated_byaway from the caller is now refused.member_default,organization_adminsys_user_preference_selfuser_id == current_user.idallowCreate/allowEditare granted. A preference row created for another user, or moved to another user, is now refused (403). It was admitted.member_default,organization_adminsys_api_key_selfuser_id == current_user.iduser_idof another user is now refused (403). It was admitted withuser_idstripped later; see the dogfood change below.member_defaultsys_user_selfid == current_user.idsys_user(whitelisted columns). The new row keeps itsid, so there is no change.member_default,organization_adminsys_session_self,sys_two_factor_self,sys_device_code_self,sys_oauth_consent_self,sys_oauth_application_selfuser_id == current_user.iddenyWritesOnManagedObjects).member_default,organization_adminsys_organization_selfid == current_user.organization_idsys_organizationare denied at the object layer for both sets.packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json(a fixture, not shipped)marketing_campaign_updates,marketing_campaign_member_updatesid != nullNo write-class USING-only policy was found in
examples/app-showcase(task_own_rowsandinvoice_own_rowsareselect;invoice_owner_immutabledeclarescheck),packages/verify(aselectprobe),skills/objectstack-data/rules/security.md(own_recordsdeclarescheck;org_isolationisselect) or any CLI scaffold.Tenant-isolation verdict: the p0 condition does not fire.
organization_id == current_user.organization_idpolicy isselect:sys_member_org,sys_invitation_organdsys_team_org.$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage-1 measurement (P3) recorded a forged-organization INSERT refused 403 by it.sys_organization_self. It compares againstcurrent_user.organization_id, and it is in the platform's own tenant-policy provenance set (isPlatformTenantPolicy, which strips it when isolation is off). But:Writes refused after this change
A single-row INSERT or a by-id UPDATE whose resulting row falls outside the
usingof every applicable write-class policy, when none of those policies declarescheck. The refusal is403 PERMISSION_DENIED, the existing row-level CHECK envelope, and nothing is stored. There is no transition switch. An author who wants a write to leave a policy's scope declares acheck.In the platform defaults, this reaches the
sys_user_preferenceandsys_api_keyrows in the census, and acreated_byre-point under the floor.Tests
Final head
19236d91ad.New file
packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts. It uses a realObjectQLengine andSecurityPluginand runs on driver-sql (better-sqlite3) and driver-sqlite-wasm: 22 tests, 11 cells on each driver.record.status != 'closed'refuses an INSERT withstatus: 'closed'. The test assertscode: 'PERMISSION_DENIED'andstatus: 403, and that nothing is stored. It admits'open'. The same holds for aninsert-class policy.'closed'is refused and the stored row does not move. An in-scope update is admitted.organization_id == current_user.organization_id) refuses a cross-organization INSERT and admits one into the caller's own organization. Layer 0 is not armed in this harness, so the refusal comes from the defaulted check.checkcontrol: the policy is judged on itscheck, not on itsusing, as before.Fixture triage, two existing tests:
security-plugin.test.ts, "owner_id is always auto-injected on insert": itstenant_isolationpolicy isalland USING-only. The insert payload now carries the caller's organization, which the org-scoping stamp would write before the check, and the test asserts it arrives unchanged.packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts: the mixed smuggle patch is split in two. Re-owning (user_idof another user) is refused 403PERMISSION_DENIEDand changes nothing.keysmuggled besiderevokedis still admitted withkeystripped.Reverse verification. Each run used
scripts/ablation-replace.mjsfrom committed state, and each restore was proven: the blob equals HEAD andgit diff HEADis empty.usingfallback inwriteCheckPoliciesreplaced with an empty list (the pre-fix behaviour)authored-row-write-verdict.test.ts"TRANSFERRED_UPDATE_IS_ADMITTED_BY_THE_COMPOSED_PATH".controlled-by-parent-detail-write-authority.test.ts(#8757 / #8865) and 2 inrow-write-widener-composition.test.ts(#5492).Suites and gates:
pnpm --filter @objectstack/plugin-security test: 121 files, 2314 tests passed.pnpm --filter @objectstack/plugin-security typecheck: exit 0, andcheck:test-typecheckOK.@objectstack/organizationstest: 108 passed.@objectstack/plugin-sharingtest: 913 passed.@objectstack/dogfood, full run before the dogfood fix: 1 failed (the case above), 1102 passed. After the fix, that file passes 9 of 9.--format json. The config is not type-aware (noparserOptions.project), so this narrowing leaves no untouched file's verdict unmeasured. The fullpnpm lintis left to CI.node scripts/pm/dispatch-gates.mjs --commands: 66 derived commands. 64 exited 0. 2 exited 3, which isPREREQUISITE NOT METand so NOT MEASURED:check:dual-build-cjs-loadsandcheck:type-check-debtneed a whole-workspace build.Acceptance notes
packages/lint/src/validate-rls-predicate-enforceability.ts, header item 4, still sayscomputeWriteCheckFilter"collects only the policies that declare acheck". It is a comment, now incomplete. The rule'susingconsequence text does not mention that an uncompilableusingon aninsert/allpolicy with nochecknow also refuses every insert (it fails closed). Carrier: none.beforeUpdate(plugin-security: the UPDATE-side RLScheckpost-image ispre-image + change setevaluated beforebeforeUpdate, so a hook-stamped scoping field can move a row into an organization the caller does not hold #16790). Thesys_api_keycase shows the effect: a column the D2 whitelist would strip now refuses the patch at the check instead.check"defaults to USING". That is now true, except in the mixed case of rule 1. The mixed case is raised in the report, not edited here.Generated by Claude Code