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
5 changes: 5 additions & 0 deletions .changeset/trim-validation-delete-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/spec": patch
---

Remove the dead `'delete'` member from the validation-rule `events` enum (#3184). The rule evaluator only runs on the insert/update write path — `engine.delete` never invokes it — so a rule declaring `events: ['delete']` was a silent no-op (flagged in #3106 and `docs/audits/2026-06-validationschema-property-liveness.md`). The enum now admits only `insert`/`update`; guard deletions with a `beforeDelete` lifecycle hook instead. No shipped metadata declares `events: ['delete']`; any off-spec metadata that did now fails loudly at `os validate` / registration rather than parsing and doing nothing. Also narrows the two hand-written mirrors (`rule-validator.ts` `BaseRule`, `metadata-protocol` JSON-schema form helper — whose stale `type` enum listing removed `unique`/`async`/`custom` variants is corrected in the same pass), updates the doc comments, the published data skill, and the hand-written validation doc.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ All validation types share these base properties:
| `active` | `boolean` | optional | Is the rule active (default: `true`) |
| `severity` | `enum` | optional | `'error'` (blocks save), `'warning'`, `'info'` (default: `'error'`) |
| `message` | `string` | ✅ | User-facing error message |
| `events` | `enum[]` | optional | When to run: `'insert'`, `'update'`, `'delete'` (default: `['insert', 'update']`) |
| `events` | `enum[]` | optional | When to run: `'insert'`, `'update'` (default: `['insert', 'update']`). No `'delete'` — guard deletions with a `beforeDelete` lifecycle hook |
| `priority` | `number` | optional | Execution order (0-9999, lower runs first, default: 100) |
| `tags` | `string[]` | optional | Categorization tags |

Expand Down
38 changes: 24 additions & 14 deletions content/docs/references/data/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ no I/O. Everything advertised here runs on the write path (see

`objectql/src/validation/rule-validator.ts`) — insert, single-id update, and multi-row

(`multi: true`) update, where the evaluator runs once per matched row (#3106). One known gap:
(`multi: true`) update, where the evaluator runs once per matched row (#3106); nothing is a

rules declaring `events: ['delete']` are not yet evaluated on delete (tracked separately).
silent no-op. The `events` enum admits only `insert`/`update` for this reason — see the

`delete` note under "Deliberately NOT validation rules" below.

The system supports these validation types:

Expand Down Expand Up @@ -65,6 +67,14 @@ the form layer, or enforce the underlying invariant with a `unique` index / life

extension point for arbitrary validation code.

- **Delete-time guards** (`events: ['delete']`) → a `beforeDelete` lifecycle hook. The evaluator

only runs on the insert/update write path (a delete carries no record payload to validate), so

a `delete` event was a proven silent no-op — the enum value was removed rather than left

advertised-but-unenforced (#3184; see docs/audits/2026-06-validationschema-property-liveness.md).

## Salesforce Comparison

ObjectStack validation rules are inspired by Salesforce validation rules but enhanced:
Expand Down Expand Up @@ -131,7 +141,7 @@ const result = ConditionalValidation.parse(data);
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -154,7 +164,7 @@ const result = ConditionalValidation.parse(data);
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -176,7 +186,7 @@ const result = ConditionalValidation.parse(data);
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | ✅ | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | ✅ | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | ✅ | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | ✅ | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | |
Expand All @@ -199,7 +209,7 @@ const result = ConditionalValidation.parse(data);
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | ✅ | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | ✅ | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | ✅ | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | ✅ | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | |
Expand All @@ -221,7 +231,7 @@ const result = ConditionalValidation.parse(data);
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -242,7 +252,7 @@ const result = ConditionalValidation.parse(data);
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | ✅ | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | ✅ | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | ✅ | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | ✅ | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | ✅ | |
Expand Down Expand Up @@ -272,7 +282,7 @@ This schema accepts one of the following structures:
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -294,7 +304,7 @@ This schema accepts one of the following structures:
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -317,7 +327,7 @@ This schema accepts one of the following structures:
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -341,7 +351,7 @@ This schema accepts one of the following structures:
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -364,7 +374,7 @@ This schema accepts one of the following structures:
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand All @@ -387,7 +397,7 @@ This schema accepts one of the following structures:
| **label** | `string` | optional | Human-readable label for the rule listing |
| **description** | `string` | optional | Administrative notes explaining the business reason |
| **active** | `boolean` | optional | |
| **events** | `Enum<'insert' \| 'update' \| 'delete'>[]` | optional | Validation contexts |
| **events** | `Enum<'insert' \| 'update'>[]` | optional | Write contexts the rule runs on. `delete` is intentionally absent — the evaluator only runs on the insert/update write path; guard deletions with a `beforeDelete` lifecycle hook |
| **priority** | `integer` | optional | Execution priority (lower runs first, default: 100) |
| **tags** | `string[]` | optional | Categorization tags (e.g., "compliance", "billing") |
| **severity** | `Enum<'error' \| 'warning' \| 'info'>` | optional | |
Expand Down
2 changes: 1 addition & 1 deletion docs/design/builder-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ field editor is shared by both Records (selected column) and Fields (selected fi
union of six declarative rule types (`packages/spec/src/data/validation.zod.ts`):
`script` (CEL predicate → fails when true), `cross_field`, `format`,
`state_machine`, `json`, `conditional`. Each carries `message`, `severity`
(error/warning/info), and `events` (insert/update/delete).
(error/warning/info), and `events` (insert/update).

- **Field-level** (required / unique / format) → configured in the **field editor**
(they belong to the field).
Expand Down
9 changes: 3 additions & 6 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
additionalProperties: true,
},
// Validation rules live inside `object.validations[]`. The canonical
// ValidationRuleSchema is a discriminated union of 9 variants; the
// ValidationRuleSchema is a discriminated union of 6 variants; the
// generic SchemaForm renderer treats unions as opaque JSON, so we
// ship a *flat* form-friendly schema covering the common base
// properties plus every variant-specific field as optional. Save-time
Expand All @@ -215,13 +215,10 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
type: 'string',
enum: [
'script',
'unique',
'state_machine',
'format',
'cross_field',
'json',
'async',
'custom',
'json_schema',
'conditional',
],
default: 'script',
Expand All @@ -230,7 +227,7 @@ const HAND_CRAFTED_SCHEMAS: Record<string, Record<string, unknown>> = {
active: { type: 'boolean', default: true },
events: {
type: 'array',
items: { type: 'string', enum: ['insert', 'update', 'delete'] },
items: { type: 'string', enum: ['insert', 'update'] },
default: ['insert', 'update'],
},
priority: { type: 'number', default: 100, minimum: 0, maximum: 9999 },
Expand Down
9 changes: 5 additions & 4 deletions packages/objectql/src/validation/rule-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@
* (uniqueness → DB index, async → form layer, custom → lifecycle hook). The
* engine invokes this evaluator on insert, single-id update, and — per matched
* row — multi-row (`multi: true`) update (#3106), so no declared rule is a
* silent no-op on the write path. Known gap: rules declaring
* `events: ['delete']` are never evaluated (`engine.delete` does not call this
* evaluator; tracked separately — see below).
* silent no-op on the write path. The `events` enum admits only `insert`/`update`
* to match: `delete` carries no record payload to validate, so it was removed
* from the spec rather than advertised-but-unenforced (#3184). Guard deletions
* with a `beforeDelete` lifecycle hook instead.
*
* ## Execution-control semantics (from `BaseValidationSchema`)
*
Expand Down Expand Up @@ -83,7 +84,7 @@ interface BaseRule {
name: string;
message: string;
active?: boolean;
events?: Array<'insert' | 'update' | 'delete'>;
events?: Array<'insert' | 'update'>;
priority?: number;
severity?: 'error' | 'warning' | 'info';
}
Expand Down
42 changes: 42 additions & 0 deletions packages/spec/src/data/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,3 +1116,45 @@ describe('ValidationRule - priority property', () => {
})).toThrow();
});
});

describe('ValidationRule - events property (#3184)', () => {
it('should accept insert / update events', () => {
const rule = ScriptValidationSchema.parse({
type: 'script',
name: 'on_write',
message: 'x',
condition: 'true',
events: ['insert', 'update'],
});
expect(rule.events).toEqual(['insert', 'update']);
});

it('should default events to insert + update', () => {
const rule = ScriptValidationSchema.parse({
type: 'script',
name: 'default_events',
message: 'x',
condition: 'true',
});
expect(rule.events).toEqual(['insert', 'update']);
});

it("should reject events: ['delete'] — the evaluator never runs on delete; guard deletions with a beforeDelete hook", () => {
expect(() => ScriptValidationSchema.parse({
type: 'script',
name: 'on_delete',
message: 'x',
condition: 'true',
events: ['delete'],
})).toThrow();

// also rejected via the discriminated-union entry point
expect(() => ValidationRuleSchema.parse({
type: 'script',
name: 'on_delete_union',
message: 'x',
condition: 'true',
events: ['insert', 'delete'],
})).toThrow();
});
});
Loading