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:


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:

  1. Action receives request
  2. Action-level validators run against current record state
  3. apply block runs and emits events
  4. Events are applied to produce a new record state
  5. Invariants are evaluated against the new state
  6. 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

Aspectvalidateinvariant
Declared inModule level or inline in actionrecord block
Evaluated againstCurrent record state + paramsNew record state after events
ScopeSpecific action(s)All actions and events
PurposeGuard preconditionsGuard 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

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:

  1. Lookup -- Find all state machines that reference the emitted event name.
  2. Guard -- For each matching state machine, verify that the current field value matches the FromValue of at least one transition for that event.
  3. Apply -- Set the field to the ToValue of the matching transition.
  4. Apply event fields -- Apply any other fields carried by the event to the record.
  5. 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:

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:

  1. Validators guard action preconditions.
  2. State machines enforce legal transitions.
  3. 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:


Validation Execution Order

Understanding the order of evaluation helps you reason about error messages and side effects:

  1. Action-level validators run first, in declaration order. The first failure short-circuits and returns the validator name (or "validation failed" for inline validators).
  2. apply block runs. fail statements can halt execution at any point.
  3. State machine guards are checked for each emitted event. An illegal transition rejects the operation.
  4. Events are applied to the record, updating fields and advancing state machine fields.
  5. 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