diff --git a/.changeset/state-machine-initial-states.md b/.changeset/state-machine-initial-states.md new file mode 100644 index 0000000000..4556cf1ce3 --- /dev/null +++ b/.changeset/state-machine-initial-states.md @@ -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. diff --git a/content/docs/data-modeling/validation.mdx b/content/docs/data-modeling/validation.mdx index 5ea64e470e..618a7c5d29 100644 --- a/content/docs/data-modeling/validation.mdx +++ b/content/docs/data-modeling/validation.mdx @@ -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'], @@ -168,14 +170,16 @@ Enforce allowed state transitions: cancelled: [], completed: [], }, - events: ['update'], } ``` | Property | Type | Description | | :--- | :--- | :--- | | `field` | `string` | State field name | -| `transitions` | `Record` | Map of current state → allowed next states | +| `transitions` | `Record` | 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 diff --git a/content/docs/references/data/validation.mdx b/content/docs/references/data/validation.mdx index ab0ceb2aef..e2e2d703d3 100644 --- a/content/docs/references/data/validation.mdx +++ b/content/docs/references/data/validation.mdx @@ -250,6 +250,7 @@ const result = ConditionalValidation.parse(data); | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | 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. | --- @@ -302,6 +303,7 @@ This schema accepts one of the following structures: | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | 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. | --- diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 4074c4257a..4032e20560 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -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[] = []; + 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']); + }); + }); }); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index d7e14e7587..1333c45557 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -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' diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 7b92f9e151..7ad7e1037d 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -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 = { diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index ef0c72512e..9a95b1a2bb 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -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 @@ -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). @@ -92,6 +95,9 @@ interface StateMachineRule extends BaseRule { type: 'state_machine'; field: string; transitions: Record; + /** 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 { @@ -586,12 +592,18 @@ 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, @@ -599,9 +611,24 @@ function checkStateMachine( data: Record, previous: Record | 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; diff --git a/packages/spec/src/data/validation.zod.ts b/packages/spec/src/data/validation.zod.ts index 649e0c2e20..d42757da54 100644 --- a/packages/spec/src/data/validation.zod.ts +++ b/packages/spec/src/data/validation.zod.ts @@ -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.'), })); /**