Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/document-validation-governance-crossfield.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</Callout>

## Basic Structure
Expand Down Expand Up @@ -197,6 +198,10 @@ Validate relationships between multiple fields:
}
```

<Callout type="info">
`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.
</Callout>

### JSON Schema Validation

Validate JSON data against a JSON Schema:
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---
Expand Down Expand Up @@ -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. |

---

Expand Down
6 changes: 1 addition & 5 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,8 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
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',
Expand All @@ -262,8 +260,6 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
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'],
Expand Down
15 changes: 14 additions & 1 deletion packages/spec/src/data/validation.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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.'),
}));

/**
Expand Down