Reacting to changes

← All sections · part of the machine-readable /all/ index.

Reacting to changes

Reacting to changes

Reference for Pagelove's automation primitives — triggers that fire before a request is processed, processors that fire after, outbound HTTP requests that can be dispatched from either, and the transition constraints and handlers that enforce a state machine over your data and announce its steps.

Pages in this group

See also

Processors

Processors

A processor fires after the server processes a request, before the response is sent to the client. Processors can inspect and modify the response — status code, headers, and body — or leave it unchanged.

Processors are stored as HTML Microdata items and discovered automatically via selector queries, the same way triggers are. Discovery is inheritance-aware: if a schema on your host declares a type with parent set to https://pagelove.org/Processor, items of that subtype are discovered and run exactly like plain processors.

Quick example

This processor intercepts 404 responses and replaces the body with a custom error page:

<div itemscope itemtype="https://pagelove.org/Processor">
  <meta itemprop="resource" content="*">
  <meta itemprop="method" content="GET">
  <meta itemprop="status" content="404">
  <div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
    <script itemprop="source" type="text/sessel">
      @schema Context url("https://pagelove.org/Context");
      Context.response.body = "<h1>Page not found</h1><p>Sorry, that page doesn't exist.</p>"
    </script>
  </div>
</div>

Request lifecycle

Processors sit near the end of the request lifecycle:

  1. Request arrives
  2. Trigger phase — matching triggers execute
  3. Core processing (GET, PUT, POST, DELETE, etc.)
  4. Processor phase — matching processors execute in order
  5. Async dispatch — queued outbound HTTP requests fire
  6. Response sent

Properties

All filter properties are optional. Omitting a filter means "match all."

resource

One or more glob patterns. The processor fires only when the request path matches at least one pattern. Same syntax as trigger resource.

<meta itemprop="resource" content="/blog/*">

method

HTTP methods to match. Same rules as trigger method.

<meta itemprop="method" content="GET">

selector

A CSS selector with semantic matching. Same behaviour as trigger selector.

status

HTTP status codes or class prefixes. The processor fires only when the response status matches one of the listed values.

<!-- Exact match -->
<meta itemprop="status" content="404">

<!-- Class prefix — matches any status 400-499 -->
<meta itemprop="status" content="4xx">

<!-- Multiple patterns (OR'd) — use separate elements -->
<meta itemprop="status" content="404">
<meta itemprop="status" content="410">

<!-- Match all errors -->
<meta itemprop="status" content="4xx">
<meta itemprop="status" content="5xx">

An empty or omitted status filter matches any status code.

when

A Sessel or JavaScript expression that gates execution. Same behaviour as trigger when, but with access to the response for status-conditional gating.

<div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Context url("https://pagelove.org/Context");
    Context.response.status == 404
  </script>
</div>

The JavaScript equivalent reads ctx.response instead of Context.response:

<div itemprop="when" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script type="module" itemprop="source">
    export default function(ctx) {
      return ctx.response.status === 404;
    }
  </script>
</div>

action

One or more actions, same types as trigger actions: Sessel, JavaScript, and outbound HTTP request.

<div itemprop="action" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script type="module" itemprop="source">
    export default function(ctx) {
      throw {
        schema_url: "https://pagelove.org/HTTPResponse",
        status: 200,
        body: "<p>Custom 404 page</p>"
      };
    }
  </script>
</div>

A JavaScript processor action cannot mutate the response directly (see Response-mutation asymmetry) — it must throw an HTTPResponse to replace it, the same shape used by trigger chain termination.

Context

Sessel expressions in processors have access to both Context.request and Context.response:

Property Type Description
Context.request.method String HTTP method
Context.request.path String Request path
Context.request.headers Map Request headers
Context.request.query Map Query string parameters
Context.response.status Number HTTP status code
Context.response.body String Response body (fully buffered)
Context.response.headers Map Response headers

JavaScript when/action modules receive the equivalent context as their first positional argument (conventionally named ctx):

Property Type Description
ctx.request Object Same shape as the trigger JavaScript context
ctx.response.status Number HTTP status code
ctx.response.body String Response body (fully buffered)
ctx.response.headers Object Response headers

Reading and writing the response

Both reading and writing use Context.response:

Context.response.status    // 404
Context.response.body      // "<p>Not Found</p>"

Context.response.status = 200
Context.response.body = "<p>Found it after all</p>"

Pass-through

If no Sessel action modifies any Context.response property, the original response is forwarded unchanged. Processors are pass-through by default — they only affect the response when an action explicitly writes to Context.response.status, Context.response.body, or Context.response.headers.

Response-mutation asymmetry

This direct-mutation style is Sessel-only. JavaScript processor actions run the same evaluation path used for when gates, which does not detect writes to ctx.response — assigning to it has no effect. A JavaScript processor action that needs to change the response must throw an HTTPResponse (see action above) rather than mutate ctx.response in place.

Writing from processors

Like triggers, processor Sessel actions can write to the database using Pagelove.PUT() and Pagelove.DELETE(). This, too, is Sessel-only — see Writing from triggers for the JavaScript alternative (an outbound HTTP request).

<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Pagelove url("https://pagelove.org/1.0");
    @schema AuditLog url("https://example.com/AuditLog");
    let log = new AuditLog { status: Context.response.status };
    Pagelove.PUT(log, "/audit/latest.html")
  </script>
</div>

Side-effect writes from processors follow the same semantics as trigger writes — see Writing from triggers for details.

Chain termination

A Sessel or JavaScript action can terminate the processor chain by throwing an HTTPResponse, the same mechanism as in triggers. The thrown response replaces whatever core produced.

Execution order

Same rules as triggers — lexicographic by document path, document order within each document.

Error handling

Same rules as triggers — malformed Microdata is skipped, Sessel or JavaScript runtime errors surface as error responses.

See also

Triggers

Triggers

A trigger fires before the server processes a request. Triggers can prepare state, write to transient elements, queue outbound HTTP requests, or terminate the request chain by throwing an HTTP response.

Triggers are stored as HTML Microdata items in the database and discovered automatically via selector queries. Any document on the host can contain trigger definitions. Discovery is inheritance-aware: if a schema on your host declares a type with parent set to https://pagelove.org/Trigger, items of that subtype are discovered and run exactly like plain triggers.

Quick example

This trigger fires on every PUT or POST to paths under /blog/ and sends a webhook notification:

<div itemscope itemtype="https://pagelove.org/Trigger">
  <meta itemprop="resource" content="/blog/*">
  <meta itemprop="method" content="PUT">
  <meta itemprop="method" content="POST">
  <div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
    <meta itemprop="url" content="https://hooks.example.com/notify">
    <meta itemprop="method" content="POST">
  </div>
</div>

Request lifecycle

Triggers sit at the beginning of the request lifecycle:

  1. Request arrives
  2. Trigger phase — matching triggers execute in order
  3. Core processing (GET, PUT, POST, DELETE, etc.)
  4. Processor phase — matching processors execute
  5. Async dispatch — queued outbound HTTP requests fire
  6. Response sent

If a trigger throws an HTTP response, steps 3--4 are skipped. The thrown response is returned directly.

Properties

All filter properties are optional. Omitting a filter means "match all."

resource

One or more glob patterns. The trigger fires only when the request path matches at least one pattern.

<meta itemprop="resource" content="/blog/*">
<meta itemprop="resource" content="/news/*">
<meta itemprop="resource" content="/announcements/*">

Multiple resource values are OR'd — any match is sufficient.

method

HTTP methods to match. The trigger fires only when the request method matches one of the listed values. Matching is case-insensitive. GET implies HEAD.

<meta itemprop="method" content="PUT">
<meta itemprop="method" content="POST">
<meta itemprop="method" content="DELETE">

selector

A CSS selector. The trigger fires only when the element targeted by the request appears in the set of elements selected by this selector.

Both the trigger's selector and the request's selector are resolved against the actual document (semantic matching). If the request does not carry a selector (e.g. a plain GET /page), the selector filter is not evaluated and the trigger matches as if the filter were omitted.

<meta itemprop="selector" content="article">

when

A Sessel or JavaScript expression that gates execution. If the expression evaluates to a falsy value (null, false, 0, empty string), the trigger is skipped. Omitting when means the trigger fires whenever its declarative filters match.

<div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Context url("https://pagelove.org/Context");
    Context.request.headers["Authorization"] != null
  </script>
</div>

The same gate written in JavaScript uses the https://pagelove.org/JavaScript/Module typed item instead — dispatch is by itemtype, not by the <script> tag:

<div itemprop="when" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script type="module" itemprop="source">
    export default function(ctx) {
      return ctx.request.method === "PUT";
    }
  </script>
</div>

The module's default export is called with the request context as its first positional argument — see Context below. this is not bound to anything meaningful for a when gate.

action

Actions to execute when the trigger fires. Actions execute in document order. Three action types are supported. A trigger needs at least one action or at least one otherwise (see below) — a trigger with neither is skipped:

<div itemprop="action" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script type="module" itemprop="source">
    export default function(ctx) {
      if (ctx.request.path.startsWith("/admin/")) {
        throw {
          schema_url: "https://pagelove.org/HTTPResponse",
          status: 403,
          message: "Admin area is restricted"
        };
      }
      return true;
    }
  </script>
</div>

A JavaScript action's return value is discarded — only side effects (an outbound request queued elsewhere in the chain, or a thrown HTTPResponse) have any effect. See Chain termination for the throw shape, and Writing from triggers for a capability JavaScript actions do not have.

<!-- Sessel action -->
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Context url("https://pagelove.org/Context");
    Context.request.headers["Authorization"] != null
  </script>
</div>

<!-- Outbound HTTP request action -->
<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
  <meta itemprop="url" content="https://hooks.example.com/notify">
  <meta itemprop="method" content="POST">
</div>

otherwise

One or more actions to execute when when evaluates to a falsy value, instead of action. Same action types, same document-order execution, same Context. otherwise is only meaningful alongside a when gate — if when is omitted, otherwise is never evaluated, since there is no falsy case for it to handle.

<div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Context url("https://pagelove.org/Context");
    Context.request.headers["Authorization"] != null
  </script>
</div>

<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">1</script>
</div>

<div itemprop="otherwise" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema HTTPResponse url("https://pagelove.org/HTTPResponse");
    throw new HTTPResponse { status: 401, body: "Authorization required" }
  </script>
</div>

This is a shorthand for the common "require X, otherwise reject" shape — it saves inverting the when condition and repeating it, and keeps the accepted and rejected paths next to each other in the markup.

Context

Sessel expressions in triggers have access to Context.request:

Property Type Description
Context.request.method String HTTP method (GET, PUT, POST, etc.)
Context.request.path String Request path
Context.request.headers Map Request headers
Context.request.query Map Query string parameters
Context.request.body String Request body (raw string)
Context.request.rawBody String Request body as raw UTF-8 bytes

Context.response is not available during trigger execution — the response does not exist yet.

JavaScript when/action modules receive the equivalent request context as their first positional argument (conventionally named ctx):

Property Type Description
ctx.request.method String HTTP method (GET, PUT, POST, etc.)
ctx.request.path String Request path
ctx.request.headers Object Request headers
ctx.request.host String The host name
ctx.request.query Object Query string parameters
ctx.request.body String Request body, when available
ctx.request.auth Object Authentication claims, when available

Chain termination

A Sessel action can terminate the trigger chain by throwing an HTTPResponse. The server does not process the request — the thrown response is returned directly to the client.

<div itemscope itemtype="https://pagelove.org/Trigger">
  <meta itemprop="method" content="PUT">
  <meta itemprop="method" content="POST">
  <meta itemprop="method" content="DELETE">
  <div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
    <script itemprop="source" type="text/sessel">
      @schema Context url("https://pagelove.org/Context");
      @schema HTTPResponse url("https://pagelove.org/HTTPResponse");
      if (Context.request.headers["Authorization"] == null) {
        throw new HTTPResponse {
          status: 403,
          message: "<p>Forbidden</p>"
        }
      }
    </script>
  </div>
</div>

This is the mechanism for request guards, authentication checks, and access control at the trigger level.

A JavaScript action terminates the chain the same way, by throwing a plain object with schema_url (or itemtype) set to https://pagelove.org/HTTPResponse:

<div itemprop="action" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script type="module" itemprop="source">
    export default function(ctx) {
      if (ctx.request.headers["Authorization"] == null) {
        throw {
          schema_url: "https://pagelove.org/HTTPResponse",
          status: 403,
          message: "Forbidden"
        };
      }
    }
  </script>
</div>

Supported properties on the thrown object: status (defaults to 500), message (fallback body when body is absent), body, and headers (an object of additional response headers).

Response headers

An HTTPResponse can include custom response headers using the header property with Pair values:

@schema HTTPResponse url("https://pagelove.org/HTTPResponse");
@schema Pair url("https://pagelove.org/Pair");
throw new HTTPResponse {
  status: 303,
  header: new Pair { key: "Location", value: "/other-page.html" }
}

Multiple headers can be set by repeating the header property (it has 0..n cardinality):

throw new HTTPResponse {
  status: 200,
  body: "

Hello

", header: new Pair { key: "X-Custom", value: "one" }, header: new Pair { key: "Cache-Control", value: "no-cache" } }

Writing from triggers

Trigger Sessel actions can write to the database using the Pagelove.PUT() and Pagelove.DELETE() methods. Import the Pagelove schema first:

This is a Sessel-only capability. JavaScript actions do not have access to Pagelove.PUT()/Pagelove.DELETE() — they run the same evaluation path used for when gates, without a write provider. A JavaScript action that needs to write should queue an outbound HTTP request back to the host instead.

<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Pagelove url("https://pagelove.org/1.0");
    @schema Thing url("https://schema.org/Thing");
    let item = new Thing { name: "side-effect-doc" };
    Pagelove.PUT(item, "/side-effects/record.html")
  </script>
</div>

Side-effect writes

Pagelove.PUT(item, "/path.html") writes a document to the specified path. The write goes through the normal request pipeline, including authorization checks and schema validation.

Body transformation

Pagelove.PUT(item, Context.request.path) writes to the same path as the incoming request. This replaces the request body in-flight — the server processes the transformed content instead of the original. No throw is needed; core processing runs normally on the modified body.

<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Pagelove url("https://pagelove.org/1.0");
    @schema Article url("https://schema.org/Article");
    let transformed = new Article {
      name: "transformed-by-trigger"
    };
    Pagelove.PUT(transformed, Context.request.path)
  </script>
</div>

Chaining

Each trigger in the chain sees the body as left by the previous trigger's Pagelove.PUT(). The last writer wins.

Error handling for writes

Side-effect writes committed via Pagelove.PUT(item, "/path") are not rolled back if a later trigger throws or errors. Body transformations via Pagelove.PUT(item, Context.request.path) are transient — they are discarded if the chain terminates with a throw or error.

Static vs dynamic properties

Any property on an outbound HTTP request action can be either a static literal or a dynamic Sessel or JavaScript expression:

<!-- Static URL -->
<meta itemprop="url" content="https://hooks.example.com/notify">

<!-- Dynamic URL (Sessel) -->
<div itemprop="url" itemscope itemtype="https://pagelove.org/Sessel">
  <script itemprop="source" type="text/sessel">
    @schema Context url("https://pagelove.org/Context");
    "https://hooks.example.com/" + Context.request.path
  </script>
</div>

<!-- Dynamic URL (JavaScript) -->
<div itemprop="url" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script type="module" itemprop="source">
    export default function(ctx) { return "https://hooks.example.com/" + ctx.request.path; }
  </script>
</div>

Declarative by default, imperative when needed.

Execution order

Within a single document, triggers execute in document order (top to bottom). When triggers are spread across multiple documents, documents are ordered lexicographically by path. Within each document, triggers execute in document order.

This produces a deterministic total ordering: triggers in /app/auth fire before triggers in /app/logging, and within each document, top to bottom.

Chain termination stops all subsequent triggers, including those from later documents.

Authentication example

A common pattern: a trigger fires on every request to check for a JWT and write identity claims to a transient element:

<div itemscope itemtype="https://pagelove.org/Trigger">
  <meta itemprop="method" content="GET">
  <meta itemprop="method" content="PUT">
  <meta itemprop="method" content="POST">
  <meta itemprop="method" content="DELETE">
  <div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
    <script itemprop="source" type="text/sessel">
      @schema Context url("https://pagelove.org/Context");
      Context.request.headers["Authorization"] != null
    </script>
  </div>
  <div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
    <script itemprop="source" type="text/sessel">
      @schema Context url("https://pagelove.org/Context");
      // Decode JWT claims and write to transient identity element
    </script>
  </div>
</div>

Authentication becomes another trigger — no separate login handler required.

Error handling

Malformed Microdata: If a trigger cannot be parsed from its Microdata (missing required fields, invalid structure), it is skipped. It does not break the request.

Sessel or JavaScript runtime error: If a when/action expression throws an unhandled error (one that isn't a recognized HTTPResponse shape), the request fails with an HTML Microdata error body describing the failure. Runtime errors are surfaced, not swallowed, regardless of which language raised them.

See also

Outbound HTTP requests

Outbound HTTP requests

An HttpRequest is an asynchronous outbound HTTP call, available as an action type in both triggers and processors. These actions are queued during trigger or processor execution and dispatched after the response is sent to the client. They are fire-and-forget — they do not block the response.

Quick example

Send a webhook notification when content under /blog/ is created or updated:

<div itemscope itemtype="https://pagelove.org/Trigger">
  <meta itemprop="resource" content="/blog/*">
  <meta itemprop="method" content="PUT">
  <meta itemprop="method" content="POST">
  <div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
    <meta itemprop="url" content="https://hooks.example.com/notify">
    <meta itemprop="method" content="POST">
    <meta itemprop="body" content="Blog content updated">
  </div>
</div>

Properties

url (required)

Target URL for the outbound request.

<meta itemprop="url" content="https://hooks.example.com/notify">

method

HTTP method. Defaults to POST. Supported methods: GET, POST, PUT, DELETE, PATCH.

<meta itemprop="method" content="POST">

content-type

Content-Type header sent with the request. Defaults to text/html.

<meta itemprop="content-type" content="application/json">

body

Request body content.

<meta itemprop="body" content="<p>Content was updated</p>">

An additional request header, written as a Pair of key and value. Repeat the property for as many headers as the request needs — this is how an outbound request carries an API token, a tenant identifier, or anything else the service you are calling expects.

<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
  <meta itemprop="url" content="https://api.postmarkapp.com/email">
  <meta itemprop="content-type" content="application/json">

  <div itemprop="header" itemscope itemtype="https://pagelove.org/Pair">
    <meta itemprop="key" content="X-Postmark-Server-Token">
    <meta itemprop="value" content="1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d">
  </div>
  <div itemprop="header" itemscope itemtype="https://pagelove.org/Pair">
    <meta itemprop="key" content="Accept">
    <meta itemprop="value" content="application/json">
  </div>
</div>

A header value can be dynamic, like any other property:

<div itemprop="header" itemscope itemtype="https://pagelove.org/Pair">
  <meta itemprop="key" content="X-Request-Path">
  <div itemprop="value" itemscope itemtype="https://pagelove.org/Sessel">
    <script itemprop="source" type="text/sessel">
      @schema Context url("https://pagelove.org/Context");
      Context.request.path
    </script>
  </div>
</div>

A header may also be written as a single line of text, split on its first colon:

<meta itemprop="header" content="X-Api-Version: 2">

Repeats are kept. The same field name may appear more than once, and headers are sent in the order you write them.

Content-Type has one home. If a header sets Content-Type, it replaces the content-type property rather than being sent alongside it, so the field never appears twice.

Malformed headers are dropped. A name that is not a valid HTTP field name, or a value containing control characters such as carriage return or line feed, is discarded rather than sent — including when a dynamic value produces one. The rest of the request is unaffected.

A header value written here lives in the document. An API token in a header is stored in the document that declares it, and is readable by anyone your authorization rules allow to read that document. Keep documents that carry credentials off any path that grants public read access.

Static vs dynamic properties

Any property can be either a static literal or a dynamic Sessel or JavaScript expression. This makes it possible to build request URLs and bodies from request context:

<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
  <!-- Static method -->
  <meta itemprop="method" content="POST">

  <!-- Dynamic URL built from request path (Sessel) -->
  <div itemprop="url" itemscope itemtype="https://pagelove.org/Sessel">
    <script itemprop="source" type="text/sessel">
      @schema Context url("https://pagelove.org/Context");
      "https://hooks.example.com/" + Context.request.path
    </script>
  </div>

  <!-- Dynamic body built from request data (Sessel) -->
  <div itemprop="body" itemscope itemtype="https://pagelove.org/Sessel">
    <script itemprop="source" type="text/sessel">
      @schema Context url("https://pagelove.org/Context");
      Context.request.path
    </script>
  </div>
</div>

The same properties accept a https://pagelove.org/JavaScript/Module typed item instead. The language is selected per property by its itemtype, so a single HttpRequest action can mix Sessel and JavaScript across different properties:

<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
  <!-- Dynamic URL built from request path (JavaScript) -->
  <div itemprop="url" itemscope itemtype="https://pagelove.org/JavaScript/Module">
    <script type="module" itemprop="source">
      export default function(ctx) { return "/api/" + ctx.request.path; }
    </script>
  </div>
  <meta itemprop="method" content="GET">
</div>

Retry

Requests are not retried unless you ask. retry defaults to 0, meaning the request is attempted once and abandoned if it fails:

<meta itemprop="retry" content="3">

That asks for 3 retries — 4 attempts in total. Retries use exponential backoff:

Attempt Delay before attempt
1 immediate
2 2 seconds
3 4 seconds
4 8 seconds
5 16 seconds

The maximum useful value of retry is 4, which gives 5 attempts in total. A larger number is not an error — it is clamped to 4 — so retry of 9 behaves exactly as 4. The backoff delay would be capped at 30 seconds, though the schedule above never reaches that cap. After the attempts are exhausted, the request is abandoned.

retry is a plain number: unlike url, body and header values, it cannot be a Sessel or JavaScript expression.

Failures do not affect the client response (already sent).

Error handling

See also

Transition constraints

Transition constraints

A transition constraint declares one permitted step of a state machine over your data — for example, that an order's status may move from pending to processing. Once any constraint watches a property, the server enforces the state machine on every write: a change nobody declared is rejected with 422 Unprocessable Entity, and nothing in the document changes.

Constraints are stored as HTML Microdata items, in any document on the host, and discovered automatically — the same way triggers are. Discovery is inheritance-aware: if a schema on your host declares a type with parent set to https://pagelove.org/TransitionConstraint, items of that subtype are enforced exactly like plain constraints.

Constraints validate writes. To be notified when a permitted change commits, pair them with a transition handler — the two are independent, and neither requires the other. For a worked end-to-end example, see Declaring a state machine.

Quick example

One step of an order lifecycle — status may move from pending to 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>

Properties

selector

Required. A CSS selector naming which items the rule watches. Pagelove selector extensions are allowed — :isa('https://example.com/Order') matches the type and any schema-declared subtype.

property

Required. The Microdata property that holds the state.

from and to

At least one must be present. A rule with both permits one step: the property may change from the from value to the to value.

Omitting one end declares a lifecycle boundary:

A constraint needs at least one of from and to; one that declares neither is ignored and never enforces anything (on hosts running the platform schemas, writing such a rule is rejected with 422). The empty string "" is an ordinary state value, distinct from absent: content="" permits transitions involving the literal empty-string state, and does not declare an entry or exit.

Strictness

Declaring even one constraint on a property makes the server strict about that property on matching items. Every appearance, change, and disappearance of the watched property must then match a declared rule; anything undeclared is rejected with 422. A write that does not change the watched value is not a transition and always passes.

Strictness is scoped by the constraint's own selector and property:

Strictness closes both ends of the lifecycle:

The exit rule has a consequence worth planning for: a fully constrained item cannot be deleted until some rule declares its exit. Without one, a DELETE of the document holding the item is rejected with 422. This is deliberate — otherwise a client could delete and recreate an item to skip the state machine. Declare an exit from every terminal state. If you forgot and the data is now stuck, see the WebDAV repair path.

The watched property must be single-valued

A state property must have exactly one value on an item — an item carrying several values for it has no well-defined state. A write that would produce that shape on a constrained item is rejected with 422, naming the item and property.

Item identity and @key

To validate a whole-document write, the server compares the stored document with the incoming one, and must decide which old item is which new item. Pairing uses the schema's primary key: a property annotated @key (which must also be individually unique). Two items of the same type are the same item when their key values match.

Three rules follow:

The fix for the ambiguous case is to declare a @key property in the type's schema, so every item carries a stable identity between writes. Selector-scoped writes name the exact element they change, so identity is inherent there — the pairing rules matter for whole-document writes.

The 422 body

A rejected transition returns 422 Unprocessable Entity with an HTML Microdata body in the platform's ConstraintViolation vocabulary — the same shape as uniqueness violations, with failedConstraint of the form transition(<property>):

<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' item 'SKU-B'
        property 'status' may not change from 'pending' to 'shipped'
        (constraint declared in '/transitions/rules.html')</span>
      <span itemprop="itemtype">https://example.com/Order</span>
      <span itemprop="property">status</span>
      <span itemprop="key">SKU-B</span>
      <span itemprop="from">pending</span>
      <span itemprop="to">shipped</span>
    </li>
  </ul>
</body>

Parse it with a microdata parser and read each violations item's itemtype, property, from, and to to drive your UI:

Concurrent writes

If two clients race to perform the same step — both trying to move the same order from pending to processing — exactly one wins. The loser's write is refused with 412 Precondition Failed rather than applied against stale state. On a 412, re-read the document and retry: the rules re-check against the current state, so a step that has already been taken then fails with 422 because the source state has moved on.

One consequence for selector-scoped writes: on a host with transition rules, an unconditional selector write touching watched items can return 412 where it would otherwise have been applied silently over a concurrent change. Handle 412 by re-reading and retrying, the same as a conditional write.

Taking effect

A constraint binds as soon as the document holding it is written through the serving path — the very next write validates against the new rules. Rules edited over WebDAV instead take effect within 60 seconds.

A stored rule missing its selector or property is ignored; a malformed rule never blocks writes.

WebDAV bypasses constraints

WebDAV authoring writes are never transition-validated. The authoring tier edits documents raw, by design — it is how humans edit content directly, and the escape hatch for repairing data that a state machine has wedged. See the repair path.

See also

Transition handlers

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