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
20 changes: 20 additions & 0 deletions .changeset/state-machine-initial-states.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
---

feat(validation): `state_machine.initialStates` enforces the FSM entry point on INSERT (#3165)

A `state_machine` rule's `transitions` only governs UPDATE — on INSERT the rule
was a no-op, and a `select` field permits ANY declared option as the initial
value. So a record could be born mid-flow (created already `approved`), skipping
the whole state machine. This was the gap #3043's mitigation idea assumed didn't
exist (declared ≠ enforced, ADR-0049).

`state_machine` rules gain an optional `initialStates: string[]` — the states a
record may be CREATED in. When set, an insert whose (defaulted) state-field value
is outside the list is rejected server-side with `code: 'invalid_initial_state'`.
Omit it to keep the legacy behavior (no initial-state check on insert). A missing
/ empty value is left to required-validation; `transitions` (UPDATE) is
unaffected. Enforced at the same `evaluateValidationRules(..., 'insert')` seam the
engine already runs after field defaults.
8 changes: 6 additions & 2 deletions content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ Enforce allowed state transitions:
severity: 'error',
message: 'Invalid status transition',
field: 'status',
// Which states a record may be CREATED in (the FSM entry point).
initialStates: ['draft'],
transitions: {
draft: ['submitted', 'cancelled'],
submitted: ['approved', 'rejected', 'cancelled'],
Expand All @@ -168,14 +170,16 @@ Enforce allowed state transitions:
cancelled: [],
completed: [],
},
events: ['update'],
}
```

| Property | Type | Description |
| :--- | :--- | :--- |
| `field` | `string` | State field name |
| `transitions` | `Record<string, string[]>` | Map of current state → allowed next states |
| `transitions` | `Record<string, string[]>` | Map of current state → allowed next states (enforced on **update**) |
| `initialStates` | `string[]` (optional) | States a record may be **created** in. Server-rejects an insert whose state field is outside this list — without it a `select` field permits any declared option as the initial value (a record could be born already `approved`). Omit to keep the legacy behavior (no initial-state check). |

`transitions` governs updates; `initialStates` governs the create. A rule may declare either or both.

### Cross-Field Validation

Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/data/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ const result = ConditionalValidation.parse(data);
| **type** | `'state_machine'` || |
| **field** | `string` || State field (e.g. status) |
| **transitions** | `Record<string, string[]>` || Map of `{ OldState: [AllowedNewStates] }` |
| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165. |


---
Expand Down Expand Up @@ -302,6 +303,7 @@ This schema accepts one of the following structures:
| **type** | `'state_machine'` || |
| **field** | `string` || State field (e.g. status) |
| **transitions** | `Record<string, string[]>` || Map of `{ OldState: [AllowedNewStates] }` |
| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165. |

---

Expand Down
57 changes: 57 additions & 0 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,4 +1557,61 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
expect(bulkUpdates[0].amount).toBe(50); // unlocked row: edit goes through
});
});

// #3165 — a state_machine `initialStates` rule constrains which state a record
// may be CREATED in, enforced end-to-end on the engine insert path (AFTER field
// defaults). Proves the FSM entry point, not just the transition check.
describe('state_machine initialStates enforcement on INSERT (#3165)', () => {
async function bootInsert() {
const inserts: Record<string, any>[] = [];
const mockDriver = {
name: 'sm-capture', version: '1.0.0',
connect: async () => {}, disconnect: async () => {},
find: async () => [], findOne: async () => null,
create: async (_o: string, d: any) => { inserts.push({ ...d }); return { id: 'rec-1', ...d }; },
update: async (_o: string, _i: any, d: any) => ({ id: _i, ...d }),
delete: async () => true, syncSchema: async () => {},
};
await kernel.use({
name: 'sm-capture-plugin', type: 'driver', version: '1.0.0',
init: async (ctx) => { ctx.registerService('driver.sm-capture', mockDriver); },
});
await kernel.use(new ObjectQLPlugin());
await kernel.bootstrap();
const objectql = kernel.getService('objectql') as any;
const obj: ObjectSchema = {
name: 'sm_request', label: 'SM Request', datasource: 'sm-capture',
fields: {
// approval status: a select with a draft default; readonly to users.
approval_status: { name: 'approval_status', label: 'Status', type: 'select', defaultValue: 'draft' } as any,
},
validations: [{
type: 'state_machine', name: 'approval_flow', message: 'A request must start as draft.',
field: 'approval_status', initialStates: ['draft'],
transitions: { draft: ['pending'], pending: ['approved', 'rejected'] },
}] as any,
};
objectql.registry.registerObject(obj, 'test', 'test');
return { objectql, inserts };
}

it('rejects a create born mid-flow (approval_status: approved)', async () => {
const { objectql, inserts } = await bootInsert();
await expect(objectql.insert(
'sm_request',
{ approval_status: 'approved' },
{ context: { userId: 'user-9' } },
)).rejects.toThrow(/must start as draft/);
expect(inserts.length).toBe(0); // nothing written
});

it('accepts a create in the declared initial state, and the defaulted value passes', async () => {
const { objectql, inserts } = await bootInsert();
await objectql.insert('sm_request', { approval_status: 'draft' }, { context: { userId: 'user-9' } });
// Omitted → defaultValue 'draft' is applied before validation and passes.
await objectql.insert('sm_request', {}, { context: { userId: 'user-9' } });
expect(inserts.length).toBe(2);
expect(inserts.map((r) => r.approval_status)).toEqual(['draft', 'draft']);
});
});
});
1 change: 1 addition & 0 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export interface FieldValidationError {
| 'invalid_type'
// Object-level validation rules (ADR-0020, see rule-validator.ts)
| 'invalid_transition'
| 'invalid_initial_state'
| 'rule_violation'
| 'invalid_format'
| 'invalid_json'
Expand Down
63 changes: 63 additions & 0 deletions packages/objectql/src/validation/rule-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,69 @@ describe('state_machine enforcement', () => {
});
});

// #3165 — the FSM entry point: `initialStates` constrains which state a record
// may be CREATED in (a `select` field alone would permit any declared option).
describe('state_machine initialStates enforcement on INSERT (#3165)', () => {
const approvalSchema = {
validations: [
{
type: 'state_machine' as const,
name: 'approval_flow',
field: 'approval_status',
message: 'A request must start as draft.',
initialStates: ['draft'],
transitions: {
draft: ['pending'],
pending: ['approved', 'rejected'],
},
},
],
};

it('allows an insert whose state is a declared initial state', () => {
expect(() =>
evaluateValidationRules(approvalSchema, { approval_status: 'draft' }, 'insert'),
).not.toThrow();
});

it('rejects an insert that is born mid-flow (approval_status: approved)', () => {
try {
evaluateValidationRules(approvalSchema, { approval_status: 'approved' }, 'insert');
throw new Error('expected throw');
} catch (e) {
const err = e as ValidationError;
expect(err).toBeInstanceOf(ValidationError);
expect(err.fields[0].code).toBe('invalid_initial_state');
expect(err.fields[0].field).toBe('approval_status');
expect(err.fields[0].message).toBe('A request must start as draft.');
}
});

it('is a no-op on insert when the field carries no value (required-validation owns presence)', () => {
expect(() =>
evaluateValidationRules(approvalSchema, { name: 'x' }, 'insert'),
).not.toThrow();
expect(() =>
evaluateValidationRules(approvalSchema, { approval_status: null }, 'insert'),
).not.toThrow();
});

it('does not affect UPDATE transitions (initialStates is insert-only)', () => {
// draft → pending is a declared transition; initialStates must not interfere.
expect(() =>
evaluateValidationRules(approvalSchema, { approval_status: 'pending' }, 'update', {
previous: { approval_status: 'draft' },
}),
).not.toThrow();
});

it('legacy no-op: a state_machine WITHOUT initialStates still allows any insert value', () => {
expect(() =>
evaluateValidationRules(accountSchema, { status: 'churned' }, 'insert'),
).not.toThrow();
});
});

describe('execution control', () => {
it('skips inactive rules', () => {
const schema = {
Expand Down
49 changes: 38 additions & 11 deletions packages/objectql/src/validation/rule-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
*
* - `state_machine` — the headline guardrail. On update, if the state field
* changed and the new value is not in `transitions[oldValue]`, the write
* is rejected. Needs the **prior** record (see plumbing note below).
* is rejected. Needs the **prior** record (see plumbing note below). On
* insert, if the rule declares `initialStates`, the created value must be one
* of them (the FSM entry point, #3165); otherwise insert is a no-op.
* - `script` / `cross_field` — CEL predicates. If the predicate evaluates
* TRUE the rule is violated. These share the prior-record gap with
* `state_machine` (a PATCH carries only changed fields), so they are
Expand Down Expand Up @@ -64,8 +66,9 @@
* `state_machine` and the field-spanning predicates are meaningful only with
* the record's prior state. The engine fetches it once (see
* `engine.update`) and threads it in via `opts.previous`. On `insert` there
* is no prior state, so `state_machine` is a no-op (the field-level select
* check already constrains the initial value to a declared option). On a
* is no prior state, so `state_machine`'s TRANSITION check is a no-op — but its
* `initialStates` entry-point check (#3165) does run against the created value.
* On a
* multi-row update the engine reads the row-scoped match set (the same AST the
* write binds, shared with the `readonlyWhen` bulk strip) and calls the
* evaluator once per matched row — one payload, N priors (#3106).
Expand All @@ -92,6 +95,9 @@ interface StateMachineRule extends BaseRule {
type: 'state_machine';
field: string;
transitions: Record<string, string[]>;
/** States a record may be CREATED in (#3165). When set, an insert whose state
* field value is outside this list is rejected — the FSM entry point. */
initialStates?: string[];
}

interface PredicateRule extends BaseRule {
Expand Down Expand Up @@ -586,22 +592,43 @@ function evaluateRule(rule: BaseRule, ctx: RuleContext): FieldValidationError |
}

/**
* State-machine transition check.
* State-machine check.
*
* Only meaningful on update with a prior record: if the state field changed,
* the new value must appear in `transitions[oldValue]`. Lenient where it
* cannot reason (no prior record, unchanged value, or a prior state with no
* declared transitions) so it never blocks legitimate or legacy data.
* On UPDATE (with a prior record): if the state field changed, the new value
* must appear in `transitions[oldValue]`. Lenient where it cannot reason (no
* prior record, unchanged value, or a prior state with no declared transitions)
* so it never blocks legitimate or legacy data.
*
* On INSERT: if the rule declares `initialStates` (#3165), the state field's
* (defaulted) value must be one of them — the FSM entry point, since a `select`
* field alone permits ANY declared option as an initial value (a record could
* otherwise be born already `approved`). Absent `initialStates`, insert is a
* no-op (legacy behavior); a missing/empty value is left to required-validation.
*/
function checkStateMachine(
rule: StateMachineRule,
mode: Mode,
data: Record<string, unknown>,
previous: Record<string, unknown> | undefined,
): FieldValidationError | null {
// Insert has no prior state — the field-level select check already
// constrains the initial value to a declared option.
if (mode === 'insert' || !previous) return null;
if (mode === 'insert') {
const initial = rule.initialStates;
if (!Array.isArray(initial) || initial.length === 0) return null; // no initial-state contract → legacy no-op
if (!(rule.field in data)) return null; // absent → required-validation's job
const value = data[rule.field];
if (value === undefined || value === null || value === '') return null; // empty → not an initial state to check
if (!initial.includes(String(value))) {
return {
field: rule.field,
code: 'invalid_initial_state',
message:
rule.message ||
`Invalid initial state for ${rule.field}: ${String(value)} (allowed: ${initial.join(', ')})`,
};
}
return null;
}
if (!previous) return null;
// The PATCH didn't touch the state field → no transition to validate.
if (!(rule.field in data)) return null;

Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/data/validation.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const StateMachineValidationSchema = lazySchema(() => BaseValidationSchem
type: z.literal('state_machine'),
field: z.string().describe('State field (e.g. status)'),
transitions: z.record(z.string(), z.array(z.string())).describe('Map of { OldState: [AllowedNewStates] }'),
initialStates: z.array(z.string()).optional().describe('States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165.'),
}));

/**
Expand Down