diff --git a/.changeset/document-validation-governance-crossfield.md b/.changeset/document-validation-governance-crossfield.md new file mode 100644 index 0000000000..1bcb9417b5 --- /dev/null +++ b/.changeset/document-validation-governance-crossfield.md @@ -0,0 +1,10 @@ +--- +"@objectstack/spec": patch +--- + +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: + +- `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. +- `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. +- 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. +- 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). diff --git a/content/docs/data-modeling/validation.mdx b/content/docs/data-modeling/validation.mdx index 19d8f04ad0..510bf9734b 100644 --- a/content/docs/data-modeling/validation.mdx +++ b/content/docs/data-modeling/validation.mdx @@ -13,6 +13,7 @@ Three patterns that look like validation rules are deliberately **not** rule typ - **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. - **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. - **Custom handler** → a `beforeInsert` / `beforeUpdate` lifecycle hook, the supported extension point for arbitrary validation code. +- **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. ## Basic Structure @@ -197,6 +198,10 @@ Validate relationships between multiple fields: } ``` + +`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. + + ### JSON Schema Validation Validate JSON data against a JSON Schema: diff --git a/content/docs/references/data/validation.mdx b/content/docs/references/data/validation.mdx index 0909d5ba81..f5200d1be8 100644 --- a/content/docs/references/data/validation.mdx +++ b/content/docs/references/data/validation.mdx @@ -171,7 +171,7 @@ const result = ConditionalValidation.parse(data); | **message** | `string` | ✅ | Error message to display to the user | | **type** | `'cross_field'` | ✅ | | | **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` | -| **fields** | `string[]` | ✅ | Fields involved in the validation | +| **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. | --- @@ -360,7 +360,7 @@ This schema accepts one of the following structures: | **message** | `string` | ✅ | Error message to display to the user | | **type** | `'cross_field'` | ✅ | | | **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` | -| **fields** | `string[]` | ✅ | Fields involved in the validation | +| **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. | --- diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 97962ed01a..8579e9a1c7 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -246,10 +246,8 @@ const HAND_CRAFTED_SCHEMAS: Record> = { fields: { type: 'array', items: { type: 'string' }, - description: 'Fields (type=unique / cross_field).', + description: 'Fields (type=cross_field).', }, - scope: { type: 'string', description: 'CEL scope predicate (type=unique).' }, - caseSensitive: { type: 'boolean', default: true }, field: { type: 'string', description: 'Single field (type=state_machine / format).' }, transitions: { type: 'object', @@ -262,8 +260,6 @@ const HAND_CRAFTED_SCHEMAS: Record> = { enum: ['email', 'url', 'phone', 'json'], description: 'Built-in format (type=format).', }, - url: { type: 'string', description: 'Endpoint URL (type=async).' }, - handler: { type: 'string', description: 'Handler reference (type=custom).' }, when: { type: 'string', description: 'Outer condition (type=conditional).' }, }, required: ['name', 'type', 'message'], diff --git a/packages/spec/src/data/validation.zod.ts b/packages/spec/src/data/validation.zod.ts index e5c2d90041..c3fa424b58 100644 --- a/packages/spec/src/data/validation.zod.ts +++ b/packages/spec/src/data/validation.zod.ts @@ -82,6 +82,13 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * - **Label/Description**: Essential for governance in large systems with thousands of rules. * - **Events**: granular control over validation timing (Context-aware validation). * - **Tags**: categorization for reporting and management. + * + * `label`, `description` and `tags` are **governance / editor metadata**: they are + * surfaced to the Studio validation-rule editor form (via the `/meta/types` form + * schema) and to rule listings, but are NOT evaluated on the write path — the + * rule validator only reads `type`/`condition`/`field`/`events`/`severity`/`message`. + * They are declared here on purpose (not silent no-ops): they carry authoring intent, + * not enforcement. */ import { lazySchema } from '../shared/lazy-schema'; const BaseValidationSchema = z.object({ @@ -197,10 +204,16 @@ export const FormatValidationSchema = lazySchema(() => BaseValidationSchema.exte * } * ``` */ +// NOTE: `cross_field` shares the exact evaluation path as `script` — both dispatch +// to the same predicate checker. `fields` is advisory: only `fields[0]` is read, to +// label which field the violation attaches to (a `script` rule attaches to `_record`); +// `fields[1..]` are documentation only. Prefer `script` unless you want the error +// targeted at a specific field. Kept as a distinct variant for that field-targeting +// affordance and for backward compatibility. export const CrossFieldValidationSchema = lazySchema(() => BaseValidationSchema.extend({ type: z.literal('cross_field'), condition: ExpressionInputSchema.describe('Predicate (CEL) comparing fields. e.g. P`record.end_date > record.start_date`'), - fields: z.array(z.string()).describe('Fields involved in the validation'), + 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.'), })); /**