From 9e3e141ed681c1f96885cdfd6d919314481f34f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:12:31 +0000 Subject: [PATCH 1/9] docs(spec): RowLevelSecurityPolicySchema.check states the per-operation using default The using-to-check default is decided per write operation across the applicable policies, not policy by policy: when any applicable policy declares check, only the declared checks decide and a USING-only sibling adds nothing. The describe and TSDoc said the default applied per policy. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/security/rls.zod.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 20eddc3993c..e1a5ffc6e97 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -399,10 +399,26 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( * Similar to USING but applies to new/modified rows. * Prevents users from creating/updating rows they wouldn't be able to see. * - * **Default Behavior**: If not specified, implementations should use the - * USING clause as the CHECK clause. This ensures data integrity by preventing - * users from creating records they cannot view. - * + * **Default Behavior**: the `using` → `check` default is decided per write + * operation across the applicable policies, not policy by policy. A policy + * is applicable to a write when it is not `enabled: false`, its `object` is + * the written object or `'*'`, its `operation` is the write's own (`insert` + * or `update`) or `'all'`, and, when it lists `positions`, the caller holds + * one of them. + * + * - When any applicable policy declares `check`, only the declared checks + * decide, OR-combined. A USING-only sibling adds nothing: its `using` is + * not part of the write check. + * - When none declares `check`, each applicable policy's `using` stands in as + * its check, OR-combined. The platform's own ownership floor + * (`owner_only_writes`) takes part only where the by-id pre-image gate + * kept it. + * + * So declaring `check` on one policy replaces, for the callers that policy + * applies to, the `using` its USING-only siblings would otherwise have + * contributed to the check. A `check` on a `select` or `delete` policy is + * never evaluated and takes no part in this choice. + * * Use cases: * - Prevent cross-tenant data creation * - Enforce mandatory field values @@ -415,7 +431,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( */ check: z.string() .optional() - .describe('Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level)'), + .describe('Validation condition for INSERT/UPDATE, matched against the new row (enforced at application level). The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy\'s `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is \'*\', `operation` matches or is \'all\', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.'), /** * Restrict this policy to specific positions (ADR-0090 D3; formerly From 799f77c3607089bab3b9f542e029c1e1b3579220 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:26:50 +0000 Subject: [PATCH 2/9] docs(spec): regenerate the security reference pages for the check describe Output of pnpm --filter @objectstack/spec check:generated --fix, which proved check:docs stale and regenerated only content/docs/references/**. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/security/permission.mdx | 2 +- content/docs/references/security/rls.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 376ef2c3f83..978772c11d9 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -168,7 +168,7 @@ const result = AdminScopeSchema.parse(data); | **object** | `string` | ✅ | Target object name | | **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to | | **using** | `string` | optional | Filter condition for SELECT/UPDATE/DELETE, authored in canonical CEL (ADR-0058 D1). It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Optional for INSERT-only policies. | -| **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) | +| **check** | `string` | optional | Validation condition for INSERT/UPDATE, matched against the new row (enforced at application level). The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | | **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index dab0e05b385..4c621c9ff00 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -172,7 +172,7 @@ const result = RLSEvaluationResultSchema.parse(data); | **object** | `string` | ✅ | Target object name | | **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to | | **using** | `string` | optional | Filter condition for SELECT/UPDATE/DELETE, authored in canonical CEL (ADR-0058 D1). It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Optional for INSERT-only policies. | -| **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) | +| **check** | `string` | optional | Validation condition for INSERT/UPDATE, matched against the new row (enforced at application level). The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | | **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | From e039b6a27238ce95ced861078a629ce4688363c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:26:51 +0000 Subject: [PATCH 3/9] docs(permissions): the RLS page states the per-operation check default The property table and the fail-closed list said an omitted check reuses using. That holds only when no applicable policy for the operation declares a check; when one does, only the declared checks decide. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/permissions/rls.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index e52c8b911f0..bf63e79d6bb 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -60,7 +60,7 @@ export const ContributorAccess = definePermissionSet({ | `object` | `string` | Target object — or `'*'` to apply to every object | | `operation` | `'select' \| 'insert' \| 'update' \| 'delete' \| 'all'` | Which operation the policy guards. A `select` policy also bounds writes when no write-class policy applies — see below | | `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) | -| `check` | `string` | Predicate rows must satisfy **after a write**. Omit it and `using` is reused | +| `check` | `string` | Predicate the new row must satisfy **after an insert or update**. Omit it and `using` stands in, unless another applicable policy for the same operation declares a `check` — see [the fail-closed contract](#the-fail-closed-contract) | | `positions` | `string[]` | Which positions the policy applies to. Omit = everyone | | `enabled` | `boolean` | Default `true`. `false` switches the policy off — a disabled policy is not evaluated | @@ -202,7 +202,10 @@ Four ways a policy denies rather than leaks: 2. A referenced context variable is missing, null, or an empty array → that policy drops out (it cannot match). 3. A policy references a column the object doesn't have → deny. -4. `check` is omitted → `using` is reused for writes, never "anything goes". +4. `check` is omitted → `using` stands in as the write check. The choice is + made per operation across all the applicable policies, not policy by + policy: when any of them declares a `check`, only the declared checks + decide, and a USING-only sibling's `using` is not part of the write check. **The one non-obvious case: no applicable policy means *no restriction*, not From 51a71c319452b4ecea353d93197d163a1fd98215 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:27:53 +0000 Subject: [PATCH 4/9] docs(lint): the RLS enforceability header and consequence text state the write-check composition The header said computeWriteCheckFilter collects only the policies that declare a check. It takes its set from writeCheckPolicies: the declared checks when any applicable policy declares one, otherwise every applicable policy's using compiled as its check. Every using consequence now states what a dropped using on an insert or all policy does to the insert check, and every check consequence is qualified by the rest of the declared set. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../validate-rls-predicate-enforceability.ts | 77 +++++++++++++++---- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 03439c52c9f..8de5dbaa912 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -29,10 +29,17 @@ * on that object then matches zero rows. If other policies also applied, * this one is simply dropped out of the OR and the access it was written to * grant does not exist. - * 4. **Write path (`check`, ADR-0058 D4).** `computeWriteCheckFilter` collects - * only the policies that declare a `check`; the same drop makes the - * post-image predicate the deny sentinel, `matchesFilterCondition` fails, - * and the write raises `PermissionDeniedError`. + * 4. **Write path (`check`, ADR-0058 D4).** `computeWriteCheckFilter` takes + * its set from `writeCheckPolicies`, per insert / update and per caller: + * the applicable policies that declare a `check` when any does (a + * USING-only sibling then adds nothing), otherwise every applicable policy + * with a `using`, that `using` compiled as its check. So an unlowerable + * `check` always reaches this path, and an unlowerable `using` on an + * `insert` / `update` / `all` policy reaches it whenever no applicable + * policy for that operation declares a `check`. When nothing else in the + * set compiles, the same drop makes the post-image predicate the deny + * sentinel, `matchesFilterCondition` fails, and the write raises + * `PermissionDeniedError`; when something else compiles, it alone decides. * * So this is not a hole — the runtime fails CLOSED, which is why it has been * survivable. It is a policy that reads as an authorization and behaves as a @@ -246,6 +253,36 @@ function quote(source: string): string { return source.length > 200 ? `${source.slice(0, 197)}...` : source; } +/** + * The INSERT half of a dropped `using`, appended to every `using` consequence + * below. The ADR-0058 D4 write check takes its set from `writeCheckPolicies`: + * when no applicable policy for the insert declares a `check`, every applicable + * policy's `using` is compiled as its check, so the same drop reaches the insert + * too. Measured through the real `SecurityPlugin` on an `insert` and an `all` + * policy, for all three kinds of drop (an unlowerable shape, an unresolved + * `current_user.*`, an undeclared column): with nothing else compiling in that + * set, every single-record insert is refused (403); with another applicable + * policy's `using` compiling, that one alone decides; with a declared `check` + * beside it, the declared check alone decides and this `using` takes no part. + */ +const USING_INSERT_CONSEQUENCE = + ' On an `insert` or `all` policy the same `using` is also the INSERT check whenever no applicable ' + + 'policy for the insert declares a `check` (ADR-0058 D4): when nothing else in that set compiles, ' + + 'every single-record insert it governs fails with `PermissionDeniedError`; when another ' + + "policy's `using` compiles, that one alone decides the insert."; + +/** + * Qualifies every `check` consequence below. The write check OR-combines the + * declared checks of all the applicable policies for the operation, so a + * dropped `check` is a blanket refusal only when no other declared `check` in + * that set compiles (measured: beside a compiling declared `check`, that one + * alone decided; beside a USING-only sibling, every write was refused, because + * a USING-only sibling takes no part once any policy declares a `check`). + */ +const CHECK_SET_QUALIFIER = + ' That holds when no other applicable policy for the operation declares a `check` that compiles; ' + + 'when one does, that `check` alone decides and this one contributes nothing.'; + /** What the runtime does with a predicate it cannot compile, per clause. */ function consequence(clause: 'using' | 'check'): string { const dropped = @@ -256,11 +293,14 @@ function consequence(clause: 'using' | 'check'): string { 'When it is the only applicable policy for that object and operation, `compileFilter` returns the ' + '`RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause: every select / update / ' + 'delete on the object matches ZERO rows. When other policies also apply, this one just vanishes ' + - 'from the OR and grants none of the access it appears to.' + 'from the OR and grants none of the access it appears to.' + + USING_INSERT_CONSEQUENCE : dropped + 'On the ADR-0058 D4 write path that leaves the post-image `check` as the `RLS_DENY_FILTER` ' + - 'sentinel, which no record can satisfy: every insert / update the policy governs fails with ' + - '`PermissionDeniedError`. The policy reads as a write rule and behaves as a blanket refusal.'; + 'sentinel, which no record can satisfy: every single-record insert and by-id update the policy ' + + 'governs fails with `PermissionDeniedError`. The policy reads as a write rule and behaves as a ' + + 'blanket refusal.' + + CHECK_SET_QUALIFIER; } /* ──────────────────────────────────────────────────────────────────────────── @@ -532,11 +572,14 @@ function referenceConsequence(clause: 'using' | 'check', kind: 'field' | 'variab 'delete matches ZERO rows, so the object DISAPPEARS for every holder of this permission set — ' + 'not because they were denied, but because the narrowing they were granted resolves to nothing. ' + 'When other policies also apply, this one vanishes from the OR and grants none of the access it ' + - 'appears to.' + 'appears to.' + + USING_INSERT_CONSEQUENCE : dropped + - 'On the ADR-0058 D4 write path that leaves the post-image `check` unsatisfiable: every insert / ' + - 'update the policy governs fails with `PermissionDeniedError`. The policy reads as a write rule ' + - 'and behaves as a blanket refusal for every holder of this permission set.'; + 'On the ADR-0058 D4 write path that leaves the post-image `check` unsatisfiable: every ' + + 'single-record insert and by-id update the policy governs fails with `PermissionDeniedError`. ' + + 'The policy reads as a write rule and behaves as a blanket refusal for every holder of this ' + + 'permission set.' + + CHECK_SET_QUALIFIER; } // ── The FIELD half. ONE direction, in every position and every polarity, @@ -555,11 +598,15 @@ function referenceConsequence(clause: 'using' | 'check', kind: 'field' | 'variab '`RLS_DENY_FILTER` sentinel: every select / update / delete matches ZERO rows, so the object ' + 'DISAPPEARS for every holder of this permission set — not because they were denied, but because ' + 'the narrowing they were granted names a column that is not there. When other policies also ' + - 'apply, this one vanishes from the OR and grants none of the access it appears to.' + 'apply, this one vanishes from the OR and grants none of the access it appears to.' + + USING_INSERT_CONSEQUENCE : dropped + - 'On the ADR-0058 D4 write path that leaves the post-image `check` unsatisfiable: every insert / ' + - 'update the policy governs fails with `PermissionDeniedError`. The policy reads as a write rule ' + - 'and behaves as a blanket refusal for every holder of this permission set. ⚠️ On a runtime older ' + + 'On the ADR-0058 D4 write path that leaves the post-image `check` unsatisfiable: every ' + + 'single-record insert and by-id update the policy governs fails with `PermissionDeniedError`. ' + + 'The policy reads as a write rule and behaves as a blanket refusal for every holder of this ' + + 'permission set.' + + CHECK_SET_QUALIFIER + + ' ⚠️ On a runtime older ' + 'than that guard this clause failed OPEN rather than closed — the write path had no ' + 'field-existence check at all, so a negated miss was satisfied VACUOUSLY by the post-image and ' + 'PERMITTED exactly the writes the policy was written to refuse, on every driver. Fix the name ' + From 8e8a56f31a0ea03d1c700502058f951d5d1d966f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:29:48 +0000 Subject: [PATCH 5/9] chore(changeset): patch spec and lint for the RLS check default text Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...9953-rls-check-default-composition-text.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/19953-rls-check-default-composition-text.md diff --git a/.changeset/19953-rls-check-default-composition-text.md b/.changeset/19953-rls-check-default-composition-text.md new file mode 100644 index 00000000000..1b18d09af0d --- /dev/null +++ b/.changeset/19953-rls-check-default-composition-text.md @@ -0,0 +1,20 @@ +--- +"@objectstack/spec": patch +"@objectstack/lint": patch +--- + +fix(spec, lint): the RLS `check` → `using` default is stated per operation across the applicable policies, as the runtime applies it, not per policy (#19953) + +Clause-②: no + +Text only. No schema shape, accepted value or runtime behaviour changes. + +`RowLevelSecurityPolicySchema.check` said the clause "defaults to USING clause if not specified", which reads as a rule for each policy on its own. The write gate decides the default once per write operation, across every applicable policy: + +- When any applicable policy for the operation declares `check`, only the declared checks decide, OR-combined. A policy with only a `using` beside them adds nothing to the check. +- Only when none declares `check` does each applicable policy's `using` stand in as its check, OR-combined. + +A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. + +- **`@objectstack/spec`**: the `check` describe and TSDoc state this composition. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe. +- **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles. From 20b8fe95e8285e8190090bf12ecd960fa54de253 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:30:02 +0000 Subject: [PATCH 6/9] docs(adr-0058): clarify that D4's using default is per operation, not per policy A dated note under D4, not an amendment: the check-to-using default is applied across the applicable policies for a write operation, and a USING-only policy adds nothing once any applicable policy declares check. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- docs/adr/0058-expression-and-predicate-surface.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/adr/0058-expression-and-predicate-surface.md b/docs/adr/0058-expression-and-predicate-surface.md index c8e73126bf4..872d5cddaa6 100644 --- a/docs/adr/0058-expression-and-predicate-surface.md +++ b/docs/adr/0058-expression-and-predicate-surface.md @@ -609,6 +609,14 @@ With D1/D2, the spec sharing-rule **`condition` (CEL) is compiled** to `criteria Compile the RLS **`check`** clause (defaulting to `using` when omitted) with the same canonical compiler and enforce it on the **write pre-image path** that already exists for by-id writes (ADR-0056/`#1994`) and on the AST-injected bulk path. A `check` that the compiler cannot lower is a compile error (D2). This closes the declared-but-unenforced `check` gap (ADR-0049). +> **Note (2026-09-24, #19953) — a clarification of "defaulting to `using` when omitted", not an amendment.** +> The default is applied per write operation across the applicable policies, not policy by policy. When any +> applicable policy for the operation declares `check`, only the declared checks take part, OR-combined, and a +> policy with only a `using` beside them adds nothing. Only when none declares `check` does each applicable +> policy's `using` stand in as its check, OR-combined. The published contract is +> `RowLevelSecurityPolicySchema.check`; the runtime selector is `writeCheckPolicies` in +> `packages/plugins/plugin-security/src/security-plugin.ts`. The rest of D4 is unchanged by this note. + ### D5 — One fail-policy matrix for the whole expression surface Today each surface invents its own behavior. Standardize on **three tiers keyed by (when) × (security-relevance)**: From f139546003c519f962ccb662c4d48b5f1b37619f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:02:36 +0000 Subject: [PATCH 7/9] Revert "docs(adr-0058): clarify that D4's using default is per operation, not per policy" This reverts commit 20b8fe95e8285e8190090bf12ecd960fa54de253. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- docs/adr/0058-expression-and-predicate-surface.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/adr/0058-expression-and-predicate-surface.md b/docs/adr/0058-expression-and-predicate-surface.md index 872d5cddaa6..c8e73126bf4 100644 --- a/docs/adr/0058-expression-and-predicate-surface.md +++ b/docs/adr/0058-expression-and-predicate-surface.md @@ -609,14 +609,6 @@ With D1/D2, the spec sharing-rule **`condition` (CEL) is compiled** to `criteria Compile the RLS **`check`** clause (defaulting to `using` when omitted) with the same canonical compiler and enforce it on the **write pre-image path** that already exists for by-id writes (ADR-0056/`#1994`) and on the AST-injected bulk path. A `check` that the compiler cannot lower is a compile error (D2). This closes the declared-but-unenforced `check` gap (ADR-0049). -> **Note (2026-09-24, #19953) — a clarification of "defaulting to `using` when omitted", not an amendment.** -> The default is applied per write operation across the applicable policies, not policy by policy. When any -> applicable policy for the operation declares `check`, only the declared checks take part, OR-combined, and a -> policy with only a `using` beside them adds nothing. Only when none declares `check` does each applicable -> policy's `using` stand in as its check, OR-combined. The published contract is -> `RowLevelSecurityPolicySchema.check`; the runtime selector is `writeCheckPolicies` in -> `packages/plugins/plugin-security/src/security-plugin.ts`. The rest of D4 is unchanged by this note. - ### D5 — One fail-policy matrix for the whole expression surface Today each surface invents its own behavior. Standardize on **three tiers keyed by (when) × (security-relevance)**: From e2d9c7492e4609a245bbe65265237721f13cc205 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:32:23 +0000 Subject: [PATCH 8/9] docs(spec, lint): scope the RLS check texts to the writes it runs on; limit OR-combine to reads The check describe, its TSDoc, the permissions RLS page and the ObjectQL security page said every insert or update's new row is checked. The post-image check runs on a single-record insert and a by-id update; an array insert and a multi: true update are not checked. The texts now say so. The priority tombstone no longer cites most-permissive-wins as its reason, and the OR-combine statements on those pages are limited to reads, since a declared check is not widened by a USING-only sibling. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...9953-rls-check-default-composition-text.md | 6 ++-- content/docs/permissions/rls.mdx | 11 ++++--- content/docs/protocol/objectql/security.mdx | 2 +- .../validate-rls-predicate-enforceability.ts | 32 ++++++++++--------- packages/spec/src/security/rls.zod.ts | 19 +++++------ 5 files changed, 36 insertions(+), 34 deletions(-) diff --git a/.changeset/19953-rls-check-default-composition-text.md b/.changeset/19953-rls-check-default-composition-text.md index 1b18d09af0d..3255e28b15f 100644 --- a/.changeset/19953-rls-check-default-composition-text.md +++ b/.changeset/19953-rls-check-default-composition-text.md @@ -16,5 +16,7 @@ Text only. No schema shape, accepted value or runtime behaviour changes. A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. -- **`@objectstack/spec`**: the `check` describe and TSDoc state this composition. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe. -- **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles. +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 instead of implying every insert and update is checked. + +- **`@objectstack/spec`**: the `check` describe and TSDoc state this composition and that scope. The `rowLevelSecurity[].priority` refusal no longer gives "applicable policies OR-combine (most permissive wins)" as its reason, which is not true of the write check, and the file overview limits "OR-combine" to reads. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe. +- **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the single-record insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles. diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index bf63e79d6bb..04b68f2bd23 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -60,7 +60,7 @@ export const ContributorAccess = definePermissionSet({ | `object` | `string` | Target object — or `'*'` to apply to every object | | `operation` | `'select' \| 'insert' \| 'update' \| 'delete' \| 'all'` | Which operation the policy guards. A `select` policy also bounds writes when no write-class policy applies — see below | | `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) | -| `check` | `string` | Predicate the new row must satisfy **after an insert or update**. Omit it and `using` stands in, unless another applicable policy for the same operation declares a `check` — see [the fail-closed contract](#the-fail-closed-contract) | +| `check` | `string` | Predicate the new row must satisfy **after a single-record insert or a by-id update**; an array insert and a `multi: true` update are not checked. Omit it and `using` stands in, unless another applicable policy for the same operation declares a `check` — see [the fail-closed contract](#the-fail-closed-contract) | | `positions` | `string[]` | Which positions the policy applies to. Omit = everyone | | `enabled` | `boolean` | Default `true`. `false` switches the policy off — a disabled policy is not evaluated | @@ -182,8 +182,9 @@ Layer 1 (business RLS) ─┘ - **Tenant isolation is a separate layer that always applies first** and is never OR-ed away by a business policy. -- **Multiple policies on the same object are OR-ed** — a row is admitted if it - matches *any* applicable policy. This is the intended way to express +- **Multiple policies on the same object are OR-ed** — on a read, a row is + admitted if it matches *any* applicable policy (for writes, see item 4 of + the fail-closed contract below). This is the intended way to express alternatives. - The full evaluation pipeline around RLS is: object CRUD → FLS → OWD baseline → depth → sharing → RLS. RLS narrows what the earlier layers already allowed; @@ -202,10 +203,10 @@ Four ways a policy denies rather than leaks: 2. A referenced context variable is missing, null, or an empty array → that policy drops out (it cannot match). 3. A policy references a column the object doesn't have → deny. -4. `check` is omitted → `using` stands in as the write check. The choice is +4. `check` is omitted → `using` stands in as the `check`. The choice is made per operation across all the applicable policies, not policy by policy: when any of them declares a `check`, only the declared checks - decide, and a USING-only sibling's `using` is not part of the write check. + decide, and a USING-only sibling's `using` is not part of the check. **The one non-obvious case: no applicable policy means *no restriction*, not diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index 86e0d479ac0..28eabc88197 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -141,7 +141,7 @@ Return filtered result ## 2. Row-Level Security -Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `'manager' in current_user.positions`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision. +Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for the new row of a single-record INSERT or a by-id UPDATE; an array insert and a `multi: true` update are not checked) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). On reads, multiple policies for one object are combined with OR; for the write `check`, see `RowLevelSecurityPolicySchema.check`. Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `'manager' in current_user.positions`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision. Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata. diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 8de5dbaa912..db43f9861ac 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -30,12 +30,13 @@ * this one is simply dropped out of the OR and the access it was written to * grant does not exist. * 4. **Write path (`check`, ADR-0058 D4).** `computeWriteCheckFilter` takes - * its set from `writeCheckPolicies`, per insert / update and per caller: - * the applicable policies that declare a `check` when any does (a - * USING-only sibling then adds nothing), otherwise every applicable policy - * with a `using`, that `using` compiled as its check. So an unlowerable - * `check` always reaches this path, and an unlowerable `using` on an - * `insert` / `update` / `all` policy reaches it whenever no applicable + * its set from `writeCheckPolicies`, per single-record insert / by-id + * update and per caller (an array insert and a `multi: true` update never + * reach it): the applicable policies that declare a `check` when any does + * (a USING-only sibling then adds nothing), otherwise every applicable + * policy with a `using`, that `using` compiled as its check. So an + * unlowerable `check` on an `insert` / `update` / `all` policy reaches this + * path, and so does an unlowerable `using` on one whenever no applicable * policy for that operation declares a `check`. When nothing else in the * set compiles, the same drop makes the post-image predicate the deny * sentinel, `matchesFilterCondition` fails, and the write raises @@ -257,27 +258,28 @@ function quote(source: string): string { * The INSERT half of a dropped `using`, appended to every `using` consequence * below. The ADR-0058 D4 write check takes its set from `writeCheckPolicies`: * when no applicable policy for the insert declares a `check`, every applicable - * policy's `using` is compiled as its check, so the same drop reaches the insert - * too. Measured through the real `SecurityPlugin` on an `insert` and an `all` - * policy, for all three kinds of drop (an unlowerable shape, an unresolved + * policy's `using` is compiled as its check, so the same drop reaches the + * single-record insert too. Measured through the real `SecurityPlugin` on an + * `insert` and an `all` policy, for all three kinds of drop (an unlowerable shape, an unresolved * `current_user.*`, an undeclared column): with nothing else compiling in that * set, every single-record insert is refused (403); with another applicable * policy's `using` compiling, that one alone decides; with a declared `check` * beside it, the declared check alone decides and this `using` takes no part. */ const USING_INSERT_CONSEQUENCE = - ' On an `insert` or `all` policy the same `using` is also the INSERT check whenever no applicable ' + - 'policy for the insert declares a `check` (ADR-0058 D4): when nothing else in that set compiles, ' + - 'every single-record insert it governs fails with `PermissionDeniedError`; when another ' + - "policy's `using` compiles, that one alone decides the insert."; + ' On an `insert` or `all` policy the same `using` is also the single-record INSERT check ' + + 'whenever no applicable policy for the insert declares a `check` (ADR-0058 D4): when nothing ' + + 'else in that set compiles, every single-record insert it governs fails with ' + + "`PermissionDeniedError`; when another policy's `using` compiles, that one alone decides the insert."; /** * Qualifies every `check` consequence below. The write check OR-combines the * declared checks of all the applicable policies for the operation, so a * dropped `check` is a blanket refusal only when no other declared `check` in * that set compiles (measured: beside a compiling declared `check`, that one - * alone decided; beside a USING-only sibling, every write was refused, because - * a USING-only sibling takes no part once any policy declares a `check`). + * alone decided; beside a USING-only sibling, every single-record insert was + * refused, because a USING-only sibling takes no part once any policy declares + * a `check`). */ const CHECK_SET_QUALIFIER = ' That holds when no other applicable policy for the operation declares a `check` that compiles; ' + diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index e1a5ffc6e97..bfe63bab784 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -78,7 +78,7 @@ import { strictObject } from '../shared/strict-object'; * ObjectStack RLS: * - A constrained CEL predicate grammar: comparisons and set-membership against literals or `current_user.*` values, composable with `&&` / `||`; anything that does not lower to a filter fails closed * - Subquery-shaped needs are pre-resolved by the runtime (§7.3.1) - * - Multiple policies OR-combine for union (any-match-allows) semantics + * - Multiple policies OR-combine on reads (any match allows); for writes see `check` * * ## Best Practices * @@ -92,8 +92,7 @@ import { strictObject } from '../shared/strict-object'; * * 1. **Defense in Depth**: RLS is one layer; use with object permissions * 2. **Default Deny**: If no policy matches, access is denied - * 3. **Policy Precedence**: More permissive policy wins (OR logic) - * 4. **Context Variables**: Ensure current_user context is always set + * 3. **Context Variables**: Ensure current_user context is always set * * @see https://www.postgresql.org/docs/current/ddl-rowsecurity.html * @see https://help.salesforce.com/s/articleView?id=sf.security_sharing_rules.htm @@ -394,10 +393,9 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( .describe('Filter condition for SELECT/UPDATE/DELETE, authored in canonical CEL (ADR-0058 D1). It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in [\'draft\', \'pending\']); these combined with `&&` / `||`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Optional for INSERT-only policies.'), /** - * CHECK clause - Validation for INSERT/UPDATE operations. - * - * Similar to USING but applies to new/modified rows. - * Prevents users from creating/updating rows they wouldn't be able to see. + * CHECK clause - 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 (#19964, #19950). * * **Default Behavior**: the `using` → `check` default is decided per write * operation across the applicable policies, not policy by policy. A policy @@ -408,7 +406,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( * * - When any applicable policy declares `check`, only the declared checks * decide, OR-combined. A USING-only sibling adds nothing: its `using` is - * not part of the write check. + * not part of the check. * - When none declares `check`, each applicable policy's `using` stands in as * its check, OR-combined. The platform's own ownership floor * (`owner_only_writes`) takes part only where the by-id pre-image gate @@ -431,7 +429,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( */ check: z.string() .optional() - .describe('Validation condition for INSERT/UPDATE, matched against the new row (enforced at application level). The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy\'s `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is \'*\', `operation` matches or is \'all\', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.'), + .describe('Validation condition matched against the new row of a single-record INSERT or a by-id UPDATE (enforced at application level); an array insert and a `multi: true` update are not post-image checked. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy\'s `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is \'*\', `operation` matches or is \'all\', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.'), /** * Restrict this policy to specific positions (ADR-0090 D3; formerly @@ -477,8 +475,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( */ priority: retiredKey( '`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. ' + - 'It never had an effect and could not: applicable policies OR-combine (most permissive wins), ' + - 'so there is no conflict to order. Delete the key — policy outcomes are unchanged. ' + + 'It never had an effect. Delete the key — policy outcomes are unchanged. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), From 22c9473c86d64d1e29b47548e2264affecd8a792 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:40:49 +0000 Subject: [PATCH 9/9] docs(spec): regenerate the security reference pages for the scoped check and priority texts Output of pnpm --filter @objectstack/spec check:generated --fix, which proved check:docs stale and regenerated only content/docs/references/**. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/security/permission.mdx | 4 ++-- content/docs/references/security/rls.mdx | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 978772c11d9..6f0299e6da7 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -168,10 +168,10 @@ const result = AdminScopeSchema.parse(data); | **object** | `string` | ✅ | Target object name | | **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to | | **using** | `string` | optional | Filter condition for SELECT/UPDATE/DELETE, authored in canonical CEL (ADR-0058 D1). It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Optional for INSERT-only policies. | -| **check** | `string` | optional | Validation condition for INSERT/UPDATE, matched against the new row (enforced at application level). The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. | +| **check** | `string` | optional | Validation condition matched against the new row of a single-record INSERT or a by-id UPDATE (enforced at application level); an array insert and a `multi: true` update are not post-image checked. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | -| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **tags** | `string[]` | optional | Policy categorization tags | ### Nested Shape: `PermissionSet.adminScope` diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 4c621c9ff00..a1caa047c55 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -78,7 +78,7 @@ Salesforce: ObjectStack RLS: - A constrained CEL predicate grammar: comparisons and set-membership against literals or `current_user.*` values, composable with `&&` / `||`; anything that does not lower to a filter fails closed - Subquery-shaped needs are pre-resolved by the runtime (§7.3.1) -- Multiple policies OR-combine for union (any-match-allows) semantics +- Multiple policies OR-combine on reads (any match allows); for writes see `check` ### Best Practices @@ -92,8 +92,7 @@ ObjectStack RLS: 1. **Defense in Depth**: RLS is one layer; use with object permissions 2. **Default Deny**: If no policy matches, access is denied -3. **Policy Precedence**: More permissive policy wins (OR logic) -4. **Context Variables**: Ensure current_user context is always set +3. **Context Variables**: Ensure current_user context is always set See also: https://www.postgresql.org/docs/current/ddl-rowsecurity.html @@ -172,10 +171,10 @@ const result = RLSEvaluationResultSchema.parse(data); | **object** | `string` | ✅ | Target object name | | **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to | | **using** | `string` | optional | Filter condition for SELECT/UPDATE/DELETE, authored in canonical CEL (ADR-0058 D1). It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Optional for INSERT-only policies. | -| **check** | `string` | optional | Validation condition for INSERT/UPDATE, matched against the new row (enforced at application level). The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. | +| **check** | `string` | optional | Validation condition matched against the new row of a single-record INSERT or a by-id UPDATE (enforced at application level); an array insert and a `multi: true` update are not post-image checked. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated. | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | -| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **tags** | `string[]` | optional | Policy categorization tags |