Skip to content

Commit 82e6da1

Browse files
docs(spec): guard the nullable amount in the docs examples and the validation condition example (#31358)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X72ZKaHCwtzbWjtWFMnY9ec)_ Fixes #31334 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:七个文档页和 `ValidationRule.condition` 的 spec 示例里,对可空 `amount` 的比较补上 `record.amount != null && …` 守卫;`common-patterns` 的报销单把 `amount` 改为 `required: true`;用 `gen:docs` 重新生成 `references/data/validation.mdx`(只变 4 行)。 - 为什么改:发布期空值守卫规则会拒绝对可空字段做未守卫的大小比较或算术;照抄这些示例,要么发布被拒,要么 `requiredWhen` 静默失效(什么都不强制)。 - 风险与回滚:纯文档与 spec 描述文字,不改 schema 形状、不移动任何接受集;`@objectstack/spec` patch;回滚即 revert。 - 先看:`content/docs/getting-started/common-patterns.mdx`(唯一选了 `required: true` 而非守卫的一处);`packages/spec/src/data/validation.zod.ts` L180;`content/docs/data-modeling/formulas.mdx` L394 起的说明文字。 - 两分钟验证:`pnpm --filter @objectstack/spec run check:docs`,预期 exit 0(生成页与源一致);下方表格是每处表达式经真实规则 `validateStackExpressions` 的前后读数。 - 需要你做的:无,席位落地。 ## What changes The publish-time null-guard rule (`packages/lint/src/validate-null-guards.ts`, wired by `validateStackExpressions`, which `os build`, `os validate`, `os lint` and the runtime publish gate all run) refuses an ordering or arithmetic operator on a declared-nullable field unless a `!= null` / `== null` / `isBlank()` test guards it. Every site in the card compared `record.amount` unguarded in a gated position. Each is fixed with the shape the formula skill teaches (`skills/objectstack-formula/SKILL.md` L112-L122, L404). | Site | Surface | Fix | Why this fix | |:--|:--|:--|:--| | `content/docs/getting-started/common-patterns.mdx` L312, flow edges `e2`/`e3` | `record_change` flow on `expense_report` | `amount` declared `required: true` | The example declares its object, and the flow branches on `amount` (`< 100` auto-approves, `>= 100` asks). Guarding the start condition instead would let a submitted report with no amount launch no flow at all, so it would skip approval silently. An expense report always has an amount, so `required: true` is the honest fix. The edges stay as written. | | `content/docs/automation/approvals.mdx` L145, start `condition` | `record_change` flow start on `opportunity` | guard: `record.amount != null && record.amount >= 50000 && …` | The object is not declared. Per the ruling, a flow is guarded at its start condition, which covers every edge and branch (`validate-expressions.ts` L2967-L2976). | | `content/docs/automation/hook-bodies.mdx` L74 | hook `condition` | guard | The object is not declared. | | `content/docs/data-modeling/formulas.mdx` L119 | field `requiredWhen` | guard | The `Invoice` example declares fields, but not `amount` (nor `subtotal`, `tax_rate`, `invoice_no`, `customer`); it is a fragment for `amount`, so it is guarded rather than given a `required` declaration it does not have. | | `content/docs/data-modeling/formulas.mdx` L356 | field `requiredWhen` | guard | The object is not declared. | | `content/docs/data-modeling/formulas.mdx` L394 | validation rule `condition` | guard, plus one sentence under it saying why an optional field needs the `!= null` test | The object is not declared. | | `content/docs/data-modeling/validation.mdx` L329 | validation rule `condition` (a `validations:` fragment) | guard | The object is not declared. | | `content/docs/automation/flows.mdx` L2473 | flow start-node `condition` (syntax table) | guard | The object is not declared. | | `packages/spec/src/data/validation.zod.ts` L180 `.describe()` | validation `condition` | guard: ``P`record.amount != null && record.amount < 0` `` | The object is not declared. `content/docs/references/data/validation.mdx` L170/L291/L505/L571 regenerated with `pnpm --filter @objectstack/spec gen:schema && gen:docs`, never hand-edited. | | `packages/spec/src/data/validation.zod.ts` L257 and L532 (doc comments) | `cross_field` and `conditional` rule examples | guard | Bounded in-place fix, see below. | **Bounded in-place fix (same file, outside the card's table).** The two doc-comment examples in `validation.zod.ts` (the `cross_field` `discount_limit` at L257, and the `conditional` `enterprise_high_value` `then` at L532) have the same defect, and the rule refuses both (table below). All four conditions hold: the same defect class, the same mechanical guard shape, no other claim on the file (PR #31322 touches `hook.zod.ts`, not this file), and the same gate families. The doc comments ship in `@objectstack/spec`'s `.d.ts`, where an author or agent reads them. They do not reach the generated reference page. Not changed (out of scope by ruling): `validation.mdx` L51 (`amount` is `required: true` at L31), the `ui/` SchemaForm test constant, release notes and CHANGELOGs. ## Tests Each changed expression was run, in its declared context, through the real gating rule: `validateStackExpressions` from the built `@objectstack/lint` `dist/` (built on this branch's base `dc7f5c4b4`). The fixture object declares `amount` as `{ type: 'currency' }`, which is nullable (no `required`, no default), as on the CRM example's `opportunity`. Counts are error-severity null-guard findings; every row had 0 other errors. | Site | Before | After | |:--|:--|:--| | common-patterns flow `e2`/`e3` | REFUSED (2: edge `e2`, edge `e3`) | accepted (`amount` `required: true`) | | approvals.mdx start condition | REFUSED (1: start condition) | accepted | | hook-bodies.mdx hook condition | REFUSED (1) | accepted | | formulas.mdx L119 / L356 `requiredWhen` | REFUSED (1) | accepted | | formulas.mdx L394 / validation.mdx L329 script rule | REFUSED (1) | accepted | | flows.mdx L2473 start condition | REFUSED (1) | accepted | | validation.zod.ts L180 condition | REFUSED (1) | accepted | | validation.zod.ts L257 `cross_field` | REFUSED (2: `discount`, `amount`) | accepted | | validation.zod.ts L532 `conditional` `then` | REFUSED (1) | accepted | <details><summary>Probe output</summary> ```text REFUSED | null-guard errors 2 | other errors 0 | common-patterns.mdx flow e2/e3 (amount declared nullable) | flow 'expense_approval' · edge 'e2' (check_amount→auto_approve) condition; flow 'expense_approval' · edge 'e3' (check_amount→request_approval) condition accepted | null-guard errors 0 | other errors 0 | common-patterns.mdx flow e2/e3 (amount required: true) REFUSED | null-guard errors 1 | other errors 0 | approvals.mdx L145 start condition BEFORE | flow 'opportunity_approval' · node 'start' (start) condition accepted | null-guard errors 0 | other errors 0 | approvals.mdx L145 start condition AFTER REFUSED | null-guard errors 1 | other errors 0 | hook-bodies.mdx L74 hook condition BEFORE | hook 'h' (opportunity) condition accepted | null-guard errors 0 | other errors 0 | hook-bodies.mdx L74 hook condition AFTER REFUSED | null-guard errors 1 | other errors 0 | formulas.mdx L119/L356 requiredWhen BEFORE | object 'opportunity' · field 'po_number' requiredWhen accepted | null-guard errors 0 | other errors 0 | formulas.mdx L119/L356 requiredWhen AFTER REFUSED | null-guard errors 1 | other errors 0 | formulas.mdx L394 / validation.mdx L329 script rule BEFORE | object 'opportunity' · validation rule 'amount_positive' accepted | null-guard errors 0 | other errors 0 | formulas.mdx L394 / validation.mdx L329 script rule AFTER REFUSED | null-guard errors 1 | other errors 0 | flows.mdx L2473 start condition BEFORE | flow 'opportunity_approval' · node 'start' (start) condition accepted | null-guard errors 0 | other errors 0 | flows.mdx L2473 start condition AFTER REFUSED | null-guard errors 1 | other errors 0 | validation.zod.ts L180 condition BEFORE | object 'opportunity' · validation rule 'r' accepted | null-guard errors 0 | other errors 0 | validation.zod.ts L180 condition AFTER REFUSED | null-guard errors 2 | other errors 0 | validation.zod.ts L257 cross_field (jsdoc) BEFORE | object 'opportunity' · validation rule 'discount_limit'; object 'opportunity' · validation rule 'discount_limit' REFUSED | null-guard errors 1 | other errors 0 | validation.zod.ts L532 conditional then (jsdoc) BEFORE | object 'opportunity' · validation rule 'enterprise_high_value' then → 'amount_approval' accepted | null-guard errors 0 | other errors 0 | validation.zod.ts L257 cross_field (jsdoc) AFTER accepted | null-guard errors 0 | other errors 0 | validation.zod.ts L532 conditional then (jsdoc) AFTER ``` </details> No test file changes: this is docs and description text. No test pins the old describe string (`git grep` over `packages/**`). ## Acceptance notes - `packages/spec/src/automation/flow.zod.ts` L1070-L1071, the `FlowSchema` `@example`, has the same `record.amount < 500` / `>= 500` edges. As written its start node carries no `config.objectName`, so the rule has no object to resolve and does not refuse it (probe: 0 findings as written, 2 once the start is bound to an `order` with a nullable `amount`). Outside this card's surface and not refused at a public door today, so it is noted, not filed. - `skills/objectstack-data/rules/validation.md` L164 (`discount_limit`) is already guarded. ## Verification On head `c4bbb0ad` (merge base `dc7f5c4b4`), after building the `@objectstack/lint` closure, then `@objectstack/spec` and the `@objectstack/client-react` closure, under `NODE_OPTIONS=--max-old-space-size=8192`: **98 of the 99 commands from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` exit 0.** `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET: it needs every workspace package's `dist/`), so it is NOT MEASURED locally and CI reads it. `dispatch-gates --ran`: 99 derived, 98 run, 1 NOT-MEASURED, 0 UNRUN (exit 0). Changed lines: 35 + the changeset, 9 files. Also run: `packages/spec` `src/data/validation.test.ts` + `validation-boundary-description.test.ts` (2 files, 90 / 90 passed, lock VERDICT command-exit 0); eslint on `validation.zod.ts` (exit 0); `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` (exit 0); `pnpm check:skill-frame-freshness` (exit 0). `pnpm check:dev-prereqs` exits 1 on local state only (65 of 107 packages have no `dist/` in this worktree, since only the needed closures were built); its self-test passes, and it is not a finding about this diff. The NOT MEASURED families `dispatch-gates` names outside the derived list (CI-only steps, type-check lanes, the dogfood and build jobs) run in CI. <details><summary>Each command with its exit code (head c4bbb0a)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:adr-symbol-anchors :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kcxM8mwtPqSQpzbVT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4b4c8a commit 82e6da1

9 files changed

Lines changed: 31 additions & 16 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the `ValidationRule` `condition` example guards its nullable `amount` comparison with `record.amount != null`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The publish-time null-guard rule refuses an ordering or arithmetic operator on a declared-nullable field in a validation `condition` unless an explicit `!= null` test guards it. The `condition` `.describe()` text compared `record.amount` without one; it now reads `record.amount != null && record.amount < 0`, and the generated `content/docs/references/data/validation.mdx` rows follow. The two doc-comment examples in the same file (the `cross_field` discount limit and the `conditional` enterprise rule) get the same guard.
11+
12+
The spec change is description and doc-comment text only: no schema shape changes, and no accept set moves.

‎content/docs/automation/approvals.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export const opportunityApproval: Flow = {
142142
config: {
143143
triggerType: 'record-after-update',
144144
objectName: 'opportunity',
145-
condition: "record.amount >= 50000 && record.stage == 'proposal'",
145+
condition: "record.amount != null && record.amount >= 50000 && record.stage == 'proposal'",
146146
},
147147
},
148148
{

‎content/docs/automation/flows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2470,7 +2470,7 @@ placeholder such as `{today}` among them, which the query engine resolves.
24702470

24712471
| Where | Dialect | Write it like | Bindings |
24722472
|:---|:---|:---|:---|
2473-
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*`, `current_user` |
2473+
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount != null && record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*`, `current_user` |
24742474
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
24752475
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, `vars.*` and `current_user` |
24762476
| Field values and assignment values, as a **literal** | none — written as it is | `'open'`, `42`, `true`, `['a', 'b']` | — (a `{…}` template token is refused here since [#19939](https://github.com/objectstack-ai/objectstack/issues/19939), `{$User.*}` and the date macros `{NOW()}` / `{TODAY() ± N}` included: a date is `isoDate(today())` / `isoDatetime(now())` in an envelope) |

‎content/docs/automation/hook-bodies.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Pure formula. No IO, no mutation. This is the `body.language: 'expression'` shap
7171
{ "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" }
7272
```
7373

74-
Hook `condition` is a **separate field with a different envelope** — a bare CEL string, or `{ dialect: 'cel', source }` (`ExpressionInputSchema` in `packages/spec/src/shared/expression.zod.ts`), not a `{ language, source }` body. e.g. `condition: 'record.status == "open" && record.amount > 1000'`.
74+
Hook `condition` is a **separate field with a different envelope** — a bare CEL string, or `{ dialect: 'cel', source }` (`ExpressionInputSchema` in `packages/spec/src/shared/expression.zod.ts`), not a `{ language, source }` body. e.g. `condition: 'record.status == "open" && record.amount != null && record.amount > 1000'`.
7575

7676
Both forms are evaluated by the same formula engine that powers field formulas — see [Formula Reference](/docs/data-modeling/formulas).
7777

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export const Invoice = ObjectSchema.create({
116116
expression: F`record.subtotal + record.subtotal * record.tax_rate`,
117117
}),
118118
po_number: Field.text({
119-
requiredWhen: P`record.amount > 10000`,
119+
requiredWhen: P`record.amount != null && record.amount > 10000`,
120120
}),
121121
// M9.9b — declarative default, evaluated at insert time
122122
issued_date: Field.date({
@@ -353,7 +353,7 @@ ObjectSchema.create({
353353
Field.text({
354354
visibleWhen: P`record.type == 'business'`,
355355
readonlyWhen: P`record.status == 'approved'`,
356-
requiredWhen: P`record.amount > 10000`,
356+
requiredWhen: P`record.amount != null && record.amount > 10000`,
357357
})
358358
```
359359

@@ -391,14 +391,16 @@ clock at install time.
391391
{
392392
name: 'amount_positive',
393393
type: 'script',
394-
condition: P`record.amount <= 0`,
394+
condition: P`record.amount != null && record.amount <= 0`,
395395
message: 'Amount must be positive.',
396396
}
397397
```
398398

399399
The generic CEL rule is `type: 'script'`. For `script` rules, the `condition`
400400
is the **failure** predicate — when it evaluates TRUE the validation fails, so
401-
invert the test (here `amount <= 0` rejects non-positive amounts).
401+
invert the test (here `amount <= 0` rejects non-positive amounts). An optional
402+
field needs the `!= null` test first: the publish gate refuses `<`, `<=`, `>`, `>=`
403+
or arithmetic on a nullable field unless a null test guards it.
402404

403405
### Hook condition
404406

‎content/docs/data-modeling/validation.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ validations: [
326326
type: 'script',
327327
severity: 'error',
328328
message: 'Order amount must be positive',
329-
condition: 'record.amount <= 0',
329+
condition: 'record.amount != null && record.amount <= 0',
330330
events: ['insert', 'update'],
331331
priority: 10,
332332
},

‎content/docs/getting-started/common-patterns.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,8 @@ Define a multi-step approval flow for records.
309309
sharingModel: 'private',
310310
fields: {
311311
title: { label: 'Title', type: 'text', required: true },
312-
amount: { label: 'Amount', type: 'currency' },
312+
// Required: the approval flow below branches on it.
313+
amount: { label: 'Amount', type: 'currency', required: true },
313314
status: { label: 'Status', type: 'select', options: [
314315
{ label: 'Draft', value: 'draft', default: true },
315316
{ label: 'Submitted', value: 'submitted' },

‎content/docs/references/data/validation.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ const result = ConditionalValidationSchema.parse(data);
167167
| **_packageVersion** | `string` | optional | Owning package version. |
168168
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
169169
| **type** | `'script'` | ✅ | |
170-
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
170+
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount != null && record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
171171

172172
### Nested Shape: `ConditionalValidation.then[type='state_machine']`
173173

@@ -288,7 +288,7 @@ const result = ConditionalValidationSchema.parse(data);
288288
| **_packageVersion** | `string` | optional | Owning package version. |
289289
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
290290
| **type** | `'script'` | ✅ | |
291-
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
291+
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount != null && record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
292292

293293
### Nested Shape: `ConditionalValidation.otherwise[type='state_machine']`
294294

@@ -502,7 +502,7 @@ const result = ConditionalValidationSchema.parse(data);
502502
| **_packageVersion** | `string` | optional | Owning package version. |
503503
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
504504
| **type** | `'script'` | ✅ | |
505-
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
505+
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount != null && record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
506506

507507

508508
---
@@ -568,7 +568,7 @@ This schema accepts one of the following structures:
568568
| **_packageVersion** | `string` | optional | Owning package version. |
569569
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
570570
| **type** | `'script'` | ✅ | |
571-
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
571+
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount != null && record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. |
572572

573573
---
574574

‎packages/spec/src/data/validation.zod.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export const ScriptValidationSchema = lazySchema(() => strictObject({
177177
}, {
178178
...BASE_VALIDATION_SHAPE,
179179
type: z.literal('script'),
180-
condition: EvaluatedExpressionInputSchema.describe('Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row.'),
180+
condition: EvaluatedExpressionInputSchema.describe('Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount != null && record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row.'),
181181
}));
182182

183183
/**
@@ -254,7 +254,7 @@ export const FormatValidationSchema = lazySchema(() => strictObject({
254254
* {
255255
* type: 'cross_field',
256256
* name: 'discount_limit',
257-
* condition: 'record.discount > (record.amount * 0.40)',
257+
* condition: 'record.discount != null && record.amount != null && record.discount > (record.amount * 0.40)',
258258
* fields: ['discount', 'amount'],
259259
* message: 'Discount cannot exceed 40% of the amount'
260260
* }
@@ -529,7 +529,7 @@ export const ValidationRuleSchema: z.ZodType<BaseValidationRuleShape, BaseValida
529529
* then: {
530530
* type: 'cross_field',
531531
* name: 'amount_approval',
532-
* condition: 'record.amount > 100000 && record.approval == null',
532+
* condition: 'record.amount != null && record.amount > 100000 && record.approval == null',
533533
* fields: ['amount', 'approval']
534534
* }
535535
* }

0 commit comments

Comments
 (0)