Skip to content

Commit d03fe25

Browse files
docs(spec): 补齐 preserveAudit 的 .describe(),并按实测形态重写 RLS using 的描述 (#6881, #6762) (#6918)
* docs(spec): 补齐 preserveAudit 的 .describe(),并按实测形态重写 RLS using 的描述 (#6881, #6762) 两条同类的 spec-surface sweep:已发布的 `.describe()` 说明不足。属性上方的 TSDoc 块不进生成器,只有 `.describe()` 会进 `content/docs/references/**`, 所以这两处直接决定参考文档里那一格渲染出什么。纯文本改动 —— 没有 key、 类型、枚举成员、refinement 或默认值变动,acceptance surface 逐字节不变 (authorable-surface/、json-schema.manifest/、authorable-defaults/ 三者 gen:schema 后零 diff)。 #6881 —— ExecutionContextSchema.preserveAudit 此前是裸声明,语义只写在上方块注释里,于是 references/kernel/ execution-context.mdx:69 的描述列渲染为空。现在补上描述,并按 #6640 收窄 后的契约措辞,与已合入的两处(FieldSchema.readonly、protocol/objectql/ security.mdx 的 callout,均出自 PR #6823)保持同一口径:豁免仅在 UPDATE 路径成立;INSERT 侧在 DataProtocol ingress 更早剥离,只认 context.isSystem, 非 system 的 create 即便携带 preserveAudit 仍被剥离并记 WARN。 #6762 —— RowLevelSecurityPolicySchema.using 此前宣称「四种编译器支持的形式之一」且四种拼写全是 SQL 方言,两个方向都不准: 实测 isSupportedRlsExpression 之下 `!=`/`<`/`<=`/`>`/`>=`、对内联字面量列表 的 `in`、`&&`、`||` 与裸 `true` 都真正生效(比宣称的宽);而 ADR-0058 D1 定 CEL 为规范方言、sqlPredicateToCel 标 @deprecated(指向正在退役的方言)。 改为按能被下推的形态描述 —— 不重新数一个固定数目,换一个同样错的计数是同一 个缺陷 —— 并把 SQL 拼写降格为过渡桥接(`=`→`==`、`IN`→`in` 仍接受;SQL 的 AND/OR/NOT IN/IS NULL/LIKE 不在桥接内,fail-closed)。 两处各加了一条读回 description 的 pin,按 idiom 而非逐字断言(改写自由, 丢失实质不自由),并各带一条 anti-vacuity 非空断言。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk * docs(spec): 给 rls.zod.ts `using` 上方那段过时语法规范打 STALE 标记,指向 #6919 PR #6918 修好了 `using` 的 `.describe()`,但同属性上方 45 行处的 TSDoc 块 (`:313`「Exactly four forms compile」)仍是旧说法。改之前两处一致地错,改 之后两处互相矛盾 —— 而读源码的人(常常是 AI,ADR-0033)先撞上的是那段更长、 更像权威的语法规范,没有任何信号提示下方那一行 `.describe()` 才是当前事实。 这里只加标记,不重写:重写那 60 行语法规范散文(连同 5 条 SQL 方言的 `@example`)是 #6919 自己的卡,塞进这张两条目 sweep 卡会破坏其 per-item 复核模型。标记把「两条互相矛盾的断言」降级为「一条断言 + 一句诚实警告」, 直到真正的修复落地。 该块不进生成器(gen:docs 只渲染 `.describe()` 与模块级 docblock),已实测 确认:加这段标记后 content/docs/references/** 零 diff。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk * docs(spec): rls 模块 docblock 的语法一行同样按实测形态重述(已发布面) PM 复核裁定:该行与属性级 TSDoc 性质不同,应并入本 PR。 `packages/spec/src/security/rls.zod.ts:79` 的 「A small, fixed expression grammar (equality, set-membership, always-true)」 是**模块级** docblock,会逐字渲染到 content/docs/references/security/rls.mdx:79 —— 与属性级 TSDoc(不进生成器,留给 #6919)不同,它是已发布面。 不改的话,本 PR 合入后同一张渲染页会自相矛盾:`:79` 说三项,`:174` 是按实测 改正的 using 行,而读者先撞上 `:79`。 改法与 `.describe()` 同一口径:讲能被下推的形态、canonical CEL,⛔ 不换一个 新的固定计数(「三」换「八」是同一个缺陷)。实测依据:比较算子全套、对 `current_user.*` 数组与内联字面量列表的 `in`、`&&`/`||` 均真正下推;其余 fail-closed。 现在 rls.mdx 相对 origin/main 恰好两行变化,且页内无残留旧说法。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44d677c commit d03fe25

7 files changed

Lines changed: 157 additions & 6 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): 修正两处 `.describe()` —— `ExecutionContext.preserveAudit` 补上描述,RLS `using` 改按编译器实际下推的形态描述 (#6881, #6762)
6+
7+
两处同类问题:已发布的 `.describe()` 说明不足。`.describe()` 是唯一进入生成参考文档的属性级文本(属性上方的 TSDoc 块不进生成器),所以这两处直接决定
8+
`content/docs/references/**` 里那一格渲染出什么。仅文本改动 —— 没有任何 key、类型、枚举成员、refinement 或默认值变动,acceptance surface 逐字节不变。
9+
10+
**`ExecutionContextSchema.preserveAudit`#6881** 此前是裸声明 `z.boolean().optional()`,语义只写在上方块注释里,于是
11+
`references/kernel/execution-context.mdx` 的描述列渲染为****(同族的 `DriverOptions.preserveAudit``.describe()`,渲染正常)。
12+
现在补上描述,并按 #6640 收窄后的契约措辞,与已合入的两处(`FieldSchema.readonly``protocol/objectql/security.mdx` 的 callout,均出自 PR #6823
13+
保持同一口径:豁免**仅在 UPDATE 路径成立**;INSERT 侧在 DataProtocol ingress 更早剥离,只认 `context.isSystem`,非 system 的 create 请求即便携带
14+
`preserveAudit`,字段仍被剥离并记 WARN。
15+
16+
**`RowLevelSecurityPolicySchema.using`#6762** 此前宣称「四种编译器支持的形式之一」,且四种拼写全是 SQL 方言。两个方向都不准:
17+
18+
- **实测比宣称的宽。** `isSupportedRlsExpression` 之下,`!=``<``<=``>``>=`、对内联字面量列表的 `in``&&``||` 以及裸 `true` 都会真正生效。
19+
- **它把作者导向正在退役的方言。** ADR-0058 D1 定 CEL 为规范方言,`sqlPredicateToCel` 标记 `@deprecated`
20+
21+
改为按**能被下推的形态**描述(而非重新数一个固定数目 —— 换一个同样错的计数是同一个缺陷),并把 SQL 拼写降格为过渡桥接:`=``==``IN``in` 仍被接受,
22+
而 SQL 的 `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` 不在桥接范围内、fail-closed。

content/docs/references/kernel/execution-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const result = ExecutionContextSchema.parse(data);
6666
| **skipAutomations** | `boolean` | optional | |
6767
| **seedReplay** | `boolean` | optional | |
6868
| **skipStateMachine** | `boolean` | optional | |
69-
| **preserveAudit** | `boolean` | optional | |
69+
| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. |
7070
| **oauthScopes** | `string[]` | optional | |
7171
| **accessToken** | `string` | optional | |
7272
| **transaction** | `any` | optional | |

content/docs/references/security/rls.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Salesforce:
7676
- Manual Sharing: Individual record sharing
7777

7878
ObjectStack RLS:
79-
- A small, fixed expression grammar (equality, set-membership, always-true)
79+
- 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
8080
- Subquery-shaped needs are pre-resolved by the runtime (§7.3.1)
8181
- Multiple policies OR-combine for union (any-match-allows) semantics
8282

@@ -171,7 +171,7 @@ const result = RLSEvaluationResultSchema.parse(data);
171171
| **description** | `string` | optional | Policy description and business justification |
172172
| **object** | `string` || Target object name |
173173
| **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` || Database operation this policy applies to |
174-
| **using** | `string` | optional | Filter condition for SELECT/UPDATE/DELETE. One of the four compiler-supported forms: `field = current_user.<prop>`, `field = 'literal'`, `field IN (current_user.<array>)`, or `1 = 1`. Optional for INSERT-only policies. |
174+
| **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. |
175175
| **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) |
176176
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
177177
| **enabled** | `boolean` || Whether this policy is active |

packages/spec/src/kernel/execution-context.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,62 @@ describe('ExecutionContextSchema', () => {
5151
expect(ctx.transaction).toBeDefined();
5252
});
5353
});
54+
55+
// ---------------------------------------------------------------------------
56+
// #6881 — `preserveAudit` must carry its contract in the `.describe()`, not
57+
// only in the block comment above the key.
58+
//
59+
// Why the block comment is not enough: `gen:docs` renders a property's
60+
// `.describe()` (and the module docblock) and NEVER its TSDoc, so with the key
61+
// declared bare the generated row in
62+
// `content/docs/references/kernel/execution-context.mdx` rendered an EMPTY
63+
// description cell while the sibling `DriverOptions.preserveAudit`
64+
// (`data/driver.zod.ts`) rendered fine.
65+
//
66+
// The wording is held to the post-#6640 NARROWED contract, whose two already
67+
// landed statements this description is aligned with (PR #6823):
68+
// - `packages/spec/src/data/field.zod.ts` — `FieldSchema.readonly`
69+
// - `content/docs/protocol/objectql/security.mdx` — the UPDATE-only callout
70+
//
71+
// Asserted by IDIOM, not by sentence: a rewrite stays free, dropping the
72+
// substance does not. The first case is the anti-vacuity arm — every other
73+
// assertion here would pass vacuously against `.describe('')` if the string
74+
// were emptied, so the non-empty check is what makes this pin fail on a blank
75+
// cell rather than only on changed wording.
76+
// ---------------------------------------------------------------------------
77+
describe('ExecutionContextSchema.preserveAudit — the published description (#6881)', () => {
78+
const description = ExecutionContextSchema.shape.preserveAudit.description ?? '';
79+
80+
it('is present and non-empty, so the generated reference row is not blank', () => {
81+
expect(description).not.toBe('');
82+
expect(description.trim().length).toBeGreaterThan(0);
83+
});
84+
85+
it('names both write paths, so the exemption cannot read as unconditional', () => {
86+
expect(description).toMatch(/\bUPDATE\b/);
87+
expect(description).toMatch(/\bINSERT\b/);
88+
});
89+
90+
it('states that the exemption does NOT reach INSERT, anchored to #6640', () => {
91+
expect(description).toMatch(/#6640/);
92+
expect(description).toMatch(/\bnot\b/i);
93+
});
94+
95+
it('names `context.isSystem` as the create-side exemption', () => {
96+
expect(description).toMatch(/isSystem/);
97+
});
98+
99+
it('states that a non-system create is warned rather than silently obeyed', () => {
100+
expect(description).toMatch(/warn/i);
101+
});
102+
103+
it('states the opt-in, server-constructed provenance', () => {
104+
expect(description).toMatch(/opt-in/i);
105+
expect(description).toMatch(/server-constructed|never client-supplied/i);
106+
});
107+
108+
it('names the whitelist it admits on the UPDATE path', () => {
109+
expect(description).toMatch(/readonly/i);
110+
expect(description).toMatch(/audit|updated_at/i);
111+
});
112+
});

packages/spec/src/kernel/execution-context.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export const ExecutionContextSchema = lazySchema(() => z.object({
353353
* field-level security are unaffected: this changes only which audit/readonly
354354
* values the runtime overwrites, never who may write the record.
355355
*/
356-
preserveAudit: z.boolean().optional(),
356+
preserveAudit: z.boolean().optional().describe('Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected.'),
357357

358358
/**
359359
* OAuth 2.1 scopes granted to the access token that authenticated this

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,67 @@ describe('unknown keys are rejected, not stripped (#4001)', () => {
543543
expect(messages).toContain('Delete the key');
544544
});
545545
});
546+
547+
// ---------------------------------------------------------------------------
548+
// #6762 — the published `using` description must describe what the compiler
549+
// actually lowers, in the canonical dialect.
550+
//
551+
// It previously advertised "one of the four compiler-supported forms" and
552+
// spelled all four in the SQL-ish dialect. Both halves were wrong in the same
553+
// direction — it UNDER-promised, and it steered authors at the dialect being
554+
// retired:
555+
//
556+
// - `isSupportedRlsExpression` (`@objectstack/formula/src/rls-predicate.ts`)
557+
// is broader than four forms. Measured against the gate on this branch,
558+
// `!=`, `<`, `<=`, `>`, `>=`, `in` over an inline literal list, `&&`, `||`
559+
// and the bare `true` all ENFORCE — `rls-predicate.test.ts` already pins
560+
// that behaviour, so this file pins only that the PROSE agrees with it.
561+
// - ADR-0058 D1 makes CEL canonical and marks `sqlPredicateToCel`
562+
// `@deprecated`, so the SQL spellings are the transitional bridge, not the
563+
// definition.
564+
//
565+
// Deliberately NOT asserted: a count. Replacing an under-promising "four" with
566+
// a differently-wrong number is the same defect, so the pin holds the SHAPE
567+
// (which operators are advertised) and forbids the closed count instead.
568+
//
569+
// Asserted by IDIOM, not by sentence. The first case is the anti-vacuity arm:
570+
// the negative assertion below would pass vacuously against `.describe('')`,
571+
// so the non-empty check is what makes this pin fail on an emptied string
572+
// rather than only on changed wording.
573+
// ---------------------------------------------------------------------------
574+
describe('RowLevelSecurityPolicySchema.using — the published description (#6762)', () => {
575+
const description = RowLevelSecurityPolicySchema.shape.using.description ?? '';
576+
577+
it('is present and non-empty, so the generated reference row is not blank', () => {
578+
expect(description).not.toBe('');
579+
expect(description.trim().length).toBeGreaterThan(0);
580+
});
581+
582+
it('names CEL as the dialect the predicate is authored in', () => {
583+
expect(description).toMatch(/\bCEL\b/);
584+
});
585+
586+
it('advertises the comparison operators the compiler really lowers', () => {
587+
for (const operator of ['==', '!=', '<=', '>=']) {
588+
expect(description, `operator ${operator} must be advertised`).toContain(operator);
589+
}
590+
});
591+
592+
it('advertises membership and boolean combination', () => {
593+
expect(description).toMatch(/\bin\b/);
594+
expect(description).toMatch(/&&/);
595+
});
596+
597+
it('states the fail-closed verdict for anything that does not lower', () => {
598+
expect(description).toMatch(/fails? closed/i);
599+
});
600+
601+
it('frames the SQL spellings as the transitional bridge, not the definition', () => {
602+
expect(description).toMatch(/transitional|bridge|deprecated/i);
603+
});
604+
605+
it('does not re-close the set with a fixed count of forms', () => {
606+
expect(description).not.toMatch(/\bfour\b/i);
607+
expect(description).not.toMatch(/\b(one|two|three|four|five)\s+(compiler-supported\s+)?forms\b/i);
608+
});
609+
});

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import { strictObject } from '../shared/strict-object';
7676
* - Manual Sharing: Individual record sharing
7777
*
7878
* ObjectStack RLS:
79-
* - A small, fixed expression grammar (equality, set-membership, always-true)
79+
* - 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
8080
* - Subquery-shaped needs are pre-resolved by the runtime (§7.3.1)
8181
* - Multiple policies OR-combine for union (any-match-allows) semantics
8282
*
@@ -309,6 +309,12 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject(
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.
317+
*
312318
* The reference RLS compiler implements a deliberately **small, fixed
313319
* grammar** rather than a general SQL parser. Exactly four forms compile;
314320
* anything else fails closed (the policy matches zero rows). Keep `using`
@@ -355,7 +361,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject(
355361
*/
356362
using: z.string()
357363
.optional()
358-
.describe('Filter condition for SELECT/UPDATE/DELETE. One of the four compiler-supported forms: `field = current_user.<prop>`, `field = \'literal\'`, `field IN (current_user.<array>)`, or `1 = 1`. Optional for INSERT-only policies.'),
364+
.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.'),
359365

360366
/**
361367
* CHECK clause - Validation for INSERT/UPDATE operations.

0 commit comments

Comments
 (0)