Repository navigation
Commit 3f86dc5
fix(spec): state the RLS using / check texts as the write gate enforces them (#20268)
Fixes #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 #19962 (`b5853da1ca`), PR #19988
(`009da14713`), PR #20012 (`44639665ee`, #19989) and PR #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 #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 #20012);
- on each matched row of a `multi: true` update (`:14345`, PR #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
#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 #19964 and #19950, and
the texts now say so". That would ship false. #19988 and #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 #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 #19962). Changed to "read ... (it now states the
default per operation across the applicable policies, #19953)". The
triage on #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 #19988
and PR #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>1 parent 47849e7 commit 3f86dc5
16 files changed
Lines changed: 207 additions & 54 deletions
File tree
- .changeset
- content/docs
- permissions
- protocol/objectql
- references/security
- docs
- packages
- plugins/plugin-security/src
- spec
- liveness
- src
- conversions
- migrations
- security
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
107 | 107 | | |
108 | 108 | | |
109 | 109 | | |
110 | | - | |
111 | | - | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
112 | 118 | | |
113 | 119 | | |
114 | 120 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
61 | 61 | | |
62 | 62 | | |
63 | 63 | | |
64 | | - | |
| 64 | + | |
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
142 | 142 | | |
143 | 143 | | |
144 | 144 | | |
145 | | - | |
| 145 | + | |
146 | 146 | | |
147 | 147 | | |
148 | 148 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
167 | 167 | | |
168 | 168 | | |
169 | 169 | | |
170 | | - | |
171 | | - | |
| 170 | + | |
| 171 | + | |
172 | 172 | | |
173 | 173 | | |
174 | 174 | | |
| |||
0 commit comments