State Machines
BifrostQL can enforce row lifecycle rules from table metadata. A state machine names the state column, the initial insert state, the allowed states, and the transitions that may move a row from one state to another.
State machines run inside the mutation transformer pipeline, so direct GraphQL requests, workflow mutations, and generated UI calls all use the same checks.
Metadata
Section titled “Metadata”Add the state-machine keys to a table metadata rule:
main.members { state-column: status; initial-state: pending; states: pending,active,inactive,deceased; transitions: pending->active[officer,admin]@member.activated|active->inactive[officer,admin]@member.inactivated|inactive->active[admin]@member.reactivated|active->deceased[officer,admin]@member.deceased|inactive->deceased[officer,admin]@member.deceased}The keys are:
state-column: the column that stores the current lifecycle state.initial-state: the only state accepted when an insert supplies the state column.states: comma-separated allowed state names.transitions: allowed transitions infrom->to[roles]@eventform.
Use | between transitions in metadata rule strings. The rule parser already uses semicolons between properties, so pipe-separated transitions avoid ambiguity in appsettings.json and sample metadata files.
Enforcement
Section titled “Enforcement”For inserts, BifrostQL allows the configured initial state. For updates, it reads the current row, compares the existing state to the requested state, and accepts only a configured transition. Updating other fields without changing the state is allowed.
Role qualifiers in [] are checked against the authenticated user context. The default admin role bypasses policy and state-machine role checks, matching the rest of the authorization pipeline.
Rejected mutations return a generic error:
State transition is not permitted.The message intentionally omits table, state, and role details so raw GraphQL callers cannot use transition failures to discover the lifecycle configuration.
Reading available transitions
Section titled “Reading available transitions”A client shouldn’t have to hard-code which buttons to show — “Activate”, “Cancel”, “Reactivate” — or guess and get rejected. Every table with a state machine exposes an _availableTransitions field that returns the target states the current caller may move this row to, already filtered by the caller’s roles:
{ members { data { memberId status _availableTransitions # e.g. ["active", "inactive"] for an officer } }}_availableTransitions returns [String!] — the reachable next states from the row’s current state, with role checks applied. An admin sees the full set; a member with no qualifying role sees []. Drive your UI straight off this list so the actions a user sees always match what the server will accept.
Audit Trail
Section titled “Audit Trail”Accepted state transitions emit a StateTransitionInfo event after the database update succeeds. The default server observer writes an audit_log row through IBifrostWorkflowExecutor, so the audit insert still traverses BifrostQL’s normal GraphQL mutation pipeline.
The default audit action format is:
<entity>.<from>-><to>For example, changing members.status from pending to active writes members.pending->active.
Membership Manager Example
Section titled “Membership Manager Example”The Hosted SPA Membership Manager sample defines two lifecycle models:
main.members { state-column: status; initial-state: pending; states: pending,active,inactive,deceased; transitions: pending->active[officer,admin]@member.activated|active->inactive[officer,admin]@member.inactivated|inactive->active[admin]@member.reactivated|active->deceased[officer,admin]@member.deceased|inactive->deceased[officer,admin]@member.deceased}
main.events { state-column: status; initial-state: draft; states: draft,published,cancelled; transitions: draft->published[event_manager,admin]@event.published|published->cancelled[event_manager,admin]@event.cancelled|draft->cancelled[event_manager,admin]@event.cancelled}This keeps invalid shortcuts, such as members.pending -> deceased, out of the generated CRUD API while still allowing administrative repair paths like members.inactive -> active.