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
40 changes: 40 additions & 0 deletions .changeset/enforce-validation-rules-1475.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/objectql": minor
"@objectstack/spec": minor
---

Enforce every declared validation-rule type on the write path; trim the three that can't be (#1475).

The `validations` union advertised nine rule types but only three (`state_machine`,
`cross_field`, `script`) ran on insert/update — the other six were accepted by the
schema yet silently did nothing. This closes that gap on both sides: implement the
synchronous types, and trim the ones that don't belong in a write-path rule.

**`@objectstack/objectql` (additive):** the rule evaluator now enforces three more
types, all deterministic, synchronous, side-effect-free predicates over one record:

- `format` — a field value against a `regex` and/or a named format
(`email` / `url` / `phone` / `json`). Runs only when the write touches the field
and the value is non-empty; a malformed regex fails open.
- `json_schema` — a JSON field validated against a JSON Schema via `ajv` (compiled
result memoised per schema). Accepts a parsed object or a JSON string; an
unparseable string is itself a violation; an uncompilable schema fails open.
- `conditional` — evaluates `when`, then recurses into `then` / `otherwise`. The
nested rule supplies the message; the outer conditional's `severity` decides
blocking. `needsPriorRecord` now recurses into conditional branches.

Adds `ajv` as a dependency and three error codes (`invalid_format`, `invalid_json`,
`json_schema_violation`).

**`@objectstack/spec` (breaking for unused declarations):** removes the
`unique`, `async`, and `custom` validation-rule variants (and the
`UniquenessValidationSchema` / `AsyncValidationSchema` / `CustomValidatorSchema`
exports). They were never enforced and each needs I/O or a handler model a
write-path rule must not carry. Use the layer that already does each correctly:
uniqueness → a unique index (`ObjectSchema.indexes`, `partial` for scope) or
field-level `unique: true`; async/remote → the client form layer; custom code →
a `beforeInsert` / `beforeUpdate` lifecycle hook. Field-level `unique: true` is
unaffected.

`examples/app-showcase` demonstrates and verifies each newly-enforced type. See the
ADR-0020 addendum for the rationale.
9 changes: 9 additions & 0 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,12 @@ The proposal's intent is fully delivered — converge to the `state_machine` rul
- **Make `state_machine` a standalone metadata type / `.state.ts` file.** Rejected: it is intrinsically a per-field constraint on one object (not reusable, not independently versioned), shares `BaseValidationSchema` with eight sibling rule types, and a standalone file just re-creates the top-level type this ADR retires. If a state table grows large, split the *TypeScript* (`export const fooValidations = [...]`), not the metadata model.
- **Encode transitions as a Salesforce-style `script` formula instead of a structured `state_machine` rule.** Rejected: a free-form predicate is not introspectable — UI can't grey out illegal actions and an Agent can't read the legal-next set, which is the whole point. The structured table is the upgrade over the Salesforce approach.
- **Leave all three shapes, just add enforcement to one.** Rejected: three declaration shapes for one concept is itself the AI-hallucination hazard this ADR exists to remove.

## Addendum — completing D3 for the whole `validations` union (#1475)

This ADR enforced `state_machine` / `cross_field` / `script` and left the other six rule types declarative "until a later phase." [#1475](https://github.com/objectstack-ai/framework/issues/1475) finished that phase, applying the same "no advertised-but-unenforced capability" principle to the rest of the union. The "nine types" figure quoted in the problem statement above is therefore now **six**:

- **Enforced (added by #1475):** `format` (regex / named email·url·phone·json), `json_schema` (ajv-compiled), `conditional` (recursive `when` → `then`/`otherwise`). These join the three this ADR shipped — all are deterministic, synchronous, side-effect-free predicates over one record, the contract that makes them safe on the write path.
- **Removed from the schema:** `unique`, `async`, `custom`. Each needs I/O or a handler model a write-path rule must not carry, so it was trimmed rather than left a silent no-op, and redirected to the layer that already does it correctly: uniqueness → a unique **index** (`ObjectSchema.indexes`, `partial` for scope) or field-level `unique: true`; async/remote → the client form layer; custom code → a `beforeInsert`/`beforeUpdate` lifecycle hook.

Deviation #3 above ("a conditional transition can be expressed as a sibling `script`/`conditional` rule") now rests on a genuinely enforced `conditional`. See [`validation.zod.ts`](../../packages/spec/src/data/validation.zod.ts) for the trimmed taxonomy and [`rule-validator.ts`](../../packages/objectql/src/validation/rule-validator.ts) for the evaluator; `examples/app-showcase` demonstrates and verifies each enforced type (`test/validation.test.ts`).
1 change: 1 addition & 0 deletions examples/app-showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"devDependencies": {
"@objectstack/cli": "workspace:*",
"@objectstack/objectql": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
Expand Down
67 changes: 67 additions & 0 deletions examples/app-showcase/src/objects/account.object.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ObjectSchema, Field } from '@objectstack/spec/data';
import { P } from '@objectstack/spec';

/**
* Account — a customer org. Lookup target for projects and the field zoo.
*
* Doubles as the validation-rule showcase: besides the re-entrant
* `state_machine` lifecycle below, it demonstrates the other write-path rule
* types — `format` (tax id pattern), `json_schema` (support config shape), and
* `conditional` (churn reason required only when churning). Each is exercised
* by `test/validation.test.ts` against the real evaluator.
*/
export const Account = ObjectSchema.create({
name: 'showcase_account',
Expand Down Expand Up @@ -35,6 +42,15 @@ export const Account = ObjectSchema.create({
{ label: 'Churned', value: 'churned', color: '#EF4444' },
],
}),
// EIN-style tax id — the `format` rule below enforces the NN-NNNNNNN shape
// with a regex (a deliberately non-trivial pattern, unlike the built-in
// email/url field validators).
tax_id: Field.text({ label: 'Tax ID (EIN)', maxLength: 20 }),
// Free-form integration settings stored as JSON — the `json_schema` rule
// below constrains its shape.
support_config: Field.json({ label: 'Support Config' }),
// Captured only when an account churns; required by the `conditional` rule.
churn_reason: Field.text({ label: 'Churn Reason', maxLength: 500 }),
},

// A third `state_machine` example with a different topology than
Expand All @@ -57,5 +73,56 @@ export const Account = ObjectSchema.create({
churned: ['active'],
},
},
{
// `format` — a regex check on a single field. Runs on insert/update
// whenever the write touches `tax_id` and the value is non-empty.
type: 'format' as const,
name: 'tax_id_format',
label: 'Tax ID Format',
description: 'Tax ID must be a US EIN (NN-NNNNNNN).',
field: 'tax_id',
regex: '^\\d{2}-\\d{7}$',
message: 'Tax ID must look like 12-3456789.',
},
{
// `json_schema` — validate the JSON `support_config` blob against a
// JSON Schema (compiled by ajv). Accepts a parsed object or a JSON
// string; an unparseable string is itself a violation.
type: 'json_schema' as const,
name: 'support_config_shape',
label: 'Support Config Shape',
description: 'Support config must declare a known tier and a positive seat count.',
field: 'support_config',
message: 'Support Config must be { tier: standard|premium|enterprise, seats?: >=1 }.',
schema: {
type: 'object',
properties: {
tier: { type: 'string', enum: ['standard', 'premium', 'enterprise'] },
seats: { type: 'integer', minimum: 1 },
},
required: ['tier'],
additionalProperties: false,
},
},
{
// `conditional` — only enforce the inner rule when `when` holds. Here:
// an account may only be marked churned if it records why. The nested
// rule supplies the message; this conditional's severity (default
// `error`) decides that it blocks.
type: 'conditional' as const,
name: 'churn_requires_reason',
label: 'Churn Requires a Reason',
description: 'A churned account must record a churn reason.',
when: P`record.status == 'churned'`,
message: 'Churn reason validation.',
then: {
type: 'script' as const,
name: 'churn_reason_present',
message: 'A churn reason is required when an account is marked churned.',
// `has()` guards the absent-key case (a PATCH that never mentions
// churn_reason); the equality checks catch an explicit null / blank.
condition: P`!has(record.churn_reason) || record.churn_reason == null || record.churn_reason == ''`,
},
},
],
});
88 changes: 88 additions & 0 deletions examples/app-showcase/test/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { evaluateValidationRules, ValidationError } from '@objectstack/objectql';

import { Account } from '../src/objects/account.object.js';

/**
* Verifies the Account object's declared validation rules actually enforce on
* the write path — running the real `evaluateValidationRules` evaluator against
* the very rules shipped in `account.object.ts`. This is the "demonstrated AND
* verified" half of the showcase: a rule that parses but no-ops would slip past
* the coverage test, but not this one.
*
* `state_machine` is already covered by objectql's own suite; here we exercise
* the three rule types this object adds: `format`, `json_schema`, `conditional`.
*/
const schema = Account as unknown as { validations?: unknown[] };

describe('showcase Account validation rules (write-path enforcement)', () => {
describe('format — tax_id EIN pattern', () => {
it('rejects a malformed tax id', () => {
expect(() => evaluateValidationRules(schema, { tax_id: '123-45' }, 'insert')).toThrow(
ValidationError,
);
});

it('accepts a well-formed EIN', () => {
expect(() => evaluateValidationRules(schema, { tax_id: '12-3456789' }, 'insert')).not.toThrow();
});

it('does not fire when tax_id is absent (requiredness is not its concern)', () => {
expect(() => evaluateValidationRules(schema, { name: 'Acme' }, 'insert')).not.toThrow();
});
});

describe('json_schema — support_config shape', () => {
it('accepts a conforming config', () => {
expect(() =>
evaluateValidationRules(schema, { support_config: { tier: 'premium', seats: 10 } }, 'insert'),
).not.toThrow();
});

it('rejects an unknown tier / missing required field', () => {
expect(() =>
evaluateValidationRules(schema, { support_config: { tier: 'gold' } }, 'insert'),
).toThrow(ValidationError);
expect(() =>
evaluateValidationRules(schema, { support_config: { seats: 3 } }, 'insert'),
).toThrow(ValidationError);
});

it('rejects additional properties (additionalProperties: false)', () => {
expect(() =>
evaluateValidationRules(schema, { support_config: { tier: 'standard', extra: 1 } }, 'insert'),
).toThrow(ValidationError);
});
});

describe('conditional — churn requires a reason', () => {
it('blocks marking an account churned without a reason', () => {
expect(() =>
evaluateValidationRules(schema, { status: 'churned' }, 'update', {
previous: { status: 'active' },
}),
).toThrow(ValidationError);
});

it('allows churning when a reason is supplied', () => {
expect(() =>
evaluateValidationRules(
schema,
{ status: 'churned', churn_reason: 'Migrated to a competitor' },
'update',
{ previous: { status: 'active' } },
),
).not.toThrow();
});

it('does not require a reason for non-churned accounts', () => {
expect(() =>
evaluateValidationRules(schema, { status: 'active' }, 'update', {
previous: { status: 'prospect' },
}),
).not.toThrow();
});
});
});
1 change: 1 addition & 0 deletions packages/objectql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@objectstack/metadata-core": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"ajv": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
5 changes: 4 additions & 1 deletion packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export interface FieldValidationError {
| 'invalid_option'
// Object-level validation rules (ADR-0020, see rule-validator.ts)
| 'invalid_transition'
| 'rule_violation';
| 'rule_violation'
| 'invalid_format'
| 'invalid_json'
| 'json_schema_violation';
message: string;
/** Allowed values for select/multiselect, when applicable. */
options?: string[];
Expand Down
Loading
Loading