Extending Statifier: <invoke> handlers

Copy Markdown View Source

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:enrich", that hands a payload to a background job system and reports completion later:

defmodule MyApp.EnrichHandler do
  @moduledoc """
  Serves `<invoke type="myapp:enrich">` by enqueuing a background job.
  """

  @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.EnrichJob.enqueue_idempotent(invoke_id, session_id, params)
  end

  def perform({:cancel, invoke_id}, _ctx) do
    MyApp.Jobs.EnrichJob.cancel(invoke_id)
    :ok
  end
end

When 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:enrich" => MyApp.EnrichHandler}
)

: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).

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.

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.EnrichHandlerConformanceTest do
  use ExUnit.Case, async: false

  use Statifier.Testing.HandlerCase,
    handler: MyApp.EnrichHandler,
    type: "myapp:enrich"

  # 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.EnrichJob.enqueued_for(invoke_id)
  end
end

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

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, or perform/2 is not caught and turned into error.execution or error.communication for 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/2 returning {:error, term()} is the one documented failure path a planning callback has, and it is planned as error.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.