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..3255e28b15f --- /dev/null +++ b/.changeset/19953-rls-check-default-composition-text.md @@ -0,0 +1,22 @@ +--- +"@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. + +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 e52c8b911f0..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 rows must satisfy **after a write**. Omit it and `using` is reused | +| `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,7 +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` is reused for writes, never "anything goes". +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 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/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 376ef2c3f83..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 (defaults to USING clause if not specified - enforced at application level) | +| **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 dab0e05b385..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 (defaults to USING clause if not specified - enforced at application level) | +| **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 | diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 03439c52c9f..db43f9861ac 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -29,10 +29,18 @@ * 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 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 + * `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 +254,37 @@ 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 + * 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 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 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; ' + + '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 +295,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 +574,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 +600,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 ' + diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 20eddc3993c..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,15 +393,30 @@ 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. - * - * **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. + * 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 + * 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 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 +429,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 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 @@ -461,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.', ),