Vertep

Writing charts

A chart can be written as an SCXML document, a YAML document or Rust tokens in the statechart! macro. All three are read into the same chart IR, checked by the same validator and hashed to the same version. This page shows each surface, the same chart in all three, and the errors you get when a chart is wrong.

Several surfaces, one IR

The chart IR in vertep-core is the canonical chart: a tree of states with transitions, executable content, invokes and data, kept in document order. SCXML gives transition priority by document order, so the IR keeps that order as data. Every surface lowers to this IR, and every tool after that (the validator, the type checker, step, the RDF projection, the hash) works on the IR alone. So a chart behaves the same however it was written.

SCXML, YAML, the statechart! macro and include_chart! all lower to one chart IR; from the IR come the compiled chart, the RDF graph and the chart version IRI SCXML YAML statechart! include_chart! Chart IR vertep-core Compiled validate, step RDF graph vertep-rdf Version IRI sha256 of RDFC-1.0
Plate I. Four ways in, one IR. The version is the hash of the IR's canonical RDF form, not of the source text.
SurfaceReader and writerDefault datamodelRound trip
SCXML 1.0 with vt: extensionsvertep-scxml (read, write)null, as SCXML saysread(write(c)) == c for every chart the writer can represent
YAML 1.2vertep-yaml (read, write)celread(write(c)) == c for every chart with unique state ids
statechart!vertep-macros, re-exported by vertep-dm-rustrust (the only one it accepts)lowers to the IR at compile time
include_chart!the SCXML and YAML readers, run inside the macromust be rustsame typed API as statechart!
RDF (Turtle, TriG, N-Quads, N-Triples)vertep-rdffrom the chartsee RDF and versions

The vt command reads any of these files and picks the surface from the extension: .scxml or .xml, .yaml or .yml, .ttl, .trig, .nq, .nt. The commands are listed on The vt command.

SCXML

Vertep reads standard SCXML 1.0 documents and runs them with the W3C SCXML interpretation algorithm, checked against the W3C conformance suite (see Verification). Here is a small chart on the null datamodel, which has no data and accepts only In() conditions. Save it as door.scxml:

<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" name="door" initial="closed">
  <state id="closed">
    <transition event="open" target="opened"/>
    <transition event="lock" target="locked"/>
  </state>
  <state id="opened">
    <transition event="close" target="closed"/>
  </state>
  <state id="locked">
    <transition event="unlock" target="closed"/>
  </state>
</scxml>

Check it, then convert it to YAML:

$ vt validate door.scxml
door.scxml: ok: urn:vertep:chart:sha256:1dc511b5d015deca8f320c136c262267c95b7e2cba6f76173318ded0951f63cd
$ vt convert --to yaml door.scxml
vertep: 1
name: door
datamodel:
  value: "null"
  implicit: true
initial: closed
states:
  closed:
    on:
      open: opened
      lock: locked
  opened:
    on:
      close: closed
  locked:
    on:
      unlock: closed

The YAML spells the datamodel as { value: "null", implicit: true }. The SCXML document did not name a datamodel, and YAML's default is cel, not null. The implicit flag records that the source said nothing, so converting back gives the same document and the same IR. Both files hash to the same version:

$ vt convert --to yaml door.scxml -o door.yaml
$ vt hash door.scxml
urn:vertep:chart:sha256:1dc511b5d015deca8f320c136c262267c95b7e2cba6f76173318ded0951f63cd
$ vt hash door.yaml
urn:vertep:chart:sha256:1dc511b5d015deca8f320c136c262267c95b7e2cba6f76173318ded0951f63cd

Reading SCXML from Rust

The vertep facade re-exports the reader as vertep::scxml. CompiledChart::new runs the well-formedness validator and builds the tables step needs. From examples/traffic-light/main.rs:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Read the SCXML document into the chart IR, then validate and compile it.
    let parsed = vertep::scxml::read_str(CHART)?;
    let chart = CompiledChart::new(parsed.chart).map_err(|v| format!("invalid chart: {v:?}"))?;

    // 2. Start a session. The chart names the `null` datamodel (no data, only `In()` guards).
    let mut dm = NullDatamodel::new();
    let mut timers: Vec<Timer> = Vec::new();
    let mut now_ms = 0;
    let start = StartInput {
        info: SessionInfo {
            sessionid: "light-1".into(),
            ..SessionInfo::default()
        },
        ..StartInput::default()
    };
    let outcome = session::start(&chart, start, &mut dm).map_err(|e| format!("{e:?}"))?;

read_str uses default options, which accept only the null datamodel. For other datamodels pass a DatamodelSet, for example vertep_scxml::ReadOptions::default().with_datamodels(&vertep_scxml::AnyDatamodel), as crates/vertep-dm-rust/tests/rust_dsl.rs does. A src attribute on <script>, <data>, <content> or <invoke> is read only through a SourceResolver you supply; with none, every src is an error. The reader does no I/O of its own.

What SCXML loses, and the loss report

Reading returns the chart and a LossReport: everything the IR does not keep. Some losses are formatting (comments, processing instructions, the XML declaration, whitespace). Others are content: an unknown unprefixed attribute, an extension on an element that has no slot for one, a resolved src that was inlined. ReadOptions::strict(true) turns a non-empty report into an error. vt convert refuses a conversion that would lose content, and writes nothing:

$ vt convert --to yaml lossy.scxml
lossy.scxml: refusing to convert scxml to yaml: it would lose:
  - lossy.scxml:2:3: attribute `color` is not defined on <state>

A chart with only formatting losses converts. examples/traffic-light/traffic-light.scxml has comments and converts to YAML without complaint.

The vt: extension namespace

Vertep features ride in the namespace https://w3id.org/vertep/vt#, usually bound to the prefix vt. A vt: attribute or element becomes an extension node on its owner in the IR; it is not dropped. Elements in any other foreign namespace are kept the same way and listed in the loss report. A document with vt: extensions is still SCXML, so another SCXML processor can run it and ignore them. The extensions in use today, and where each is explained:

ExtensionWhereWhat it doesRead by
<vt:types>child of <scxml>declares named types and event payload typestypes
vt:type<data>, <send>, <param>the type of a data item, a send payload or a parametertypes
vt:defer="order.*"statematching external events wait in the mailbox while the state is activeruntime
vt:unhandled<scxml>discard, dead-letter or error for events no transition takesruntime
vt:restart, vt:restart-limit, vt:restart-window, vt:restart-backoff<invoke>restart policy (on-failure, always) for an invoked actorruntime
<vt:request>staterequest and reply with a timeout, lowered to a send, a waiting substate and two transitionsruntime
<vt:reminder>, <vt:migrate>executable contenta durable periodic reminder; a request to migrate to another chart versionruntime
vt:decide and vt:candidates, vt:fallback, vt:deadline, vt:maxDecisionsstatea choice point where a decider picks one enabled eventagents
<invoke type="vt:llm">, <vt:grant>, <vt:call>invoke, state, executable contentLLM actors, capability grants, plan-chart callsagents

The runtime lowers <vt:request>, <vt:reminder> and <vt:migrate> into plain SCXML before compiling (see crates/vertep-runtime/src/chart.rs). The chart version is computed on the chart as written, before that lowering.

YAML

The YAML surface is a thin mirror of the IR. States are keyed by id under states, and key order is document order. A document starts with vertep: 1 (the format version, required) and name. The reader is built on a YAML 1.2 parser that keeps key order and reports a line and column for every node. YAML 1.1 parsers were rejected because they read on as the boolean true, and on is the key of every state's transitions.

This is examples/typed-order/order.yaml, a typed chart on the CEL datamodel:

# A typed order chart in YAML on the CEL datamodel.
#
# `types` names record and union types, `events` gives every event its payload type and
# `context` gives every data item a type and an initial value. Guards and assignments are CEL
# expressions: `ctx` is the context, `ev.data` the event payload. `vt typecheck` checks every
# expression against these declarations before the chart ever runs.
#
# Illustrates the type-system capability (named types, typed events and context, Command Line
# Type Tools), yaml-format and cel-datamodel.
vertep: 1
name: order
datamodel: cel
types:
  Item: { sku: string, qty: int, price: decimal }
  Pay: { amount: decimal, method: { oneOf: { Card: { last4: string }, Invoice: null } } }
  Ship: { carrier: string, tracking: "optional<string>" }
events:
  add: Item
  checkout: null
  pay: Pay
  approve: null
  ship: Ship
  cancel: null
context:
  items: { type: "list<Item>", init: "[]" }
  total: { type: decimal, init: "0" }
  limit: { type: decimal, init: "500" }
initial: shopping
states:
  shopping:
    on: { cancel: cancelled }
    initial: cart
    states:
      cart:
        on:
          add:
            - cond: "ev.data.qty > 0"
              actions:
                - assign: { location: ctx.items, expr: "ctx.items + [ev.data]" }
                - assign: { location: ctx.total, expr: "ctx.total + ev.data.price * decimal(ev.data.qty)" }
          checkout:
            - { cond: "size(ctx.items) > 0", target: payment }
      payment:
        on:
          pay:
            - { cond: "ev.data.amount < ctx.total", target: payment }
            - { cond: "ctx.total > ctx.limit", target: review }
            - { target: fulfilment }
      review:
        on: { approve: fulfilment }
  fulfilment:
    on: { ship: shipped }
  shipped: { type: final }
  cancelled: { type: final }

The keys a state takes, from crates/vertep-yaml/src/lib.rs:

KeyMeaning
typeparallel, final or history (with history: shallow|deep); absent means a plain state
initiala child id, or a list of ids
onevent name to transition. A transition is a target string, a list of targets, or a mapping with target, cond, type and actions. A list of mappings is several transitions, in order.
alwayseventless transitions, in the same forms
entry, exitexecutable content
invoke, context, donedata, statesinvokes, state-local data, done data, children

Executable content is a list of mappings, each with exactly one key: assign, raise, send, log, if, foreach, cancel, script, call (a vt:call element) or extension (any other extension element). At the top level, types, events and context carry the types; Datamodels and types covers them.

Quote "null" when you mean the null datamodel. Unquoted, YAML reads it as a null value:

$ vt validate dn.yaml
dn.yaml:3:12: datamodel: expected a string, found null

The writer is canonical and byte-stable. Spellings such as implicit: true, generated: true on generated state ids, a transitions list that keeps an order on grouping would change, and extensions lists on every node exist so that no IR distinction is lost.

The statechart! macro

statechart! writes a chart in Rust tokens on the rust datamodel. Guards and actions are ordinary Rust functions, bound by name. The macro runs inside the compiler: it lowers the tokens to the IR, runs the same well-formedness validator and type checker the runtime uses, reports unreachable states and unhandled events, and generates a typed API. Every finding is a compile error or warning at the offending tokens. The macros are re-exported by vertep-dm-rust, so a crate needs only that dependency.

This is examples/rust-macro/main.rs, a turnstile:

use vertep_dm_rust::{MachineError, statechart};

statechart! {
    name: turnstile,
    types { Coin = { cents: int } }
    context { credit: int = 0, price: int = 50, passes: int = 0 }
    events { coin: Coin, push, shutdown }

    initial service;
    state service {
        initial locked;
        on shutdown => off;
        state locked {
            // Targetless: stay locked and count the coin.
            on coin / add_credit;
            // Eventless: unlock as soon as there is enough credit.
            always if paid => unlocked;
        }
        state unlocked {
            on coin / add_credit;
            on push => locked / let_through;
        }
    }
    final off;
}

/// Guard: guards get the context and the payload read-only, and return a bool.
fn paid(ctx: &Ctx, _: &()) -> bool {
    ctx.credit >= ctx.price
}

/// Action: actions may change the context; `Emit` would let them raise or send events.
fn add_credit(ctx: &mut Ctx, coin: &Coin, _: &mut Emit) {
    ctx.credit += coin.cents;
}

/// Action without an emitter: the shorter signature is accepted too.
fn let_through(ctx: &mut Ctx, _: &()) {
    ctx.credit -= ctx.price;
    ctx.passes += 1;
}

fn main() -> Result<(), MachineError> {
    println!("chart version {}", *VERSION);

    // `Machine::start` runs the start macrostep; the effects (none here) are returned.
    let (mut m, _effects) = Machine::start("turnstile-1")?;
    show("(start)", &m);

    let script = [
        Event::Coin { cents: 20 },
        Event::Push, // nothing happens: the turnstile is locked
        Event::Coin { cents: 50 },
        Event::Push,
        Event::Push,
        Event::Shutdown,
    ];
    for event in script {
        let label = format!("{event:?}");
        m.send(event)?;
        show(&label, &m);
    }
    assert!(m.is_terminated(), "`off` is a top-level final state");
    Ok(())
}
$ cargo run -p vertep-examples --bin rust-macro
chart version urn:vertep:chart:sha256:0b1614ef361262f9f6fec5189fa5834756f37808a3a466f81a9f83d6ed20e868
(start)                -> [Service, Locked]    credit=0   passes=0
Coin { cents: 20 }     -> [Service, Locked]    credit=20  passes=0
Push                   -> [Service, Locked]    credit=20  passes=0
Coin { cents: 50 }     -> [Service, Unlocked]  credit=70  passes=0
Push                   -> [Service, Locked]    credit=20  passes=1
Push                   -> [Service, Locked]    credit=20  passes=1
Shutdown               -> []                   credit=20  passes=1

The grammar

The full grammar is at the top of crates/vertep-macros/src/parse.rs. In short:

ItemForm
chart headername: order, (required) · datamodel: rust, · binding: early|late,
lint attributes#[deny(unreachable_states)], #[allow(unhandled_events)] (both warn by default)
declarationstypes { Pay = { amount: decimal } } · context { total: decimal = 0 } · events { start, pay: Pay }
statesstate s; · state s { … } · parallel p { … } · final f; · history h deep => t;
state bodyinitial a; · data { … } · entry a, b; · exit a; · invoke "type" src "…" id "…" autoforward;
transitionson pay if positive => payment / record_total; · always if ready => running; · targetless on coin / add_credit; · internal or external before /
guardsif name, if !name, if In(state)
actionsa function name · raise e · send e to "…" after "…" type "…" id "…" · log "…" · cancel "…"

A guard has the signature fn(&Ctx, &P) -> bool, where P is the event's payload type (() for none). It gets no mutable context and no emitter, so it cannot change anything. An action is fn(&mut Ctx, &P, &mut Emit), fn(&mut Ctx, &P) or fn(&mut Ctx, &P) -> Vec<E>; its only effects are context changes and emitted events. A function with the wrong signature is a type error at the guard's or action's name.

The macro generates, next to the chart: Ctx (the context struct), Event (one variant per event, carrying its payload fields), State (one variant per state, with parent(), children() and contains()), a struct per named type, CHART (the IR), VERSION (the chart version IRI), NAME, bind() (a registry of the chart's functions) and Machine, a typed session with start, send, active, is_in, context and is_terminated.

include_chart!

include_chart!("tests/charts/order.scxml") reads an SCXML (.scxml, .xml) or YAML (.yaml, .yml) file at compile time, relative to the crate's CARGO_MANIFEST_DIR, and generates the same API as statechart!. The crate is rebuilt when the file changes. The file must use the rust datamodel. Charts on other datamodels are loaded at run time with their readers instead.

One chart in three surfaces

The test order_chart_is_the_same_from_every_surface in crates/vertep-dm-rust/tests/rust_dsl.rs writes one order chart three times, and asserts that all three give the same IR, the same API and the same version IRI, and run the same. In the macro:

statechart! {
    name: order,
    datamodel: rust,
    types {
        Pay = { amount: decimal },
        Approval = { by: iri },
    }
    context { total: decimal = 0, limit: decimal = 100 }
    events { start, pay: Pay, authorized, approve: Approval, cancel }

    initial idle;
    state idle { on start => checkout; }
    state checkout {
        initial cart;
        on cancel => cancelled;
        state cart {
            on pay if positive => payment / record_total;
        }
        state payment {
            on authorized if over_limit => review;
            on authorized => paid;
        }
        state review { on approve => paid; }
    }
    final paid;
    final cancelled;
}

With these functions, shared by all three:

pub fn positive(_: &Ctx, ev: &Pay) -> bool {
    ev.amount > Decimal::ZERO
}
pub fn over_limit(ctx: &Ctx, _: &()) -> bool {
    ctx.total > ctx.limit
}
pub fn record_total(ctx: &mut Ctx, ev: &Pay, _: &mut Emit) {
    ctx.total = ev.amount;
}

In YAML, crates/vertep-dm-rust/tests/charts/order.yaml:

vertep: 1
name: order
datamodel: rust
types:
  Pay:
    amount: decimal
  Approval:
    by: iri
events:
  start: null
  pay: Pay
  authorized: null
  approve: Approval
  cancel: null
context:
  total:
    type: decimal
    init: "0"
  limit:
    type: decimal
    init: "100"
initial: idle
states:
  idle:
    on:
      start: checkout
  checkout:
    initial: cart
    on:
      cancel: cancelled
    states:
      cart:
        on:
          pay:
            cond: "vt:guard/order/positive"
            target: payment
            actions:
              - foreach:
                  array: "vt:action/order/record_total"
                  item: _vt_emit
                  do:
                    - send:
                        eventexpr: _vt_emit.event
                        targetexpr: _vt_emit.target
                        typeexpr: _vt_emit.type
                        delayexpr: _vt_emit.delay
                        content:
                          expr: _vt_emit.data
      payment:
        on:
          authorized:
            - cond: "vt:guard/order/over_limit"
              target: review
            - target: paid
      review:
        on:
          approve: paid
  paid:
    type: final
  cancelled:
    type: final

In SCXML, crates/vertep-dm-rust/tests/charts/order.scxml:

<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:vt="https://w3id.org/vertep/vt#" version="1.0" name="order" datamodel="rust" initial="idle">
  <vt:types xmlns:vt="https://w3id.org/vertep/vt#">type Pay = { amount: decimal };
type Approval = { by: iri };
event start;
event pay: Pay;
event authorized;
event approve: Approval;
event cancel;
</vt:types>
  <datamodel>
    <data id="total" expr="0" vt:type="decimal"/>
    <data id="limit" expr="100" vt:type="decimal"/>
  </datamodel>
  <state id="idle">
    <transition event="start" target="checkout"/>
  </state>
  <state id="checkout" initial="cart">
    <transition event="cancel" target="cancelled"/>
    <state id="cart">
      <transition event="pay" cond="vt:guard/order/positive" target="payment">
        <foreach array="vt:action/order/record_total" item="_vt_emit">
          <send eventexpr="_vt_emit.event" targetexpr="_vt_emit.target" typeexpr="_vt_emit.type" delayexpr="_vt_emit.delay">
            <content expr="_vt_emit.data"/>
          </send>
        </foreach>
      </transition>
    </state>
    <state id="payment">
      <transition event="authorized" cond="vt:guard/order/over_limit" target="review"/>
      <transition event="authorized" target="paid"/>
    </state>
    <state id="review">
      <transition event="approve" target="paid"/>
    </state>
  </state>
  <final id="paid"/>
  <final id="cancelled"/>
</scxml>

The SCXML and YAML show how the rust datamodel appears in the IR. A guard is a cond holding an IRI, vt:guard/<chart>/<function>. An action is a <foreach> whose array is the action's IRI, vt:action/<chart>/<function>. Evaluating the array runs the action, which may change the context, and yields the events it emitted; the body sends each one. This keeps every effect of an action visible in the IR and in the step output, without any change to the interpreter. The rules are in crates/vertep-dm-rust/src/lowering.rs.

$ vt hash order.scxml
urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f
$ vt hash order.yaml
urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f

The macro's VERSION constant is the same IRI; the test checks it.

The datamodel is part of the chart, so it is part of the version. The same states written once with CEL expressions and once with Rust functions are two charts with two versions.

Validation and well-formedness

A chart passes several checks before it can run. All of them report every error they find, with a location, instead of stopping at the first.

CheckWhereFinds
Readingvertep-scxml, vertep-yaml, the macro parsersyntax, unknown elements and keys, missing or exclusive attributes, duplicate state ids, unknown targets
Well-formednessvertep_core::validate, run by CompiledChart::newthe structural rules of SCXML (table below)
SHACL shapesvertep-rdf, run by vt validatethe same rules, stated over the RDF projection
Typesvertep_core::types::check_chart, run by vt typecheck, the loaders and the macropayload, context and expression types (see types)

A typo and a duplicate id, in SCXML:

$ vt validate door-typo.scxml; echo "exit $?"
door-typo.scxml:3:5: transition target names unknown state `opend`
door-typo.scxml:5:3: state id `closed` is already declared at 2:3
exit 1

The same typo in YAML. The YAML reader gives the dotted path of the node and suggests the closest state id:

$ vt validate door-typo.yaml
door-typo.yaml:8:13: states.closed.on.open.target: unknown target `opend`; did you mean `opened`?

Structural errors come from the core validator, and vt validate also checks the chart's RDF projection against the SHACL shapes. This chart puts only a history state in closed, a transition in a final state, and <donedata> in a state that is not final:

<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" name="door" initial="closed">
  <state id="closed">
    <history id="h"/>
    <transition event="open" target="opened"/>
  </state>
  <final id="opened">
    <transition event="close" target="closed"/>
  </final>
  <state id="x"><donedata><content expr="1"/></donedata><state id="y"/></state>
</scxml>
$ vt validate door-shapes.scxml
door-shapes.scxml:2:3: StateHistoryOnly at State { id: "closed" }
door-shapes.scxml:6:3: FinalContent { what: "transitions" } at State { id: "opened" }
door-shapes.scxml:9:3: DoneDataOutsideFinal at State { id: "x" }
door-shapes.scxml: shapes: <urn:vertep:chart:sha256:dcf27340493a3568d3813f8b980134e8e4475d58cbc59e85e9da0c3be3a3c807#s.opened> (vt:child|vt:transition|vt:invoke|vt:data|vt:initial|vt:initialElement): a final state has only onentry, onexit and donedata (MaxCountConstraintComponent)
door-shapes.scxml: shapes: <urn:vertep:chart:sha256:dcf27340493a3568d3813f8b980134e8e4475d58cbc59e85e9da0c3be3a3c807#s.closed> = <urn:vertep:chart:sha256:dcf27340493a3568d3813f8b980134e8e4475d58cbc59e85e9da0c3be3a3c807#s.closed>: a state's children cannot all be history states (OrConstraintComponent)
door-shapes.scxml: shapes: <urn:vertep:chart:sha256:dcf27340493a3568d3813f8b980134e8e4475d58cbc59e85e9da0c3be3a3c807#s.x> vt:donedata: donedata belongs to final states (MaxCountConstraintComponent)

vt validate prints core violations by their internal names (StateHistoryOnly, FinalContent), not as sentences. The table below says what each name means.

ViolationMeaning
EmptyChartthe chart has no states
DuplicateStateId, DuplicateDataIdtwo states, or two data items, share an id
UnknownTargeta target names no state
IllegalInitialTargetan initial target is not a proper descendant of its state
InitialAttributeAndElement, InitialOnNonCompound, IllegalInitialTransitionboth an initial attribute and element; an initial on a state without children; an <initial> transition with an event, a condition or no target
HistoryAtRoot, HistoryContent, HistoryTransitionCount, IllegalHistoryTransition, IllegalHistoryTarget, HistoryDefaultCyclemisplaced history states, history states with content or several transitions, a default transition that leaves the parent or loops back
FinalContent, DoneDataOutsideFinala final state with anything but onentry, onexit and donedata; donedata outside a final state
ParallelHistoryOnly, StateHistoryOnlya state whose only children are history states
IllegalTargetSetthe targets of one transition cannot be active together
ConflictingAttributesattributes or children that exclude each other, or a missing required one

Errors from the macro

In the macro, the same checks become compiler errors at the right span. These are the recorded outputs of the compile-fail tests in crates/vertep-dm-rust/tests/ui/. A misspelled target:

statechart! {
    name: order,
    events { pay }
    initial cart;
    state cart { on pay => paymnt; }
    state payment;
}
error: unknown target state `paymnt`; did you mean `payment`?
 --> tests/ui/unknown_target.rs:7:28
  |
7 |     state cart { on pay => paymnt; }
  |                            ^^^^^^

An event declared under #[deny(unhandled_events)] that no transition handles:

error: event `refund` is declared but no transition handles it
 --> tests/ui/unhandled_event.rs:6:19
  |
6 |     events { pay, refund }
  |                   ^^^^^^

A guard that asks for &mut Ctx, which guards may not have:

error[E0631]: type mismatch in function arguments
  --> tests/ui/guard_mut_ctx.rs:8:28
   |
 8 |     state open { on pay if counted => paid; }
   |                            ^^^^^^^ expected due to this
...
12 | fn counted(ctx: &mut Ctx, _: &()) -> bool {
   | ----------------------------------------- found signature defined here
   |
   = note: expected function signature `for<'a, 'b> fn(&'a Ctx, &'b ()) -> _`
              found function signature `fn(&mut Ctx, &()) -> _`
   = note: required for `for<'a, 'b> fn(&'a mut Ctx, &'b ()) -> bool {counted}` to implement `GuardFn<Ctx, ()>`
note: required by a bound in `Registry::<C>::guard`
  --> src/registry.rs
   |
   |     pub fn guard<P, F>(&mut self, iri: &str, f: F) -> Result<&mut Self, RegistryError>
   |            ----- required by a bound in this associated function
...
   |         F: GuardFn<C, P>,
   |            ^^^^^^^^^^^^^ required by this bound in `Registry::<C>::guard`
help: consider wrapping the function in a closure
   |
 8 |     state open { on pay if |arg0: &Ctx, arg1: &()| counted(&mut *arg0, arg1) => paid; }
   |                            +++++++++++++++++++++++        ++++++++++++++++++