Skip to content

Commit 2bf7801

Browse files
committed
docs(spec): state the RLS using grammar by what pushes down, not by a count (#6919)
The TSDoc block above `RowLevelSecurityPolicySchema`'s `using` property still claimed "Exactly four forms compile" and "no support for ... comparison operators other than `=`". That contradicted the `.describe()` on the SAME property (corrected in #6762 / PR #6918) and it contradicted the compiler. Measured against `isSupportedRlsExpression` (`@objectstack/formula`, `src/rls-predicate.ts`) at 07383fe: `!=` and the full ordering comparisons, `in` over a `current_user.*` array AND over an inline CEL list literal, string `startsWith`/`endsWith`/`contains`, `&&`, `||`, parenthesised grouping, and a bare `true` all lower to a filter and enforce. SQL `AND`/`OR`/`NOT`, `NOT IN`, `IS NULL`, `LIKE`, regex, arithmetic, subqueries, cross-object traversal and a bare truthy field all fail closed. Rewritten in terms of the forms that push down, with no count anywhere — replacing "four" with the current number would be the same defect. Canonical CEL leads; the SQL spelling is stated as the deprecated transitional bridge it is (`sqlPredicateToCel`, ADR-0058 D1), covering only `=` -> `==` and `IN` -> `in`. The `⚠️ STALE` marker PR #6918 parked on the block is removed with the rewrite. The property's five `@example` strings, all SQL dialect, are now CEL. Two boundaries the old prose got wrong in the permissive direction are now explicit, both silent fail-closed traps: SQL's parenthesised value list does not survive the bridge (`status IN ('draft', 'pending')` fails closed, where `status in ['draft', 'pending']` lowers), and `!` negates a parenthesised comparison but cannot negate a bare field. Adds `rls-predicate-grammar-docs.pin.test.ts`: the file states this grammar on three faces — the published module docblock line, the property TSDoc, and the property's `.describe()` — and nothing compared them, which is how the same under-statement drifted twice. The pin holds all three to one story: no face may re-assert a fixed-count or closed-set grammar, all must keep stating the fail-closed contract, and the two operator-listing faces must name the same operator set. Scope is the claim shape, not the wording. No generated output changes: `gen:docs` never renders property-level TSDoc, so `check:docs` reports all 231 files still in sync. Fixes #6919 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XdGuLGuJaDfQidXjbU7wJ
1 parent 07383fe commit 2bf7801

3 files changed

Lines changed: 306 additions & 32 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): describe the RLS `using` grammar by what pushes down, not by a count (#6919)
6+
7+
The TSDoc block above `RowLevelSecurityPolicySchema`'s `using` property still
8+
opened with "The reference RLS compiler implements a deliberately **small,
9+
fixed grammar****Exactly four forms compile**", then enumerated four SQL
10+
spellings and declared "there is intentionally **no** support for `AND`/`OR`/
11+
`NOT`, comparison operators other than `=`". That contradicted the
12+
`.describe()` on the *same property* — corrected in #6762 / PR #6918 — and it
13+
contradicted the compiler. Measured against `isSupportedRlsExpression`
14+
(`@objectstack/formula`, `src/rls-predicate.ts`): `!=` and the full ordering
15+
comparisons, `in` over a `current_user.*` array **and** over an inline CEL list,
16+
string `startsWith`/`endsWith`/`contains`, `&&`, `||`, parenthesised grouping
17+
and a bare `true` all lower to a filter and genuinely enforce.
18+
19+
PR #6918 could only park a `⚠️ STALE` marker on the block, because rewriting
20+
~60 lines of grammar prose deserved its own review. This is that rewrite; the
21+
marker is gone with it.
22+
23+
The block is now written as the one question the compiler actually asks —
24+
*does this predicate lower to an ObjectQL filter?* — with the forms that lower
25+
listed as open categories rather than a numbered set, and the forms that fail
26+
closed listed beside them. Replacing "four" with the current number would have
27+
been the same defect, so no count appears. Canonical CEL leads; the SQL
28+
spelling is presented as what it is, a deprecated transitional bridge
29+
(`sqlPredicateToCel`, ADR-0058 D1) covering only `=``==` and `IN``in`.
30+
The property's five `@example` strings, all SQL dialect, are now CEL.
31+
32+
Two boundaries the old text got wrong in the *permissive* direction are stated
33+
explicitly, because both are silent-fail-closed traps: SQL's parenthesised
34+
value list does not survive the bridge (`status IN ('draft', 'pending')` fails
35+
closed where `status in ['draft', 'pending']` lowers), and `!` negates a
36+
parenthesised comparison but cannot negate a bare field.
37+
38+
Also adds `rls-predicate-grammar-docs.pin.test.ts`, which holds the file's
39+
three grammar faces — the published module docblock line, the property TSDoc,
40+
and the property's `.describe()` — to one story: none may re-assert a
41+
fixed-count or closed-set grammar, all must keep stating the fail-closed
42+
contract, and the two operator-listing faces must name the same operators.
43+
This grammar has now drifted twice in the same direction, and nothing compared
44+
the faces to each other.
45+
46+
No generated output changes: `gen:docs` never renders property-level TSDoc, so
47+
`check:docs` reports all 231 files still in sync.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6919] The RLS predicate grammar is stated on THREE faces of
5+
* `rls.zod.ts`, and they must not drift apart again.
6+
*
7+
* The three:
8+
*
9+
* 1. **module docblock** — the `ObjectStack RLS:` bullet list. `build-docs.ts`
10+
* publishes the module block verbatim as the opening prose of
11+
* `content/docs/references/security/rls.mdx`, so this face is READ BY USERS.
12+
* 2. **property docblock** — the TSDoc block above `using`. No generator reads
13+
* property-level TSDoc, so this face is read only by whoever opens the file
14+
* (often an AI author, ADR-0033) — which is exactly why it rotted unnoticed.
15+
* 3. **`.describe()` on `using`** — also published (it renders into the same
16+
* page's property table).
17+
*
18+
* They have now drifted twice. Both times the same way: a face froze a
19+
* *snapshot* of the compiler as a **closed enumeration with a count**
20+
* ("Exactly four forms compile"; "equality, set-membership, always-true"),
21+
* the compiler grew, and the prose stayed. #6762 / PR #6918 fixed faces 1 and
22+
* 3; #6919 rewrote face 2 and deleted the interim `⚠️ STALE` marker PR #6918
23+
* had parked on it. Nothing compared the three, so face 2 contradicted face 3
24+
* — on the same property — across two majors.
25+
*
26+
* ⛔ Scope: **the claim shape, not the wording.** Rephrasing a sentence,
27+
* reordering the bullets, or adding a newly-supported form is free. Reverting
28+
* any face to a fixed-count / closed-set claim, dropping an operator that
29+
* enforces, or dropping the fail-closed statement is not.
30+
*
31+
* Why a source-text pin is the right instrument here, given #6987's warning
32+
* that source-scanning pins nail the wrong number when the pinned fact lives
33+
* outside the source: the fact pinned here IS text — three prose faces of one
34+
* file agreeing with each other. Reading the source is not a proxy for the
35+
* fact, it is the fact. (The behavioural half — which predicates actually
36+
* lower — is owned by `isSupportedRlsExpression`'s own tests in
37+
* `@objectstack/formula`; this file must not restate it, and deliberately does
38+
* not import a runtime package.)
39+
*/
40+
41+
import fs from 'node:fs';
42+
import path from 'node:path';
43+
import url from 'node:url';
44+
45+
import { describe, it, expect } from 'vitest';
46+
47+
import { RowLevelSecurityPolicySchema } from './rls.zod';
48+
49+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
50+
const SOURCE = path.resolve(HERE, 'rls.zod.ts');
51+
52+
const source = fs.readFileSync(SOURCE, 'utf8');
53+
54+
/**
55+
* Face 1 — the `ObjectStack RLS:` bullets of the module docblock.
56+
*
57+
* Read out of the raw source rather than out of the generated `.mdx`: the
58+
* source is what a reader of the file sees AND what the generator copies, so
59+
* one read covers both surfaces and it cannot go green because a regen was
60+
* forgotten.
61+
*/
62+
function moduleFace(): string {
63+
const blockEnd = source.indexOf('*/');
64+
const block = source.slice(0, blockEnd);
65+
const anchor = block.indexOf('ObjectStack RLS:');
66+
expect(anchor, '`ObjectStack RLS:` heading not found in the module docblock').toBeGreaterThan(-1);
67+
68+
// Collect the run of `* - ` bullets that follows, stopping when it ends.
69+
const lines: string[] = [];
70+
for (const line of block.slice(anchor).split('\n')) {
71+
if (/^\s*\*\s*-\s+\S/.test(line)) { lines.push(line); continue; }
72+
if (lines.length > 0) break;
73+
}
74+
return lines.join('\n');
75+
}
76+
77+
/** Face 2 — the TSDoc block immediately above the `using` property. */
78+
function propertyFace(): string {
79+
const decl = source.indexOf('\n using: z.string()');
80+
expect(decl, '`using: z.string()` declaration not found').toBeGreaterThan(-1);
81+
const end = source.lastIndexOf('*/', decl);
82+
const start = source.lastIndexOf('/**', end);
83+
expect(start, 'no TSDoc block found above `using`').toBeGreaterThan(-1);
84+
return source.slice(start, end + 2);
85+
}
86+
87+
/** Face 3 — the `.describe()` carried by the `using` property. */
88+
function describeFace(): string {
89+
const shape = (RowLevelSecurityPolicySchema as unknown as { shape: Record<string, { description?: string }> }).shape;
90+
return shape.using?.description ?? '';
91+
}
92+
93+
/**
94+
* Every operator the reference compiler lowers, spelled the way all three
95+
* faces spell it (backticked, canonical CEL). Adding a row here when the
96+
* compiler grows is the intended maintenance: it turns "the docs are stale"
97+
* from something nobody notices into a red test.
98+
*/
99+
const ENFORCING_OPERATORS = ['`==`', '`!=`', '`<`', '`<=`', '`>`', '`>=`', '`in`', '`&&`', '`||`'] as const;
100+
101+
/** A count attached to the accepted set — the exact defect that recurred. */
102+
const FIXED_COUNT_CLAIM =
103+
/\b(?:exactly|precisely|only|just)\s+(?:\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:forms?|shapes?|expressions?|predicates?)\b/i;
104+
105+
/** The same defect spelled without the adverb ("four forms compile"). */
106+
const BARE_COUNT_CLAIM =
107+
/\b(?:\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:forms?|shapes?)\s+(?:compile|lower|are\s+supported)\b/i;
108+
109+
const FACES: ReadonlyArray<readonly [string, string]> = [
110+
['module docblock', moduleFace()],
111+
['property docblock', propertyFace()],
112+
['.describe()', describeFace()],
113+
];
114+
115+
describe('[#6919] rls.zod.ts states one predicate grammar on all three faces', () => {
116+
it('finds all three faces at all (anti-vacuity)', () => {
117+
// Every assertion below is a search over a string. An empty haystack would
118+
// make the negative ones pass forever the day someone moves a block.
119+
for (const [name, text] of FACES) {
120+
expect(text.length, `${name} face came back empty — the extractor no longer finds it`)
121+
.toBeGreaterThan(200);
122+
}
123+
expect(propertyFace()).toContain('Supported expression grammar');
124+
});
125+
126+
it.each(FACES.map(([name, text]) => ({ name, text })))(
127+
'the $name face states no fixed count of accepted forms',
128+
({ name, text }) => {
129+
// ⛔ #6919: replacing "four" with the current number is the SAME defect —
130+
// the grammar is "whatever lowers to a filter", not a numbered list.
131+
expect(FIXED_COUNT_CLAIM.test(text), `${name} re-introduced a counted accepted set`).toBe(false);
132+
expect(BARE_COUNT_CLAIM.test(text), `${name} re-introduced a counted accepted set`).toBe(false);
133+
},
134+
);
135+
136+
it.each(FACES.map(([name, text]) => ({ name, text })))(
137+
'the $name face does not re-assert a retracted under-statement',
138+
({ name, text }) => {
139+
// The two literal sentences #6762 / #6918 / #6919 removed.
140+
expect(text, `${name} re-asserts that only \`=\` compares`).not.toMatch(/comparison\s+operators?\s+other\s+than/i);
141+
expect(text, `${name} re-asserts the closed three-item grammar`)
142+
.not.toMatch(/equality,\s*set-membership,\s*always-true/i);
143+
},
144+
);
145+
146+
it.each(FACES.map(([name, text]) => ({ name, text })))(
147+
'the $name face still says the compiler fails closed',
148+
({ name, text }) => {
149+
// The one safety-relevant sentence. A face that drops it turns a
150+
// "matches zero rows" contract into an unstated one.
151+
expect(text, `${name} no longer states the fail-closed contract`).toMatch(/fails?\s+closed/i);
152+
},
153+
);
154+
155+
it('the property docblock and `.describe()` name the same operator set', () => {
156+
// The pair that literally contradicted each other on one property (#6919).
157+
const property = propertyFace();
158+
const described = describeFace();
159+
for (const op of ENFORCING_OPERATORS) {
160+
expect(property, `property docblock stopped naming ${op}`).toContain(op);
161+
expect(described, `.describe() stopped naming ${op}`).toContain(op);
162+
}
163+
// The allow-all is a literal, not an operator, but it is the form most
164+
// often dropped when someone "tidies" the list.
165+
expect(property).toContain('`true`');
166+
expect(described).toContain('`true`');
167+
});
168+
169+
it('the module face describes an open grammar, not a closed list', () => {
170+
// This face is ONE published line, so it cannot enumerate operators. What
171+
// it must not do is name a finite set of categories again: it has to carry
172+
// the composition operators, which are what a closed "equality /
173+
// set-membership / always-true" list always omits.
174+
const module = moduleFace();
175+
expect(module).toContain('`&&`');
176+
expect(module).toContain('`||`');
177+
expect(module).toMatch(/comparisons/i);
178+
expect(module).toMatch(/set-membership/i);
179+
});
180+
181+
it('carries no `STALE` marker on any face', () => {
182+
// PR #6918 parked a `⚠️ STALE` marker on the property block as an interim
183+
// measure and #6919 removed it with the rewrite. A marker coming back is a
184+
// signal that the faces disagree again — which is what this pin is for.
185+
for (const [name, text] of FACES) {
186+
expect(text, `${name} carries a STALE marker again`).not.toMatch(/\bSTALE\b/);
187+
}
188+
});
189+
190+
it('presents CEL as the canonical spelling, SQL as the deprecated bridge', () => {
191+
// ADR-0058 D1. `sqlPredicateToCel` is `@deprecated`; a face that leads with
192+
// SQL sends an author to the dialect we are migrating off.
193+
expect(propertyFace()).toMatch(/canonical CEL/);
194+
expect(describeFace()).toMatch(/canonical CEL/);
195+
expect(moduleFace()).toMatch(/CEL/);
196+
});
197+
});

packages/spec/src/security/rls.zod.ts

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -294,44 +294,74 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject(
294294

295295
/**
296296
* USING clause - Filter condition for SELECT/UPDATE/DELETE.
297-
*
298-
* This is a constrained, SQL-like expression compiled into an ObjectQL
297+
*
298+
* A constrained CEL predicate (ADR-0058 D1) compiled into an ObjectQL
299299
* filter (see the supported grammar below). Only rows the compiled filter
300300
* matches are accessible.
301301
*
302302
* **Note**: For INSERT-only policies, USING is not required (only CHECK is needed).
303303
* For SELECT/UPDATE/DELETE operations, USING is required.
304304
*
305-
* **Security Note**: the compiler maps each form to a structured filter and
306-
* binds context values as parameters at the driver layer — context values
307-
* are never string-concatenated into SQL. Policy `using` strings are
308-
* authored by administrators, not end users.
305+
* **Security Note**: the compiler lowers each predicate to a structured
306+
* filter and binds context values as parameters at the driver layer —
307+
* context values are never string-concatenated into SQL. Policy `using`
308+
* strings are authored by administrators, not end users.
309309
*
310310
* **Supported expression grammar (reference compiler)**
311311
*
312-
* ⚠️ **STALE — the enumeration below UNDER-states what compiles (#6919).**
313-
* The `.describe()` on this property carries the current truth: `!=`, the
314-
* ordering comparisons, `in` over an inline literal list, `&&`, `||` and a
315-
* bare `true` all lower today. Rewriting this block is tracked in #6919; do
316-
* not read the four-item list as the accepted set.
312+
* There is no blessed list of forms to memorise here, and no count to
313+
* quote: the grammar is defined by ONE question — *does the predicate lower
314+
* to an ObjectQL filter?* `isSupportedRlsExpression`
315+
* (`@objectstack/formula`, `src/rls-predicate.ts`) is that single decision
316+
* procedure, and `@objectstack/lint` calls it at authoring time (ADR-0056
317+
* D4) so a predicate that would never enforce is rejected instead of
318+
* silently dropped. Anything that does not lower **fails closed** — the
319+
* policy matches zero rows, never more.
317320
*
318-
* The reference RLS compiler implements a deliberately **small, fixed
319-
* grammar** rather than a general SQL parser. Exactly four forms compile;
320-
* anything else fails closed (the policy matches zero rows). Keep `using`
321-
* to one of:
321+
* What lowers, written in canonical CEL:
322322
*
323-
* 1. `field = current_user.<prop>` — equality against a context value
324-
* 2. `field = 'literal'` — equality against a single-quoted string literal
325-
* 3. `field IN (current_user.<array_prop>)` — set membership against a
326-
* pre-resolved id array (see "Dynamic membership" below)
327-
* 4. `1 = 1` — always true / no restriction (privileged-position allow-all)
323+
* - **Comparison** of a field against a literal or a `current_user.*`
324+
* context value with `==`, `!=`, `<`, `<=`, `>` or `>=` —
325+
* `owner_id == current_user.id`, `amount > 100`, `status != 'draft'`.
326+
* Either operand may be the field; `current_user.id == owner_id` lowers
327+
* to the same filter.
328+
* - **Set membership** with `in`, against a pre-resolved `current_user.*`
329+
* array (see "Dynamic membership" below) or an inline CEL list literal —
330+
* `assigned_to_id in current_user.team_member_ids`,
331+
* `status in ['draft', 'pending']`.
332+
* - **String prefix / suffix / substring** tests —
333+
* `name.startsWith('AC')`, `name.endsWith('_archived')`,
334+
* `name.contains('demo')`.
335+
* - **Composition** of the above with `&&`, `||` and parentheses —
336+
* `organization_id == current_user.organization_id && status == 'published'`.
337+
* `!` negates a *parenthesised comparison* (`!(status == 'draft')`); it
338+
* cannot negate a bare field, because a bare field does not lower on its
339+
* own.
340+
* - **Allow-all**: the bare literal `true` (the privileged-position escape
341+
* hatch). `1 == 1` lowers as an ordinary comparison and means the same.
342+
* There is no bare-`false` deny-all — to make a policy inert, set
343+
* `enabled: false`.
328344
*
329-
* There is intentionally **no** support for `AND`/`OR`/`NOT`, comparison
330-
* operators other than `=`, `IS NULL`/`IS NOT NULL`, `NOT IN`, `LIKE`/
331-
* `ILIKE`, regex (`~`/`!~`), `ANY`/`ALL`, subqueries, or `NOW()`/
332-
* `CURRENT_DATE`/`CURRENT_TIME`. Combine conditions by defining multiple
333-
* policies (they OR-combine); express anything subquery-shaped as a
334-
* pre-resolved `current_user.*` array instead.
345+
* What does **not** lower, and therefore fails closed: SQL `AND` / `OR` /
346+
* `NOT`, `NOT IN`, `IS NULL` / `IS NOT NULL`, `LIKE` / `ILIKE`, regex
347+
* (`~` / `!~`), `ANY` / `ALL`, arithmetic (`amount + 1 > 2`), subqueries,
348+
* `NOW()` / `CURRENT_DATE` / `CURRENT_TIME`, traversal across objects
349+
* (`account.owner.id == current_user.id`), and a bare truthy field
350+
* (`is_active`). Combine conditions with `&&` / `||`, or by defining
351+
* multiple policies (they OR-combine); express anything subquery-shaped as
352+
* a pre-resolved `current_user.*` array instead.
353+
*
354+
* **SQL spelling is a transitional bridge, not a second dialect.** Stored
355+
* legacy predicates keep compiling because `sqlPredicateToCel`
356+
* (`@deprecated` under ADR-0058 D1) rewrites `=` to `==` and `IN` to `in`
357+
* before the one compiler sees them, so `owner_id = current_user.id`,
358+
* `status = 'published'` and `assigned_to_id IN (current_user.team_member_ids)`
359+
* still enforce. Only that subset is bridged. In particular SQL's
360+
* parenthesised value list does **not** survive the bridge —
361+
* `status IN ('draft', 'pending')` fails closed, where the CEL list
362+
* `status in ['draft', 'pending']` lowers — and SQL keywords outside the
363+
* subset (`AND`, `OR`, `NOT IN`, `IS NULL`, `LIKE`) are never rewritten.
364+
* Author new policies in CEL.
335365
*
336366
* **Context values** — `current_user.*` resolves against the request's
337367
* execution context (camelCase fields map to snake_case placeholders):
@@ -348,16 +378,16 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject(
348378
* need a subquery ("tasks assigned to anyone I manage", "accounts in my
349379
* territories") is resolved by the runtime into
350380
* `ExecutionContext.rlsMembership` under a stable key, then referenced as
351-
* `field IN (current_user.<key>)`. This keeps the compiler subquery-free
381+
* `field in current_user.<key>`. This keeps the compiler subquery-free
352382
* while still supporting hierarchy- and sharing-based access.
353383
*
354384
* **Prohibited**: Dynamic SQL, DDL statements, DML statements (INSERT/UPDATE/DELETE)
355385
*
356-
* @example "organization_id = current_user.organization_id"
357-
* @example "owner_id = current_user.id"
358-
* @example "status = 'published'"
359-
* @example "assigned_to_id IN (current_user.team_member_ids)" // §7.3.1 pre-resolved
360-
* @example "1 = 1" // privileged-position allow-all
386+
* @example "organization_id == current_user.organization_id"
387+
* @example "owner_id == current_user.id"
388+
* @example "status == 'published'"
389+
* @example "assigned_to_id in current_user.team_member_ids" // §7.3.1 pre-resolved
390+
* @example "true" // privileged-position allow-all
361391
*/
362392
using: z.string()
363393
.optional()

0 commit comments

Comments
 (0)