Skip to content

Commit 04ecd4e

Browse files
os-zhuangclaude
andauthored
feat(validation): state_machine.initialStates enforces the FSM entry point on INSERT (#3165) (#3188)
A `state_machine` rule's `transitions` only governs UPDATE — on INSERT it 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 `evaluateValidationRules(..., 'insert')` seam the engine already runs after field defaults (engine.ts:2294). - spec: `StateMachineValidationSchema.initialStates` (additive, optional). - objectql: `checkStateMachine` insert branch + `invalid_initial_state` code. - docs: data-modeling/validation.mdx + regenerated reference. - tests: rule-validator unit (allowed/rejected/absent/update-unaffected/legacy no-op) + engine integration (real insert: 'approved' rejected & nothing written; 'draft' and defaulted 'draft' accepted). Claude-Session: https://claude.ai/code/session_01P5fRY4ctobCeFpBba1oGrz Co-authored-by: Claude <noreply@anthropic.com>
1 parent fefcd54 commit 04ecd4e

8 files changed

Lines changed: 188 additions & 13 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
feat(validation): `state_machine.initialStates` enforces the FSM entry point on INSERT (#3165)
7+
8+
A `state_machine` rule's `transitions` only governs UPDATE — on INSERT the rule
9+
was a no-op, and a `select` field permits ANY declared option as the initial
10+
value. So a record could be born mid-flow (created already `approved`), skipping
11+
the whole state machine. This was the gap #3043's mitigation idea assumed didn't
12+
exist (declared ≠ enforced, ADR-0049).
13+
14+
`state_machine` rules gain an optional `initialStates: string[]` — the states a
15+
record may be CREATED in. When set, an insert whose (defaulted) state-field value
16+
is outside the list is rejected server-side with `code: 'invalid_initial_state'`.
17+
Omit it to keep the legacy behavior (no initial-state check on insert). A missing
18+
/ empty value is left to required-validation; `transitions` (UPDATE) is
19+
unaffected. Enforced at the same `evaluateValidationRules(..., 'insert')` seam the
20+
engine already runs after field defaults.

content/docs/data-modeling/validation.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ Enforce allowed state transitions:
160160
severity: 'error',
161161
message: 'Invalid status transition',
162162
field: 'status',
163+
// Which states a record may be CREATED in (the FSM entry point).
164+
initialStates: ['draft'],
163165
transitions: {
164166
draft: ['submitted', 'cancelled'],
165167
submitted: ['approved', 'rejected', 'cancelled'],
@@ -168,14 +170,16 @@ Enforce allowed state transitions:
168170
cancelled: [],
169171
completed: [],
170172
},
171-
events: ['update'],
172173
}
173174
```
174175

175176
| Property | Type | Description |
176177
| :--- | :--- | :--- |
177178
| `field` | `string` | State field name |
178-
| `transitions` | `Record<string, string[]>` | Map of current state → allowed next states |
179+
| `transitions` | `Record<string, string[]>` | Map of current state → allowed next states (enforced on **update**) |
180+
| `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). |
181+
182+
`transitions` governs updates; `initialStates` governs the create. A rule may declare either or both.
179183

180184
### Cross-Field Validation
181185

content/docs/references/data/validation.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ const result = ConditionalValidation.parse(data);
250250
| **type** | `'state_machine'` || |
251251
| **field** | `string` || State field (e.g. status) |
252252
| **transitions** | `Record<string, string[]>` || Map of `{ OldState: [AllowedNewStates] }` |
253+
| **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. |
253254

254255

255256
---
@@ -302,6 +303,7 @@ This schema accepts one of the following structures:
302303
| **type** | `'state_machine'` || |
303304
| **field** | `string` || State field (e.g. status) |
304305
| **transitions** | `Record<string, string[]>` || Map of `{ OldState: [AllowedNewStates] }` |
306+
| **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. |
305307

306308
---
307309

packages/objectql/src/plugin.integration.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,4 +1557,61 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
15571557
expect(bulkUpdates[0].amount).toBe(50); // unlocked row: edit goes through
15581558
});
15591559
});
1560+
1561+
// #3165 — a state_machine `initialStates` rule constrains which state a record
1562+
// may be CREATED in, enforced end-to-end on the engine insert path (AFTER field
1563+
// defaults). Proves the FSM entry point, not just the transition check.
1564+
describe('state_machine initialStates enforcement on INSERT (#3165)', () => {
1565+
async function bootInsert() {
1566+
const inserts: Record<string, any>[] = [];
1567+
const mockDriver = {
1568+
name: 'sm-capture', version: '1.0.0',
1569+
connect: async () => {}, disconnect: async () => {},
1570+
find: async () => [], findOne: async () => null,
1571+
create: async (_o: string, d: any) => { inserts.push({ ...d }); return { id: 'rec-1', ...d }; },
1572+
update: async (_o: string, _i: any, d: any) => ({ id: _i, ...d }),
1573+
delete: async () => true, syncSchema: async () => {},
1574+
};
1575+
await kernel.use({
1576+
name: 'sm-capture-plugin', type: 'driver', version: '1.0.0',
1577+
init: async (ctx) => { ctx.registerService('driver.sm-capture', mockDriver); },
1578+
});
1579+
await kernel.use(new ObjectQLPlugin());
1580+
await kernel.bootstrap();
1581+
const objectql = kernel.getService('objectql') as any;
1582+
const obj: ObjectSchema = {
1583+
name: 'sm_request', label: 'SM Request', datasource: 'sm-capture',
1584+
fields: {
1585+
// approval status: a select with a draft default; readonly to users.
1586+
approval_status: { name: 'approval_status', label: 'Status', type: 'select', defaultValue: 'draft' } as any,
1587+
},
1588+
validations: [{
1589+
type: 'state_machine', name: 'approval_flow', message: 'A request must start as draft.',
1590+
field: 'approval_status', initialStates: ['draft'],
1591+
transitions: { draft: ['pending'], pending: ['approved', 'rejected'] },
1592+
}] as any,
1593+
};
1594+
objectql.registry.registerObject(obj, 'test', 'test');
1595+
return { objectql, inserts };
1596+
}
1597+
1598+
it('rejects a create born mid-flow (approval_status: approved)', async () => {
1599+
const { objectql, inserts } = await bootInsert();
1600+
await expect(objectql.insert(
1601+
'sm_request',
1602+
{ approval_status: 'approved' },
1603+
{ context: { userId: 'user-9' } },
1604+
)).rejects.toThrow(/must start as draft/);
1605+
expect(inserts.length).toBe(0); // nothing written
1606+
});
1607+
1608+
it('accepts a create in the declared initial state, and the defaulted value passes', async () => {
1609+
const { objectql, inserts } = await bootInsert();
1610+
await objectql.insert('sm_request', { approval_status: 'draft' }, { context: { userId: 'user-9' } });
1611+
// Omitted → defaultValue 'draft' is applied before validation and passes.
1612+
await objectql.insert('sm_request', {}, { context: { userId: 'user-9' } });
1613+
expect(inserts.length).toBe(2);
1614+
expect(inserts.map((r) => r.approval_status)).toEqual(['draft', 'draft']);
1615+
});
1616+
});
15601617
});

packages/objectql/src/validation/record-validator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export interface FieldValidationError {
7474
| 'invalid_type'
7575
// Object-level validation rules (ADR-0020, see rule-validator.ts)
7676
| 'invalid_transition'
77+
| 'invalid_initial_state'
7778
| 'rule_violation'
7879
| 'invalid_format'
7980
| 'invalid_json'

packages/objectql/src/validation/rule-validator.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,69 @@ describe('state_machine enforcement', () => {
240240
});
241241
});
242242

243+
// #3165 — the FSM entry point: `initialStates` constrains which state a record
244+
// may be CREATED in (a `select` field alone would permit any declared option).
245+
describe('state_machine initialStates enforcement on INSERT (#3165)', () => {
246+
const approvalSchema = {
247+
validations: [
248+
{
249+
type: 'state_machine' as const,
250+
name: 'approval_flow',
251+
field: 'approval_status',
252+
message: 'A request must start as draft.',
253+
initialStates: ['draft'],
254+
transitions: {
255+
draft: ['pending'],
256+
pending: ['approved', 'rejected'],
257+
},
258+
},
259+
],
260+
};
261+
262+
it('allows an insert whose state is a declared initial state', () => {
263+
expect(() =>
264+
evaluateValidationRules(approvalSchema, { approval_status: 'draft' }, 'insert'),
265+
).not.toThrow();
266+
});
267+
268+
it('rejects an insert that is born mid-flow (approval_status: approved)', () => {
269+
try {
270+
evaluateValidationRules(approvalSchema, { approval_status: 'approved' }, 'insert');
271+
throw new Error('expected throw');
272+
} catch (e) {
273+
const err = e as ValidationError;
274+
expect(err).toBeInstanceOf(ValidationError);
275+
expect(err.fields[0].code).toBe('invalid_initial_state');
276+
expect(err.fields[0].field).toBe('approval_status');
277+
expect(err.fields[0].message).toBe('A request must start as draft.');
278+
}
279+
});
280+
281+
it('is a no-op on insert when the field carries no value (required-validation owns presence)', () => {
282+
expect(() =>
283+
evaluateValidationRules(approvalSchema, { name: 'x' }, 'insert'),
284+
).not.toThrow();
285+
expect(() =>
286+
evaluateValidationRules(approvalSchema, { approval_status: null }, 'insert'),
287+
).not.toThrow();
288+
});
289+
290+
it('does not affect UPDATE transitions (initialStates is insert-only)', () => {
291+
// draft → pending is a declared transition; initialStates must not interfere.
292+
expect(() =>
293+
evaluateValidationRules(approvalSchema, { approval_status: 'pending' }, 'update', {
294+
previous: { approval_status: 'draft' },
295+
}),
296+
).not.toThrow();
297+
});
298+
299+
it('legacy no-op: a state_machine WITHOUT initialStates still allows any insert value', () => {
300+
expect(() =>
301+
evaluateValidationRules(accountSchema, { status: 'churned' }, 'insert'),
302+
).not.toThrow();
303+
});
304+
});
305+
243306
describe('execution control', () => {
244307
it('skips inactive rules', () => {
245308
const schema = {

packages/objectql/src/validation/rule-validator.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
*
1818
* - `state_machine` — the headline guardrail. On update, if the state field
1919
* changed and the new value is not in `transitions[oldValue]`, the write
20-
* is rejected. Needs the **prior** record (see plumbing note below).
20+
* is rejected. Needs the **prior** record (see plumbing note below). On
21+
* insert, if the rule declares `initialStates`, the created value must be one
22+
* of them (the FSM entry point, #3165); otherwise insert is a no-op.
2123
* - `script` / `cross_field` — CEL predicates. If the predicate evaluates
2224
* TRUE the rule is violated. These share the prior-record gap with
2325
* `state_machine` (a PATCH carries only changed fields), so they are
@@ -64,8 +66,9 @@
6466
* `state_machine` and the field-spanning predicates are meaningful only with
6567
* the record's prior state. The engine fetches it once (see
6668
* `engine.update`) and threads it in via `opts.previous`. On `insert` there
67-
* is no prior state, so `state_machine` is a no-op (the field-level select
68-
* check already constrains the initial value to a declared option). On a
69+
* is no prior state, so `state_machine`'s TRANSITION check is a no-op — but its
70+
* `initialStates` entry-point check (#3165) does run against the created value.
71+
* On a
6972
* multi-row update the engine reads the row-scoped match set (the same AST the
7073
* write binds, shared with the `readonlyWhen` bulk strip) and calls the
7174
* evaluator once per matched row — one payload, N priors (#3106).
@@ -92,6 +95,9 @@ interface StateMachineRule extends BaseRule {
9295
type: 'state_machine';
9396
field: string;
9497
transitions: Record<string, string[]>;
98+
/** States a record may be CREATED in (#3165). When set, an insert whose state
99+
* field value is outside this list is rejected — the FSM entry point. */
100+
initialStates?: string[];
95101
}
96102

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

588594
/**
589-
* State-machine transition check.
595+
* State-machine check.
590596
*
591-
* Only meaningful on update with a prior record: if the state field changed,
592-
* the new value must appear in `transitions[oldValue]`. Lenient where it
593-
* cannot reason (no prior record, unchanged value, or a prior state with no
594-
* declared transitions) so it never blocks legitimate or legacy data.
597+
* On UPDATE (with a prior record): if the state field changed, the new value
598+
* must appear in `transitions[oldValue]`. Lenient where it cannot reason (no
599+
* prior record, unchanged value, or a prior state with no declared transitions)
600+
* so it never blocks legitimate or legacy data.
601+
*
602+
* On INSERT: if the rule declares `initialStates` (#3165), the state field's
603+
* (defaulted) value must be one of them — the FSM entry point, since a `select`
604+
* field alone permits ANY declared option as an initial value (a record could
605+
* otherwise be born already `approved`). Absent `initialStates`, insert is a
606+
* no-op (legacy behavior); a missing/empty value is left to required-validation.
595607
*/
596608
function checkStateMachine(
597609
rule: StateMachineRule,
598610
mode: Mode,
599611
data: Record<string, unknown>,
600612
previous: Record<string, unknown> | undefined,
601613
): FieldValidationError | null {
602-
// Insert has no prior state — the field-level select check already
603-
// constrains the initial value to a declared option.
604-
if (mode === 'insert' || !previous) return null;
614+
if (mode === 'insert') {
615+
const initial = rule.initialStates;
616+
if (!Array.isArray(initial) || initial.length === 0) return null; // no initial-state contract → legacy no-op
617+
if (!(rule.field in data)) return null; // absent → required-validation's job
618+
const value = data[rule.field];
619+
if (value === undefined || value === null || value === '') return null; // empty → not an initial state to check
620+
if (!initial.includes(String(value))) {
621+
return {
622+
field: rule.field,
623+
code: 'invalid_initial_state',
624+
message:
625+
rule.message ||
626+
`Invalid initial state for ${rule.field}: ${String(value)} (allowed: ${initial.join(', ')})`,
627+
};
628+
}
629+
return null;
630+
}
631+
if (!previous) return null;
605632
// The PATCH didn't touch the state field → no transition to validate.
606633
if (!(rule.field in data)) return null;
607634

packages/spec/src/data/validation.zod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export const StateMachineValidationSchema = lazySchema(() => BaseValidationSchem
115115
type: z.literal('state_machine'),
116116
field: z.string().describe('State field (e.g. status)'),
117117
transitions: z.record(z.string(), z.array(z.string())).describe('Map of { OldState: [AllowedNewStates] }'),
118+
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.'),
118119
}));
119120

120121
/**

0 commit comments

Comments
 (0)