This is a guide for a host application author who wants to reach real
computation - a database call, a background job, an LLM agent loop, an
external API - from an SCXML document's <invoke> element. It does not
re-explain the interpreter's architecture; see docs/architecture.md for
that. It shows you how to write and register a handler.
What the seam is for
docs/datamodel.md names the reason Statifier's datamodel stays
non-evaluative rather than chasing ECMAScript:
Real computation belongs in the host application, reached through
<invoke>handlers and external<send>- controlled, typed, supervised.
Until now that sentence named an intention with no destination. This document
is the destination: a Statifier.Invoke.Handler is how your application
registers itself to serve an <invoke type="..."> value the built-in engine
does not already know (scxml and its long-URI spelling,
http://www.w3.org/TR/scxml/, are the only types shipped in the library
itself). If what you are after is a durable <send delay> rather than an
<invoke>, that is a different seam - see docs/durable-timers.md.
Writing a Statifier.Invoke.Handler
A handler is a module implementing the Statifier.Invoke.Handler behaviour:
three required callbacks and one optional one.
@callback start(invoke :: Statifier.Effect.Invoke.t(), ctx :: Statifier.Invoke.Handler.ctx()) ::
{:ok, [instruction()]} | {:error, term()}
@callback cancel(invoke_id :: String.t(), ctx :: Statifier.Invoke.Handler.ctx()) ::
{:ok, [instruction()]}
@callback forward(invoke_id :: String.t(), event :: Statifier.Event.t(), ctx :: Statifier.Invoke.Handler.ctx()) ::
{:ok, [instruction()]}
@callback perform(instruction :: instruction(), ctx :: Statifier.Invoke.Handler.ctx()) ::
:ok | {:error, term()}start/2, cancel/2, and forward/3 are pure. They run inside
Statifier.Session.Effects.plan/2's own fold, alongside the planning for
every other effect - no process, no clock, no I/O. They decide what should
happen and return a list of instructions describing it; they never perform
anything themselves. This is what lets a durable host that drives
Statifier.Interpreter directly, with no Statifier.Session process at all,
plan invocations the same way Statifier.Session does.
perform/2 is the impure half - the only callback allowed to touch the
outside world. An executor (Statifier.Session is one) calls it to actually
carry out one of the instructions a planning callback returned. It is
optional: a handler whose planning callbacks never return one of its own
instructions needs no perform/2 at all.
The instruction vocabulary a planning callback returns is opaque outside the
library, with one exception a handler author needs: {:handler, __MODULE__, payload}. Returning this instruction from start/2 (or cancel/2,
forward/3) is how you hand work to your own perform/2 - payload is
whatever your perform/2 clause needs to do it.
ctx is a plain map, not a struct, handed to every planning callback
unchanged:
%{session_id: session_id, invoke_types: invoke_types, invoke_handlers: invoke_handlers}The field a handler author actually reaches for is session_id (spec 5.10's
_sessionid) - useful when the external system you are calling needs to know
who is asking. ctx carries no pid, no %Statifier.MachineState{}, and no
session struct, so a handler cannot reach into Statifier.Session internals
through it. Per-invocation identity - invoke_id, type, src, params,
content - is read off the %Statifier.Effect.Invoke{} struct start/2
already receives as its own argument.
A complete worked example
Here is a minimal handler for an invented type, "myapp:authorize", that
hands a card authorization to a background job system and reports the
approval or decline later:
defmodule MyApp.AuthorizeHandler do
@moduledoc """
Serves `<invoke type="myapp:authorize">` by enqueuing a background job
that authorizes a card transaction against the account's remaining budget.
"""
@behaviour Statifier.Invoke.Handler
alias Statifier.Effect.Invoke
@impl Statifier.Invoke.Handler
def start(%Invoke{invoke_id: invoke_id, params: params}, ctx) do
{:ok, [{:handler, __MODULE__, {invoke_id, ctx.session_id, params}}]}
end
@impl Statifier.Invoke.Handler
def cancel(invoke_id, _ctx) do
{:ok, [{:handler, __MODULE__, {:cancel, invoke_id}}]}
end
@impl Statifier.Invoke.Handler
def forward(_invoke_id, _event, _ctx) do
# This handler's jobs do not accept autoforwarded events.
{:ok, []}
end
@impl Statifier.Invoke.Handler
def perform({invoke_id, session_id, params}, _ctx) do
# MUST be idempotent on invoke_id - see "At-least-once" below.
MyApp.Jobs.AuthorizeJob.enqueue_idempotent(invoke_id, session_id, params)
end
def perform({:cancel, invoke_id}, _ctx) do
MyApp.Jobs.AuthorizeJob.cancel(invoke_id)
:ok
end
endWhen the background job finishes - possibly minutes or days later, possibly
from a different node entirely - it reports back through
Statifier.Session.done_invocation/3 (see below), not through this module.
Registering a handler
Handlers are registered per session, on Statifier.Session.start_link/2:
Statifier.Session.start_link(machine,
invoke_handlers: %{"myapp:authorize" => MyApp.AuthorizeHandler}
):invoke_handlers is a %{type_string => module} map. The default is %{},
which registers no type beyond the built-in scxml/bare-URI set - passing
nothing changes no observable behavior (ADR-0051). A session that drives a
different chart registers a different palette - a signup wizard running an
A/B test reaches for variant assignment and conversion recording rather than
anything to do with cards:
Statifier.Session.start_link(wizard_machine,
invoke_handlers: %{
"myapp:assign_variant" => MyApp.AssignVariantHandler,
"myapp:signup" => MyApp.SignupStepHandler,
"myapp:conversion" => MyApp.ConversionHandler
}
)Registration is per session, not global, on purpose: a multi-tenant host that runs different chart deployments for different tenants can give each session a different handler palette, rather than every deployed handler being reachable from every session process-wide.
The set is also fixed for the session's whole lifetime: it is a
start_link/2 option, exactly like :max_macrostep_rounds, not something
re-stamped or re-registered mid-session. A host that needs a different
handler palette starts a different session with a different
:invoke_handlers map; there is no supported way to add or remove a handler
from a session already running.
Async and long-lived invocations
invoke_id stays stable across a persist/reload cycle because it is not a
freshly generated value - it is a deterministic counter carried on
%Statifier.MachineState{} (ADR-0008, as amended). Replaying the same drive
from the same persisted position always produces the same invoke_id for the
same <invoke> element, so a done.invoke.<id> that arrives minutes or days
after start/2 planned it is still addressing a stable, recognizable name.
For what "persisted position" means safely - the reload has to land on the
same chart revision it was saved against, or fail loudly rather than resume
the wrong states - see docs/persistence.md.
The door your host uses to report completion is
Statifier.Session.done_invocation/3:
@spec done_invocation(server :: server(), invoke_id :: String.t(), donedata :: term()) :: :ok
def done_invocation(server, invoke_id, donedata \\ nil)Call it with the owning session (the one whose <invoke> started the work,
never a child of it - a handler-backed invocation has no child session at
all) and the invoke_id your start/2 was handed. It constructs
done.invoke.<invoke_id> from donedata and delivers it exactly as an
ordinary invoked event, subject to the same drain-time discard as any other
invocation-tagged entry: if the invocation was cancelled before the event is
dequeued, it is dropped rather than delivered, per spec 6.4.3.
Reporting permanent failure
done_invocation/3 is the door for work that finished. Work that will never
finish needs the other door, or the chart waits forever in its invoking state:
@spec failed_invocation(server :: server(), invoke_id :: String.t(), failure :: keyword()) :: :ok
def failed_invocation(server, invoke_id, failure \\ [])Call it with the same owning session and invoke_id, at the moment your own
retry policy is exhausted and you have decided the invocation is over. It
constructs error.communication.invoke.<invoke_id> and delivers it on exactly
the same invocation-tagged entry, under exactly the same drain-time discard: a
cancel that got there first still wins, and the invocation's table entry is
popped either way, because a permanently failed invocation is over in the same
sense a completed one is.
failure is a keyword list read for three optional keys, none of which the
library interprets:
| Key | Read from a chart as | Absent |
|---|---|---|
:reason | _event.data.reason | "unknown" |
:attempts | _event.data.attempts | undefined |
:detail | _event.data.detail | undefined |
This is the host's call, never a handler callback's. start/2, cancel/2,
and forward/3 are pure planning callbacks that may not perform IO at all, and
perform/2 returning {:error, term()} is a transient signal - it means this
attempt failed, which is what a retry policy exists to absorb. Only the layer
that owns the policy knows when the policy has run out, which is the same
reason completion is reported rather than inferred.
The event name is deliberately a suffix of error.communication rather than a
new error.invoke family. Spec 3.12.1 lets a platform extend a generated
event's name with a suffix precisely because the descriptor prefix rule keeps
the shorter name matching, so both of these work, and the first one works in
charts written before this door existed:
<!-- catches any invoke's permanent failure, and any other communication error -->
<transition event="error.communication" target="failed"/>
<!-- parks one invocation specifically, for operator recovery -->
<transition event="error.communication.invoke.inv_3" target="needs_attention">
<log expr="_event.data.reason"/>
</transition>ADR-0068 records the full argument, including why the event rides the external queue with the rest of the invocation's traffic rather than being raised internally.
At-least-once: handlers must be idempotent
perform/2 MAY be called more than once for the same invoke_id. A host
that crashes between starting an instruction and durably recording that it
ran may re-run the same drive after recovery, producing the byte-identical
instruction again. A handler implementing perform/2 MUST be idempotent on
invoke_id.
The library performs no deduplication itself, and cannot: it has no view of
your host's durable store, no database, no job queue, nothing to check a
prior attempt against. invoke_id is the idempotency key you are handed for
exactly this reason - it is stable by construction (see above), not merely by
convention, so keying your own dedup table on it is sound.
What an unregistered type does
An <invoke> whose type (or evaluated typeexpr) resolves to no registered
handler raises error.execution. This follows from two clauses of the
spec's local cache rather than from a 6.4 MUST that does not exist: 3.12.2
distinguishes error.execution (errors internal to the execution of the
document) from error.communication (errors while trying to communicate with
an external entity), and 6.2.5 gives <send>'s own unsupported-type case as
the explicit analogue, raising error.execution for exactly this reason. An
unregistered type never attempted communication with anything - this
deployment implements no such service - so it falls on the error.execution
side.
This contrasts with a registered handler that fails to reach its service:
that is error.communication, because communication genuinely was attempted
and failed. See ADR-0051 for the full argument and the corpus that pins both
outcomes.
Testing your handler: the conformance case
Everything this document requires of a handler is also pinned mechanically:
Statifier.Testing.HandlerCase (ADR-0065) generates a conformance suite for
your implementation from two lines in a test module:
defmodule MyApp.AuthorizeHandlerConformanceTest do
use ExUnit.Case, async: false
use Statifier.Testing.HandlerCase,
handler: MyApp.AuthorizeHandler,
type: "myapp:authorize"
# The observation point for the idempotency check: return the observable
# effects attributable to invoke_id - enqueued jobs, written rows,
# whatever your perform/2 produces.
def observed_effects(invoke_id) do
MyApp.Jobs.AuthorizeJob.enqueued_for(invoke_id)
end
endThe generated tests verify the planning callbacks are deterministic and
effect-free, perform/2 is idempotent on invoke_id (the "At-least-once"
section above, judged against your observed_effects/1), cancel of an
unknown invoke_id never raises, {:error, _} from start/2 surfaces as
error.execution in a minimal driving chart, and handler exceptions
propagate un-rescued. Fixtures are overridable (conformance_invoke/0,
conformance_ctx/0, conformance_event/0) for a handler that reads
params, src, or content; every check is also a plain public function
on the module for suites that want them one at a time. See the module's own
documentation for the full contract of each check.
One part of an async handler's contract the case deliberately does not
generate a check for: reporting permanent failure. failed_invocation/3 is
called by the host's retry layer, not by the handler, so a handler-scoped
conformance case has no view of the thing that would need asserting - whether
your retry policy actually reaches the door when it gives up. Write that test
where your retry policy lives. What the case does cover on the failure side is
the one failure path a handler owns outright: {:error, _} from start/2
surfacing as error.execution.
A naming note
Statifier.Invoke.Handler (and its registration) is not
Statifier.Registry. Statifier.Registry is the embedder-placed session
registry keyed by session id (ADR-0027) - #_scxml_<sessionid> routing, not
<invoke> dispatch. The two are unrelated concepts that happen to share the
word "registry" in casual conversation; do not confuse per-session handler
registration described here with looking a session up by id.
Where the library will not help
Two things the library deliberately does not do on a handler's behalf:
- It never fetches a URI.
<invoke src="...">is never dereferenced by the engine itself, for the same security posture that governs<data src>(ADR-0024) and that ADR-0038 applies specifically to<invoke>: a document-named URI dereferenced by the engine by default is a request-forgery surface handed to whoever authored the document. If your handler needs to reach a URI, do it as ordinary application code, under your own security policy. - It never rescues a handler exception into an event. An exception raised
from
start/2,cancel/2,forward/3, orperform/2is not caught and turned intoerror.executionorerror.communicationfor you - it crashes the session process, deliberately, on the same reasoning the moduledoc gives for the idempotency requirement above rather than a rescue-to-default.start/2returning{:error, term()}is the one documented failure path a planning callback has, and it is planned aserror.execution- the same class an unregistered type gets, since no communication was ever attempted.perform/2's return value is not interpreted by the library at all: an{:error, term()}there is your own handler's concern to observe (log it, retry it, raise it), not something the session recovers from or turns into an event on your behalf. That is a statement about the return value, not about failure generally: once your retry policy has given up,Statifier.Session.failed_invocation/3above is how you say so, and the library still infers nothing - you decide when the invocation is over and tell it.