Transition handlers

A transition handler watches committed data changes: when a write through the serving path moves a watched property on a matching item to one of the handler's becomes values, the handler fires with a document describing the changed item. Use it to hand work to an external worker — a payment processor picking up orders as they become processing, for example.

Handlers are stored as HTML Microdata items, in any document on the host, and discovered automatically — the same way triggers are. Discovery is inheritance-aware: a subtype declared with parent set to https://pagelove.org/TransitionHandler fires exactly like the plain type.

Handlers are independent of transition constraints — neither requires the other. A handler may watch a property no constraint watches; every change to such a property is permitted, so the handler fires on all of them. For a worked example combining the two, see Declaring a state machine.

Quick example

When any order becomes processing, notify a payment worker:

<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>

When a handler fires

The handler fires after a permitted change commits and the watched property lands on one of the becomes values. In detail:

Firing never affects the write. The mutation is committed and the response determined before handlers run; nothing a handler does can fail or roll back the write.

If an item carries several values for the watched property, it has no well-defined state: the write still commits, and the handler does not fire for that item. (If a constraint also watches the property, the write is rejected instead — see the single-valued rule.)

The selector must name a type

A handler's selector is judged against the changed item's type alone. Only a selector branch that is entirely a type predicate can match:

A comma-separated selector list fires if any type branch accepts the item's type. A branch containing anything else — classes, ids, other attributes, combinators, other pseudo-classes — never fires the handler. A handler whose selector has no type branch can never fire in this version.

The when gate

An optional Sessel expression that gates delivery. It is evaluated against the Transition document itself: self is the Transition item. There is no Context.request or Context.response — no request is in scope when a handler fires.

The action

The action must be an outbound HTTP request. A Sessel or JavaScript action does not work here — there is no request context for it to run against — and a handler declaring one never fires.

Three HttpRequest properties have no effect on a transition handler, because the platform owns them here: retry, body, and content-type. The request body is always the Transition document, delivered as text/html.

What the action receives

The action's request body is a https://pagelove.org/Transition document describing the changed item:

<!DOCTYPE html>
<html>
  <head><title>Transition</title></head>
  <body itemscope itemtype="https://pagelove.org/Transition">
    <meta itemprop="path" content="/orders/order-1.html">
    <meta itemprop="selector"
          content="[itemtype=&quot;https://example.com/Order&quot;]:has([itemprop=&quot;orderNumber&quot;]:value-equals(&quot;10&quot;))">
    <div itemprop="body" itemscope itemtype="https://example.com/Order">
      <meta itemprop="orderNumber" content="10">
      <meta itemprop="status" content="processing">
    </div>
  </body>
</html>

"At the commit" matters: delivery can lag, and by the time the worker reads the document the item may have moved on. The handler for becomes="processing" receives the item as it was when it became processing.

Parse the Transition document with a standard microdata parser rather than depending on the exact serialization. One detail is worth knowing: an element carries at most one itemprop, so if the watched element was itself nested inside another item, its original itemprop is replaced by body on the delivered copy. Apart from that attribute (and attribute ordering), the delivered element is the stored one.

Delivery

The outbound call is made in the background after the commit, the same way trigger actions are dispatched — with one difference: no retry. Delivery is at-most-once: a single attempt, and a failed attempt (or a server restart at just the wrong moment) loses the notification. Plan for that: a handler tells a worker something has happened, and the worker must not depend on hearing it. retry on the action has no effect.

The action's url, method, and header values must all be plain text. A Sessel expression cannot be evaluated here, because no request is in scope when a handler fires. A handler whose action uses an expression anywhere does not fire at all, and no request is sent. That includes an expression in a single header: sending the rest of the request without it would quietly strip whatever the header carried, such as an Authorization credential.

A delivery sent to a Pagelove host is an ordinary write: the target host's own rules apply, so it can be rejected — a transition constraint on the target can 422 it, and the notification is then lost — and it can fire handlers in turn, including the one that sent it, because the Transition document contains the watched item at the watched state. The chain stops on its own (an unchanged value is not a transition), but the second delivery overwrites the first at the target path. Add a when gate — for example self.path == "/orders.html" — whenever a handler's action writes to its own host.

CRDT change-set PATCHes do not fire handlers in this version, though constraints do validate them.

WebDAV edits never fire handlers

Writes made through the WebDAV authoring tier never fire transition handlers, permanently and by design. Authoring edits do not drive your state machine: a worker waiting for becomes="processing" will not hear about a status set over WebDAV. This is what makes WebDAV safe as the repair path — fixing a wedged order does not trigger payment processing.

Duplicate deliveries

A worker may still receive the same logical notification twice, because two racing writes can each commit a matching change. When the handler is paired with constraints, the worker gets idempotency for free — it simply attempts its own transition, and the constraint rejects the second attempt with 422 because the state has already moved on.

See also