Reacting to changes
← All sections · part of the machine-readable /all/ index.
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
- Processors — inspect or modify the response after a request completes
- Triggers — run a Sessel or JavaScript action before a write lands
- Outbound HTTP requests — asynchronous outbound calls dispatched from triggers and processors
- Transition constraints — declare a state machine over your data and reject writes that skip a step
- Transition handlers — notify an HTTP endpoint when a permitted state change commits
See also
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:
- Request arrives
- Trigger phase — matching triggers execute
- Core processing (GET, PUT, POST, DELETE, etc.)
- Processor phase — matching processors execute in order
- Async dispatch — queued outbound HTTP requests fire
- 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
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:
- Request arrives
- Trigger phase — matching triggers execute in order
- Core processing (GET, PUT, POST, DELETE, etc.)
- Processor phase — matching processors execute
- Async dispatch — queued outbound HTTP requests fire
- 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:
- Sessel — a synchronous expression with access to
Context.request
- JavaScript — a synchronous
export default function(ctx) module with access to the same request context (see Context)
- Outbound HTTP request — an asynchronous outbound call, queued for dispatch after the response is sent
<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).
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
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
- Transport errors (connection refused, timeout): retried according to the retry schedule —
or, with the default
retry of 0, not retried at all.
- Non-2xx responses: same — retried only as far as
retry asks.
- After the attempts are exhausted: the request is abandoned. No effect on the client.
See also
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:
to with no from is an entry rule — the property (or the whole item) may appear at that value. You need one to create items at all once the property is watched.
from with no to is an exit rule — the property (or the whole item) may be removed from that value. You need one to delete items at all once the property is watched.
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:
- properties nobody watches stay completely unconstrained
- a constraint on
status for orders says nothing about status on other types
- other properties on orders stay unconstrained too
Strictness closes both ends of the lifecycle:
- creating an item that carries the watched property — a new document, or the property appearing on an existing item — is an entry, and needs an entry rule
- removing the item or its watched property — including deleting the whole document — is an exit, and needs an exit rule
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 key may not change. A changed key — including an item gaining or losing its key value — reads as one item leaving and another arriving, and validates as an exit plus an entry, each needing its own rule.
- A lone keyless item still pairs. When there is only one item of a kind on each side, identity is unambiguous and the write validates normally.
- Ambiguity is rejected. Two or more keyless items of one type on either side, or a duplicate key value on one side, reject the write with 422 and a message saying the type needs a
@key property.
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:
- an entry violation has an empty
from; an exit violation has an empty to
key names the offending item when its type declares a @key, and is omitted entirely when there is none — absent and empty mean different things
message cites the document that declares the constraint, so you can find and fix the rule
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
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:
- it fires once per changed item — if one mutation changes several matching items, each gets its own firing with its own Transition document
- it fires for entries too: an item created directly in a watched state fires the handler
- exits never fire — there is no arrival value to match
becomes
- a write that leaves the watched value unchanged fires nothing
- repeated
becomes values are OR'd: list several to fire on any of them
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:
[itemtype='https://example.com/Order'] — exact type
:isa('https://example.com/Order') — the type or any schema-declared descendant
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.
- absent gate: always deliver
- falsy result: no delivery
- evaluation error: no delivery for that handler; the committed write is unaffected
- malformed gate (not a Sessel item, or no source): the handler never fires — a broken gate fails closed rather than becoming "always fire"
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="https://example.com/Order"]:has([itemprop="orderNumber"]:value-equals("10"))">
<div itemprop="body" itemscope itemtype="https://example.com/Order">
<meta itemprop="orderNumber" content="10">
<meta itemprop="status" content="processing">
</div>
</body>
</html>
path — the path of the modified document
selector — selects the changed item within that document. It is key-based when the item's schema declares a @key and the item carries a value for it; with no key the item was necessarily the only one of its kind in the document, so the selector addresses it by type
body — the changed element itself, as it was at the commit that fired the handler, embedded as a nested item: the element's own tag carries itemprop="body", so a microdata parse reads the item's type and properties directly
"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