Skip to content

Commit 5e3301d

Browse files
os-zhuangclaude
andauthored
docs(spec): document validation governance fields + cross_field/script overlap; tidy stale form mirror (#3198)
Follow-up to the 2026-06 validation liveness audit (#3106 / #3184). No runtime behavior change — documentation, a form-schema cleanup, and a docs pointer: - Document that label/description/tags are governance/editor metadata (surfaced to the Studio rule editor, not evaluated on the write path). Kept, not removed: they're set by nearly every example rule and feed the /meta/types editor form, so they're declared on purpose rather than silent no-ops. - Document that cross_field evaluates identically to script (same CEL predicate); only fields[0] is read, to target the violation at a field. Noted on the schema, its fields .describe(), and the validation docs. Variant kept for the field-targeting affordance + backward compatibility (removing it would break discriminated-union parsing of existing cross_field metadata). - Remove dead form-field entries (scope/caseSensitive/url/handler) and the stale type=unique hint from the hand-written HAND_CRAFTED_SCHEMAS['validation'] fallback in metadata-protocol — leftovers from the removed unique/async/custom variants. - Add the missing beforeDelete pointer to the docs' "not a rule type" callout so delete-time guards aren't stranded (validation has no delete event since #3184). Regenerated content/docs/references/data/validation.mdx. Claude-Session: https://claude.ai/code/session_01VCUSMJBsX14C3RQdWFw7N7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80273c8 commit 5e3301d

5 files changed

Lines changed: 32 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Document two validation-rule facts surfaced by the 2026-06 liveness audit (follow-up to #3106 / #3184), and clean up a stale form-schema mirror — no runtime behavior change:
6+
7+
- `label` / `description` / `tags` on validation rules are governance / editor metadata (surfaced to the Studio rule editor and rule listings), not evaluated on the write path. Documented as such on `BaseValidationSchema` rather than removed — they are set by nearly every example rule and feed the `/meta/types` editor form, so they are declared on purpose, not silent no-ops.
8+
- `cross_field` evaluates identically to `script` (same CEL predicate path); only `fields[0]` is read, to target the violation at a field. Documented the overlap on the schema, its `fields` `.describe()`, and the validation docs so authors can choose between them; the variant is kept for the field-targeting affordance and backward compatibility.
9+
- Removed dead form-field entries (`scope`, `caseSensitive`, `url`, `handler`) and the stale `type=unique` hint from the hand-written `HAND_CRAFTED_SCHEMAS['validation']` fallback in `@objectstack/metadata-protocol` — leftovers from the removed `unique`/`async`/`custom` variants.
10+
- Added the missing `beforeDelete` lifecycle-hook pointer to the validation docs' "not a rule type" callout, so delete-time guards aren't stranded now that validation has no `delete` event (#3184).

content/docs/data-modeling/validation.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Three patterns that look like validation rules are deliberately **not** rule typ
1313
- **Uniqueness** → a unique index (`ObjectSchema.indexes`, `{ fields, unique: true }`, with `partial` for a scoped constraint) or field-level `unique: true`. A SELECT-then-INSERT rule is inherently racy (TOCTOU); a DB unique constraint is not.
1414
- **Async / remote validation** → a client-form concern, and an SSRF/latency hazard on the server write path. Keep it in the form layer, or enforce the invariant with a `unique` index / lifecycle hook.
1515
- **Custom handler** → a `beforeInsert` / `beforeUpdate` lifecycle hook, the supported extension point for arbitrary validation code.
16+
- **Delete-time guards** → a `beforeDelete` lifecycle hook. Validation rules run only on insert/update (a delete carries no record payload to validate), so there is no `'delete'` validation event — block or gate deletions from a `beforeDelete` hook.
1617
</Callout>
1718

1819
## Basic Structure
@@ -197,6 +198,10 @@ Validate relationships between multiple fields:
197198
}
198199
```
199200

201+
<Callout type="info">
202+
`cross_field` evaluates identically to `script` — both run the same CEL predicate. The only difference is `fields[0]`, which labels the field the error attaches to (a plain `script` error attaches to the record). The remaining `fields` are advisory/documentation. Reach for `cross_field` when you want the error targeted at a specific field; otherwise `script` is equivalent.
203+
</Callout>
204+
200205
### JSON Schema Validation
201206

202207
Validate JSON data against a JSON Schema:

content/docs/references/data/validation.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ const result = ConditionalValidation.parse(data);
171171
| **message** | `string` || Error message to display to the user |
172172
| **type** | `'cross_field'` || |
173173
| **condition** | `string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date` |
174-
| **fields** | `string[]` || Fields involved in the validation |
174+
| **fields** | `string[]` || Fields involved. Only fields[0] is read (labels which field the violation attaches to); the rest are advisory. Shares script’s evaluation path. |
175175

176176

177177
---
@@ -360,7 +360,7 @@ This schema accepts one of the following structures:
360360
| **message** | `string` || Error message to display to the user |
361361
| **type** | `'cross_field'` || |
362362
| **condition** | `string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date` |
363-
| **fields** | `string[]` || Fields involved in the validation |
363+
| **fields** | `string[]` || Fields involved. Only fields[0] is read (labels which field the violation attaches to); the rest are advisory. Shares script’s evaluation path. |
364364

365365
---
366366

packages/metadata-protocol/src/protocol.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,8 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
246246
fields: {
247247
type: 'array',
248248
items: { type: 'string' },
249-
description: 'Fields (type=unique / cross_field).',
249+
description: 'Fields (type=cross_field).',
250250
},
251-
scope: { type: 'string', description: 'CEL scope predicate (type=unique).' },
252-
caseSensitive: { type: 'boolean', default: true },
253251
field: { type: 'string', description: 'Single field (type=state_machine / format).' },
254252
transitions: {
255253
type: 'object',
@@ -262,8 +260,6 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
262260
enum: ['email', 'url', 'phone', 'json'],
263261
description: 'Built-in format (type=format).',
264262
},
265-
url: { type: 'string', description: 'Endpoint URL (type=async).' },
266-
handler: { type: 'string', description: 'Handler reference (type=custom).' },
267263
when: { type: 'string', description: 'Outer condition (type=conditional).' },
268264
},
269265
required: ['name', 'type', 'message'],

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ import { ExpressionInputSchema } from '../shared/expression.zod';
8282
* - **Label/Description**: Essential for governance in large systems with thousands of rules.
8383
* - **Events**: granular control over validation timing (Context-aware validation).
8484
* - **Tags**: categorization for reporting and management.
85+
*
86+
* `label`, `description` and `tags` are **governance / editor metadata**: they are
87+
* surfaced to the Studio validation-rule editor form (via the `/meta/types` form
88+
* schema) and to rule listings, but are NOT evaluated on the write path — the
89+
* rule validator only reads `type`/`condition`/`field`/`events`/`severity`/`message`.
90+
* They are declared here on purpose (not silent no-ops): they carry authoring intent,
91+
* not enforcement.
8592
*/
8693
import { lazySchema } from '../shared/lazy-schema';
8794
const BaseValidationSchema = z.object({
@@ -197,10 +204,16 @@ export const FormatValidationSchema = lazySchema(() => BaseValidationSchema.exte
197204
* }
198205
* ```
199206
*/
207+
// NOTE: `cross_field` shares the exact evaluation path as `script` — both dispatch
208+
// to the same predicate checker. `fields` is advisory: only `fields[0]` is read, to
209+
// label which field the violation attaches to (a `script` rule attaches to `_record`);
210+
// `fields[1..]` are documentation only. Prefer `script` unless you want the error
211+
// targeted at a specific field. Kept as a distinct variant for that field-targeting
212+
// affordance and for backward compatibility.
200213
export const CrossFieldValidationSchema = lazySchema(() => BaseValidationSchema.extend({
201214
type: z.literal('cross_field'),
202215
condition: ExpressionInputSchema.describe('Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date`'),
203-
fields: z.array(z.string()).describe('Fields involved in the validation'),
216+
fields: z.array(z.string()).describe('Fields involved. Only fields[0] is read (labels which field the violation attaches to); the rest are advisory. Shares script’s evaluation path.'),
204217
}));
205218

206219
/**

0 commit comments

Comments
 (0)