Skip to content

State machines

A state-machine field is a String field with an extra stateMachine config. The framework stamps the initial state on create, validates every change against the declared transitions, runs onEnter hooks, writes audit rows, emits webhooks, and exposes a transition action across REST, GraphQL, MCP, and the typed client.

{
name: 'status',
type: String,
stateMachine: {
initial: 'draft',
states: ['draft', 'review', 'approved', 'rejected', 'archived'],
transitions: {
draft: ['review', 'archived'],
review: ['approved', 'rejected'],
approved: ['archived'],
rejected: ['draft'],
},
onEnter: {
approved: async (record, ctx) => {
// Side effect, e.g. send notification.
},
},
},
}
Sub-keyDescription
initialStamped server-side on POST. Clients cannot pick a non-initial state on create.
statesRequired array. Becomes a literal union in the typed client.
transitionsMap of current -> allowed nexts.
onEnterMap of state -> async (record, ctx). Runs once per arrival. Errors are logged, never fail the mutation.

Multiple state-machine fields per schema operate independently — everything is per-field, not per-schema.

OperationBehaviour
POSTinitial is stamped. Client values for the SM field are ignored.
PUT / GraphQL update / MCP updateEach declared transition is validated against transitions[current]. Anything else surfaces as 400 INVALID_TRANSITION.
AuditEach successful transition writes a row with action: 'transition', the old and new state, the actor’s userId, and the field name.
WebhooksEmits a <path>.transitioned event in addition to the regular updated.
onEnter[state]Runs once per arrival, with (record, ctx). Best-effort: errors logged, never fail.
availableTransitionsVirtual attached on every read so clients render the right action buttons without re-parsing the schema.
{
"error": {
"code": "INVALID_TRANSITION",
"message": "Cannot transition status from 'review' to 'archived'",
"details": {
"field": "status",
"current": "review",
"attempted": "archived",
"allowed": ["approved", "rejected"]
}
}
}

Agents that read details.allowed can self-correct. The typed client’s DavepiError exposes the same shape.

Drive a transition by sending the new value through the standard update route. The framework validates the move before persisting:

PUT /api/v1/quote/abc
{ "status": "review" }

There’s no separate action endpoint — transitions go through the same PUT that any other field update would use.

A dedicated <path>Transition<Field>(_id, to) mutation is generated per state-machine field. The to argument is typed as the schema’s generated enum, so a typo on the wire is caught at validation time:

mutation {
quoteTransitionStatus(_id: "abc", to: review) {
record { _id, status, availableTransitions { status } }
}
}

The standard quoteUpdateById resolver also validates against the state machine when the field is set — the dedicated mutation is preferred, but you can’t bypass the transition graph through it.

There’s no dedicated transition tool. Send the new value through update_<path> — the framework runs the same validation:

{
"name": "update_quote",
"arguments": { "id": "abc", "record": { "status": "review" } }
}
await api.quote.transitionStatus(id, 'review');
// ^^^^^^^^
// 'review' is typed as a literal union of allowed states

The compiler catches typos: transitionStatus(id, 'reveiw') is a red squiggle.

{
"_id": "abc",
"status": "review",
"availableTransitions": {
"status": ["approved", "rejected"]
}
}

Clients render the right buttons without reading the schema. The shape is keyed by field name, so a record with two state-machine fields gets two arrays.

onEnter: {
approved: async (record, ctx) => {
await ctx.events.emit('quote.approved', { id: record._id });
await sendApprovalEmail(record);
},
},

ctx carries { user, log, events, models, schema } so the hook can do anything a regular handler does. Errors are logged but don’t fail the mutation — same posture as audit. If a hook must succeed before the transition is acknowledged, do the work in a custom route that wraps the transition.

A single schema can have multiple state-machine fields. Each is independent:

fields: [
{ name: 'editorialStatus', type: String, stateMachine: { /* ... */ } },
{ name: 'fulfillmentStatus', type: String, stateMachine: { /* ... */ } },
],

Each gets its own transitionEditorialStatus / transitionFulfillmentStatus typed client method, its own action route, its own MCP tool, and its own slot in availableTransitions.