You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: content/docs/data-modeling/validation.mdx
+6-2Lines changed: 6 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -160,6 +160,8 @@ Enforce allowed state transitions:
160
160
severity: 'error',
161
161
message: 'Invalid status transition',
162
162
field: 'status',
163
+
// Which states a record may be CREATED in (the FSM entry point).
164
+
initialStates: ['draft'],
163
165
transitions: {
164
166
draft: ['submitted', 'cancelled'],
165
167
submitted: ['approved', 'rejected', 'cancelled'],
@@ -168,14 +170,16 @@ Enforce allowed state transitions:
168
170
cancelled: [],
169
171
completed: [],
170
172
},
171
-
events: ['update'],
172
173
}
173
174
```
174
175
175
176
| Property | Type | Description |
176
177
| :--- | :--- | :--- |
177
178
|`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.
Copy file name to clipboardExpand all lines: content/docs/references/data/validation.mdx
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -250,6 +250,7 @@ const result = ConditionalValidation.parse(data);
250
250
|**type**|`'state_machine'`| ✅ ||
251
251
|**field**|`string`| ✅ | State field (e.g. status) |
252
252
|**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. |
253
254
254
255
255
256
---
@@ -302,6 +303,7 @@ This schema accepts one of the following structures:
302
303
|**type**|`'state_machine'`| ✅ ||
303
304
|**field**|`string`| ✅ | State field (e.g. status) |
304
305
|**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. |
field: z.string().describe('State field (e.g. status)'),
117
117
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.'),
0 commit comments