Declaring a state machine

This recipe shows how to declare a state machine over your data and have the server enforce it: which values a property may move between, what a client sees when it tries an illegal step, and how to hand permitted steps to an external worker. It builds a payment flow for an Order type end to end.

When to use this approach

Use a state machine when a property represents a lifecycle and the order of steps matters. An order that must pass through processing before it can be success. A ticket that cannot jump from open to archived without being closed first. The server enforces the steps on document writes through the serving path — whole-document and selector-scoped writes, deletes, and change-set merges alike — so an ordinary client cannot skip one.

The building blocks are two Microdata types:

They are independent — you can enforce without notifying, or notify without enforcing.

Declare the rules

Each constraint declares one permitted step. Store them in any document on the host — a dedicated rules document is a good habit:

<!-- /transitions/rules.html -->
<!DOCTYPE html>
<html><body>

<!-- Orders may be created at pending (entry rule: to, no from) -->
<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="to" content="pending">
</div>

<!-- pending -> processing -->
<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="from" content="pending">
  <meta itemprop="to" content="processing">
</div>

<!-- processing -> success -->
<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="from" content="processing">
  <meta itemprop="to" content="success">
</div>

<!-- success orders may be deleted (exit rule: from, no to) -->
<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="from" content="success">
</div>

</body></html>

PUT this document to the host and the rules bind immediately — the next write validates against them.

Declaring even one constraint on Order.status makes the server strict about that property: every appearance, change, and disappearance of status on an Order must now match a declared rule. That is why the first and last rules exist:

Properties nobody watches — total, or status on other types — stay completely unconstrained.

Seed an order

Create an order at pending. The entry rule permits it:

curl -X PUT https://myapp.example.com/orders/order-1.html \
  -H "Content-Type: text/html" \
  -d '<!DOCTYPE html>
<html><body>
<div id="order1" itemscope itemtype="https://example.com/Order">
  <meta itemprop="status" content="pending">
  <span itemprop="total">42.00</span>
</div>
</body></html>'

If a document will ever hold more than one Order, declare a @key property in the Order schema so the server can tell the items apart between writes. Without one, a write touching several same-typed items is rejected with a message saying the type needs a @key property.

Move the order to processing. The pending -> processing rule permits it. A selector-scoped PUT replaces just the order element and is validated the same as a whole-document write:

curl -X PUT https://myapp.example.com/orders/order-1.html \
  -H "Content-Type: text/html" \
  -H "Range: selector=#order1" \
  -d '<div id="order1" itemscope itemtype="https://example.com/Order">
  <meta itemprop="status" content="processing">
  <span itemprop="total">42.00</span>
</div>'

The write commits and, like any other mutation, is published on the SSE mutation stream — watching browsers see the status change without polling.

Watch the illegal step fail

Try to move a pending order straight to success. No rule declares that step, so the server rejects the write with 422 Unprocessable Entity and the document does not change. The body is HTML Microdata you can parse to drive your UI:

<body itemscope itemtype="https://pagelove.org/ConstraintViolation">
  <h1 itemprop="name">Unprocessable Entity</h1>
  <meta itemprop="statusCode" content="422">
  <p itemprop="description">Transition constraints violated</p>
  <ul>
    <li itemprop="violations" itemscope itemtype="https://pagelove.org/Violation">
      <span itemprop="constraintSelector">[itemprop='status']</span>
      <span itemprop="failedConstraint">transition(status)</span>
      <span itemprop="message">Transition violation: 'https://example.com/Order'
        property 'status' may not change from 'pending' to 'success'
        (constraint declared in '/transitions/rules.html')</span>
      <span itemprop="itemtype">https://example.com/Order</span>
      <span itemprop="property">status</span>
      <span itemprop="from">pending</span>
      <span itemprop="to">success</span>
    </li>
  </ul>
</body>

The message cites the document that declares the rules, so a confused client can find them. The full shape is on the transition constraints page.

If two clients race the same legal step, exactly one wins. The loser sees 412 Precondition Failed — re-read and retry, at which point the duplicate attempt fails 422 because the state has moved on.

Notify a worker

A transition handler closes the loop with an external worker. Add it to the rules document (or any document on the host). When any Order becomes processing, it fires with the changed element:

<div itemscope itemtype="https://pagelove.org/TransitionHandler">
  <meta itemprop="selector" content=":isa('https://example.com/Order')">
  <meta itemprop="property" content="status">
  <meta itemprop="becomes" content="processing">
  <div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
    <meta itemprop="url" content="https://worker.example.com/payments">
    <meta itemprop="method" content="POST">
  </div>
</div>

The handler's selector must name a type — [itemtype='…'] or :isa('…'). Class or structural selectors never fire a handler. The payload is a Transition document carrying the document's path, a selector addressing the changed item, and the item as it was at the commit, embedded as a nested microdata item the worker can parse directly.

Delivery is at-most-once: one background attempt after the commit, no retry, and a failed attempt loses the notification. That is fine here — a worker that misses a notification picks the order up on its next sweep, and a worker that gets a duplicate is protected by the state machine itself: its own report-back transition is rejected with 422 because the state has already moved on.

The worker processes the payment and reports back with an ordinary write — processing -> success is declared, so it commits:

curl -X PUT https://myapp.example.com/orders/order-1.html \
  -H "Content-Type: text/html" \
  -H "Range: selector=#order1" \
  -d '<div id="order1" itemscope itemtype="https://example.com/Order">
  <meta itemprop="status" content="success">
  <span itemprop="total">42.00</span>
</div>'

There is no separate state store and no separate reporting interface — the state machine, the notification, and the report-back are all ordinary documents and ordinary writes.

The delete surprise

Here is the surprise that catches most people. Suppose you had declared only the middle of the lifecycle:

<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="to" content="pending">
</div>
<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="from" content="pending">
  <meta itemprop="to" content="processing">
</div>
<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="from" content="processing">
  <meta itemprop="to" content="success">
</div>

Orders can be created, processed, and completed. But now try to delete a completed one:

curl -X DELETE https://myapp.example.com/orders/order-1.html

The delete is rejected with 422 — an exit violation, with from="success" and an empty to. Deleting the document removes the item, removing the item removes its status, and no rule permits status leaving success. Your completed orders are undeletable, by design: if deletion were free, a client could delete and recreate an order to skip the state machine.

The fix is an exit rule from every terminal state:

<div itemscope itemtype="https://pagelove.org/TransitionConstraint">
  <meta itemprop="selector" content="[itemtype='https://example.com/Order']">
  <meta itemprop="property" content="status">
  <meta itemprop="from" content="success">
</div>

Write that rule to the host and the same DELETE succeeds. Plan your exits when you plan your entries.

The two-orders-in-one-document wedge

A rule matches items, not documents. If a document already holds two items of the same type with no @key to tell them apart, the server cannot say which stored item each incoming item corresponds to. It refuses to guess. Every write to that document that touches a constrained property is rejected with 422 until the ambiguity is gone.

That is deliberate — guessing would let a client swap two orders' states and have both steps pass — but it can catch you out, because the data was written before you declared the rules. The document is stuck: you cannot fix it through the serving path, since the fix is itself a write to the document the rules now reject.

Two ways out:

Handler-only watches are not affected. With no constraint on the property, an ambiguous group is skipped rather than rejected, so the write still commits and no handler fires for those items.

The repair path: WebDAV

WebDAV authoring edits bypass state machines entirely: they are never transition-validated and never fire handlers. This is permanent and by design — the authoring tier is how humans edit content directly, and it is the escape hatch when a state machine wedges its own data.

Use it when:

Open the document over your WebDAV mount, edit or delete it, and save. The change lands in storage without tripping validation — and without firing handlers, so repairing an order does not trigger payment processing. The flip side: you cannot drive your state machine from the authoring tier. A worker waiting for becomes="processing" will not hear about a status set over WebDAV.

One timing difference: rules documents edited over WebDAV take effect within 60 seconds, where rules written through the serving path bind immediately.

See also