Validation & State Machines
Grove treats correctness as a first-class concern. Validation and state machines work together to guarantee that every event stored in the log represents a legal transition. This page covers every mechanism Grove provides for enforcing business rules -- from simple field checks to full entity lifecycle enforcement.
Prerequisites: Familiarity with records, events, and actions. The Build a Task Tracker tutorial covers the basics.
What you'll learn:
- How to write reusable
validateblocks - How to use inline validation inside actions
- How record
invariantblocks work - How to declare and enforce
statemachines - How state transitions interact with emitted events
- How to use the
failstatement for explicit error handling - Patterns for combining validation and state machines
Reusable Validate Blocks
A validate block is a named boolean expression declared at the module level.
It evaluates against the current state of the record through the record
root object.
validate has_assignee {
record.assignee != null
}
validate title_long_enough {
record.title.trim().len() >= 3
}
validate not_archived {
record.status != Status.archived
}
Referencing validators in actions
Actions reference validators by name. Multiple validators can be listed and all
must pass before the apply block runs.
action assign_reviewer {
reviewer: String
validate has_assignee
validate not_archived
apply {
emit reviewer_assigned {
reviewer: params.reviewer
}
}
}
When a validator fails the request is rejected and no events are emitted. The validator's name is included in the error response so the caller knows which rule was violated.
Composing complex conditions
Each validate block should express a single rule. Compose multiple validators
to build up complex precondition sets rather than writing one large expression.
validate has_title {
record.title.trim().len() > 0
}
validate has_description {
record.description.trim().len() > 0
}
validate has_priority {
record.priority != Priority.none
}
action publish_article {
validate has_title
validate has_description
validate has_priority
apply {
emit article_published {}
}
}
This approach gives callers specific, actionable error messages instead of a generic "validation failed."
Inline Validation in Actions
When a validation rule is specific to a single action, declare it inline. An
inline validate block is an anonymous boolean expression.
action set_due_date {
due_date: Date
validate {
params.due_date > meta.today()
}
apply {
emit due_date_set {
due_date: params.due_date
}
}
}
Inline validators have access to both the params root object (incoming
request) and the record root object (current entity state).
Mixing reusable and inline validation
You can combine named and inline validators freely within a single action. All validators -- named and inline -- must pass.
action escalate_ticket {
new_priority: Priority
validate not_archived
validate {
params.new_priority.ordinal() > record.priority.ordinal()
}
validate {
record.assignee != null
}
apply {
emit ticket_escalated {
priority: params.new_priority
}
}
}
The validators execute in declaration order. If the first fails the subsequent ones are not evaluated.
Record Invariants
Invariants are boolean expressions declared inside the record block. Unlike
action-level validators, invariants are checked after every event is applied
to the record. If any invariant evaluates to false the entire operation is
rolled back.
record {
kind "acct"
balance: Decimal = 0.00
name: String
email: String
invariant balance_non_negative {
record.balance >= 0
}
invariant name_not_blank {
record.name.trim().len() > 0
}
invariant email_format {
record.email.contains("@")
}
}
When invariants run
Invariants are evaluated against the record state that results from applying the emitted events. This means they protect the entity regardless of which action or event caused the change.
Consider this timeline:
- Action receives request
- Action-level validators run against current
recordstate applyblock runs and emits events- Events are applied to produce a new record state
- Invariants are evaluated against the new state
- If all invariants pass the events are committed; otherwise the operation is rejected
This two-phase approach lets you validate both the incoming request (validators) and the resulting state (invariants).
Invariants vs. validators
| Aspect | validate | invariant |
|---|---|---|
| Declared in | Module level or inline in action | record block |
| Evaluated against | Current record state + params | New record state after events |
| Scope | Specific action(s) | All actions and events |
| Purpose | Guard preconditions | Guard data integrity |
Use validators when a rule is a precondition for an action. Use invariants when a rule must always hold regardless of how the state changed.
State Machine Declarations
State machines formalize the legal transitions of an enum field. They are
declared inside the record block using the state keyword.
enum OrderStatus {
draft,
submitted,
approved,
fulfilled,
cancelled
}
record {
kind "order"
status: OrderStatus = OrderStatus.draft
state status {
OrderStatus.draft -> OrderStatus.submitted on order_submitted
OrderStatus.draft -> OrderStatus.cancelled on order_cancelled
OrderStatus.submitted -> OrderStatus.approved on order_approved
OrderStatus.submitted -> OrderStatus.cancelled on order_cancelled
OrderStatus.approved -> OrderStatus.fulfilled on order_fulfilled
OrderStatus.approved -> OrderStatus.cancelled on order_cancelled
}
}
Transition syntax
Each transition follows the pattern:
FromValue -> ToValue on event_name
- FromValue -- the enum value the field must currently hold.
- ToValue -- the enum value the field will be set to.
- event_name -- the event that triggers this transition.
Automatic enforcement
When an event is emitted, Grove checks whether the state machine has a transition for that event from the current field value. If no matching transition exists, the operation is rejected before the event is applied.
You do not need to write validators to guard state transitions. The state machine handles it:
action submit_order {
apply {
emit order_submitted {}
}
}
If the order is already in OrderStatus.approved, emitting order_submitted
fails because there is no transition from approved on order_submitted.
Multiple state machines
A record can declare multiple state machines on different fields. Each one is enforced independently.
enum PaymentStatus {
pending,
paid,
refunded
}
enum ShippingStatus {
waiting,
shipped,
delivered,
returned
}
record {
kind "ordr"
payment: PaymentStatus = PaymentStatus.pending
shipping: ShippingStatus = ShippingStatus.waiting
state payment {
PaymentStatus.pending -> PaymentStatus.paid on payment_received
PaymentStatus.paid -> PaymentStatus.refunded on payment_refunded
}
state shipping {
ShippingStatus.waiting -> ShippingStatus.shipped on item_shipped
ShippingStatus.shipped -> ShippingStatus.delivered on item_delivered
ShippingStatus.delivered -> ShippingStatus.returned on item_returned
}
}
Shared events across transitions
A single event can appear in multiple transitions. This is useful when an event can occur from several source states:
state status {
OrderStatus.draft -> OrderStatus.cancelled on order_cancelled
OrderStatus.submitted -> OrderStatus.cancelled on order_cancelled
OrderStatus.approved -> OrderStatus.cancelled on order_cancelled
}
The order_cancelled event is valid from draft, submitted, or approved
but not from fulfilled or cancelled.
How State Transitions Interact with Events
When an action emits an event that is named in a state machine transition, Grove performs the following steps in order:
- Lookup -- Find all state machines that reference the emitted event name.
- Guard -- For each matching state machine, verify that the current field
value matches the
FromValueof at least one transition for that event. - Apply -- Set the field to the
ToValueof the matching transition. - Apply event fields -- Apply any other fields carried by the event to the record.
- Invariants -- Evaluate all invariants against the new record state.
This means the state field is updated automatically -- you do not set it manually in the event. The event only needs to carry its own payload:
event order_approved {
approved_by: String
approved_at: DateTime
}
The status field transitions to OrderStatus.approved because the state
machine declares that transition on order_approved. You do not include
status in the event fields.
Error Handling with fail
The fail statement lets you reject an operation with an explicit error message
inside an apply block. Use it for conditional logic that goes beyond what
boolean validators can express.
action withdraw {
amount: Decimal
apply {
let new_balance = record.balance - params.amount
if new_balance < 0 {
fail "Insufficient funds: balance would be ${new_balance}"
}
emit withdrawal_made {
amount: params.amount
}
}
}
fail vs. validate
Use validate blocks when the check is a simple boolean precondition. Use
fail inside apply when:
- The check depends on a computed value (like
new_balanceabove). - You need a descriptive error message with interpolated values.
- The logic involves branching that cannot be expressed as a single boolean.
action transfer_ownership {
new_owner: String
apply {
if params.new_owner == record.owner {
fail "Cannot transfer to the current owner"
}
if record.status == Status.locked {
fail "Cannot transfer a locked record"
}
emit ownership_transferred {
new_owner: params.new_owner
}
}
}
Each fail statement immediately halts execution and rejects the operation. No
events emitted before the fail are committed.
Combining Validation and State Machines
The most robust entity lifecycles layer all three mechanisms:
- Validators guard action preconditions.
- State machines enforce legal transitions.
- Invariants protect data integrity after every write.
Here is a complete example that combines all three:
module subscription
enum Plan {
free,
basic,
premium,
enterprise
}
enum SubStatus {
trial,
active,
past_due,
cancelled,
expired
}
record {
kind "sub"
plan: Plan = Plan.free
status: SubStatus = SubStatus.trial
seats: Int = 1
billing_email: String
trial_ends_at: Date
invariant seats_positive {
record.seats > 0
}
invariant billing_email_valid {
record.billing_email.contains("@")
}
invariant max_seats_per_plan {
match record.plan {
Plan.free => record.seats <= 1
Plan.basic => record.seats <= 5
Plan.premium => record.seats <= 50
Plan.enterprise => true
}
}
state status {
SubStatus.trial -> SubStatus.active on subscription_activated
SubStatus.trial -> SubStatus.expired on trial_expired
SubStatus.active -> SubStatus.past_due on payment_failed
SubStatus.active -> SubStatus.cancelled on subscription_cancelled
SubStatus.past_due -> SubStatus.active on payment_recovered
SubStatus.past_due -> SubStatus.cancelled on subscription_cancelled
}
}
validate is_active {
record.status == SubStatus.active
}
validate is_not_free {
record.plan != Plan.free
}
event subscription_activated {}
event trial_expired {}
event payment_failed {}
event payment_recovered {}
event subscription_cancelled { reason: String = "" }
event plan_changed {
plan: Plan
}
event seats_updated {
seats: Int
}
action activate {
validate {
record.status == SubStatus.trial
}
validate {
meta.today() <= record.trial_ends_at
}
apply {
emit subscription_activated {}
}
}
action change_plan {
plan: Plan
validate is_active
validate {
params.plan != record.plan
}
apply {
if params.plan == Plan.free && record.seats > 1 {
fail "Reduce seats to 1 before downgrading to the free plan"
}
emit plan_changed {
plan: params.plan
}
}
}
action update_seats {
seats: Int
validate is_active
validate is_not_free
validate {
params.seats > 0
}
apply {
emit seats_updated {
seats: params.seats
}
}
}
action cancel {
reason: String = ""
apply {
emit subscription_cancelled {
reason: params.reason
}
}
}
In this example:
- Validators ensure the subscription is active before allowing plan changes or seat updates, and prevent changing to the same plan.
- The state machine prevents impossible transitions like cancelling an already-expired trial.
- Invariants guarantee seats stay positive, the billing email is valid, and the seat count respects plan limits -- no matter which action caused the change.
failinchange_planenforces a rule that depends on computed logic (checking the combination of the new plan and the current seat count).
Validation Execution Order
Understanding the order of evaluation helps you reason about error messages and side effects:
- Action-level validators run first, in declaration order. The first failure short-circuits and returns the validator name (or "validation failed" for inline validators).
applyblock runs.failstatements can halt execution at any point.- State machine guards are checked for each emitted event. An illegal transition rejects the operation.
- Events are applied to the record, updating fields and advancing state machine fields.
- Invariants are evaluated against the new record state. Any failure rolls back all emitted events.
Common Patterns
Guard with validator, enforce with state machine
Use validators to give clear error messages for expected cases. Let the state machine catch anything unexpected as a safety net.
validate can_approve {
record.status == OrderStatus.submitted
}
action approve_order {
validate can_approve
apply {
emit order_approved {
approved_by: meta.user()
approved_at: meta.now()
}
}
}
The validator gives a clear rejection when someone tries to approve a draft. The state machine independently prevents approval from any non-submitted state.
Temporal invariants
Use meta.today() or meta.now() in invariants for time-based constraints:
invariant not_expired {
record.expires_at == null || record.expires_at > meta.now()
}
Conditional invariants
Use boolean logic to make invariants conditional on state:
invariant paid_orders_have_receipt {
record.payment != PaymentStatus.paid || record.receipt_id != null
}
This reads as: if payment is paid, then receipt_id must not be null.
See Also
- Build a Task Tracker -- project-based tutorial using validation and state machines
- Expressions Deep Dive -- full expression reference for use in validators and invariants
- Events & Actions -- detailed guide to event and action declarations
- Testing -- how to test validation rules and state transitions