core.on_event: an interrupt handler, valid inside an interrupts slot
and nowhere else (ADR-0002 decision 10).
A leaf with four config fields: the event that fires it, an optional
cond that decides whether it fires at all, the outcome that decides
what happens to the group it interrupts, and an optional capture that
writes values out of the firing event's payload into the datamodel
before the outcome is raised.
Placement, in both directions, from one tag
This type declares kinds: [:interrupt_handler] and nothing else. That
single tag is the whole placement rule:
- an
on_eventdropped into abodyslot fails, becausebodydeclares[:step]and the two sets do not intersect; - an ordinary step dropped into
interruptsfails, becauseinterruptsdeclares[:interrupt_handler]and a step is not one.
ADR-0002 decision 10 originally recorded the first direction as a special-cased validation rule the core types carry, and withdrew it at acceptance in favour of ADR-0003 decision 3's kind tags, which close both directions with one declaration on each side. There is no placement check in this module, and there is not supposed to be one: adding it back would give the editor two code paths to highlight from.
This type also never names the group types it may live inside. A host
group with an interrupts slot admits it by declaring
"interrupts" => [:interrupt_handler], and a host with a genuinely
different notion of interrupt handler mints its own kind and its own
group without touching this package.
Candidates for event (sb-82mu)
event is a plain :string and this type validates it the way it always
has - the event-name shape rule, and nothing else. What the editor adds is
a list of suggestions: the completion events the blocks in the handler's
enclosing body raise, each written as the
done.outcome.<state id>.<outcome> name
StatifierBlocks.Compiler.StateId.outcome_event/2 mints, and labelled by
the block's own card label and that outcome. Wiring a handler onto a
sibling's outcome is then a pick rather than a transcription of a generated
name.
Three properties of the list are this record's, not the control's.
- The body is read through the declaration. "The enclosing body" is
every slot of the enclosing block that admits ADR-0003's
:stepkind, which isbodyoncore.groupandcore.resumable_groupand whatever a host group calls the slot it declares the same way. The handler's owninterruptsslot declares[:interrupt_handler], so it is excluded by construction rather than by name. - Only a type that declares outcomes contributes. ADR-0002 amendment
A1 gives a type that implements no
outcomes/1a single defaultdone, and offering an author a generated name for an outcome a type never declared would be offering them a wire that is not there. config_schema/1is untouched. The field declaration gains no candidate key; the derivation is the editor's and is keyed on this type.core.send,core.raiseandcore.awaiteach declare aneventkey too, and each names events this list is not about.
The outcome values
ADR-0002 decision 10 fixes outcome as a :select and names no values.
Two are implemented:
outcome | Means |
|---|---|
"abandon" | leave the group and do not come back |
"resume" | handle the event and re-enter the group |
They are the minimal pair ADR-0001 decision 10's compile target needs -
transitions on the group's state, with the group's own history mode
deciding where a "resume" re-enters. A third value is a
config_schema/1 change plus a current_version/0 bump, not a document
schema change.
The optional cond guard
cond is an optional :expression field, and when it is set it becomes
the cond on the watcher's transition: the handler fires only when the
event arrives and the condition holds. A handler with no cond -
the key absent, or blank - emits exactly the bytes it emitted before the
key existed, which is what keeps it an additive key rather than a
document schema change.
The guard belongs here rather than on a core.branch after the handler,
which is the shape it would otherwise be spelled as. A core.on_event
decides whether to leave the in-flight body at all, and by the time a
branch inside the handler could read a condition the body has already
been abandoned - so the two spellings do not express the same thing, and
only this one expresses a guarded interrupt. See ADR-0002's 2026-08-31
note.
The condition is the author's bytes passed through into predicator's
datamodel verbatim. This package ships no expression checking of its own
(ADR-0004 decision 9), so validate_config/1 only asks whether the
stored value is a string; a typo inside it surfaces as an upstream
compile error routed back to the "cond" field by the cond_key this
type passes to StatifierBlocks.Core.Emit.transition/2.
Unlike core.branch, this type declares no value_path: its condition
is stored at config["cond"], so ADR-0002 decision 7's default path -
[key] - already addresses it. And summary/1 is untouched. ADR-0002
amendment H6 fixes this type's card as the outcome word then the event
name, and the reason core.branch counts its arms rather than listing
their conditions holds here too: an expression is not a chip.
The optional capture map
capture writes values out of the firing event's payload into the
datamodel. It is a map, and the direction is worth stating twice
because a path-to-path map reads either way: the key is the
destination - a datamodel path - and the value is the source, a
path inside _event.data. A capture of
%{"order.cancel_reason" => "reason"} on a handler for
order.cancelled writes that event's reason into
order.cancel_reason.
One <assign> is emitted per pair, on the transition the handler
already emits and before the <raise> that carries the outcome.
The pairs are emitted in their datamodel paths' sorted order: a map has
no order of its own and a compile has to be deterministic, so the
record fixes one rather than leaving the bytes to a map's iteration.
A handler whose capture is absent or empty writes no <assign> at
all, which keeps the key additive in exactly the way cond is.
The assigns belong on the transition, and before the raise, for the
reason the guard belongs here: the <raise> is what tells the
enclosing group to abandon or resume, and by the time control is
anywhere else that has happened - on abandon the body is gone, on
resume the body is re-entered and history decides where. _event.data
is in scope only for the transition the event selected, so a
core.assign placed after the handler is a separate microstep with a
different _event and the payload is not merely awkward to reach
there, it is gone. See ADR-0002's 2026-09-05 note.
A captured value that quietly is not there is the failure this key has to avoid, because everything downstream would read it as an authored absence. What the interpreter does about that splits on whether the expression's root is bound, not on whether the whole path resolves:
_eventis always bound, so acapturewhose source path is not in the payload writes the interpreter's explicit unbound marker and raises nothing. The marker is notniland is notnil's spelling - unbound and null are deliberately different values there - so the absence is one a reader can test for rather than a silent hole. A consumer of a captured path has to make that test; that obligation is the whole of what this key promises today.- A wholly unbound root raises
error.executionand writes nothing. That is predicator'son_unbound: :errorpolicy reachingStatifier.Interpreter.Content's one raise site, and nocapturecompiles to such an expression.
[Note 2026-09-05, sb-0q0z: this paragraph read "an <assign> whose
expr does not resolve is an execution error", following ADR-0002's
capture Note, which was written ahead of the measurement. Measured on
two engine versions, the error is raised for an unbound root only, never
for a missing member of a bound one. ADR-0002 carries the correction and
the cites; the error arriving for this shape too is upstream work, and
nothing here may be built on it until that lands.]
The compile-time half - a :config finding for a declared payload that
lacks a named source path - is dormant, because nothing in this package
declares an event payload's shape; fixtures/0 below is one sample per
event name for a palette panel, not a declaration.
config_schema/1 declares no field for capture. ADR-0002
decision 7's field-type set has no member that describes a map, the
2026-09-05 note declines to add one, and how an author writes the pairs
is ADR-0005's question rather than this module's. So the key is
authored through the document today and not through the editor, and the
two <assign> attributes carry no config attribution for the same
reason core.subchart's composed conditions carry none: expr is
composed here rather than the author's bytes verbatim, and location
has no declared field for a finding to land on.
Summary
Functions
A compound state that waits for event and, when it arrives, raises the
interrupt-protocol event its outcome names before going final.
One example event payload, so a palette panel can show what _event.data
looks like when this handler fires.
The outcome's word, then the event name, as a chip list (ADR-0002 amendment H6).
Functions
A compound state that waits for event and, when it arrives, raises the
interrupt-protocol event its outcome names before going final.
<state id="s_INT" initial="s_INT__armed">
<state id="s_INT__armed">
<transition event="order.cancelled" target="s_INT__done">
<raise event="statifier_blocks.interrupt.abandon"/>
</transition>
</state>
<final id="s_INT__done"/>
</state>The group this handler sits in runs it as a region of a <parallel>
alongside the body, which is what keeps it live while the body works, and
transitions on both protocol events unconditionally - see
StatifierBlocks.Core.Emit. The raise is how the outcome crosses that
seam: ADR-0004 decision 4 keeps a child's config out of its parent's
context on purpose, so the group cannot read outcome and must not try.
A raised event is internal, so it is processed before any external event the queue is holding, and a nested group's handler is selected over an outer group's because SCXML prefers the transition whose source is the deepest active state.
A guarded handler
A cond in config becomes the cond on that one transition, and
nothing else about the shape moves:
<state id="s_INT__armed">
<transition cond="review.parked" event="review.resolved" target="s_INT__done">
<raise event="statifier_blocks.interrupt.resume"/>
</transition>
</state>So the event arriving while the condition is false leaves the handler
armed and the body running - the interrupt simply does not happen, and
the same event arriving later, once the condition holds, still fires it.
A handler with no cond writes no cond attribute at all
(StatifierBlocks.Core.Emit.transition/2 drops an absent one), which is
why an unguarded handler's bytes are unchanged by this key existing.
The cond_key passed alongside is "cond", the config key the author
typed into, so an upstream expression error lands on that field rather
than reading as a bug in this type (ADR-0004 decision 9). It is passed
unconditionally, guard or no guard:
StatifierBlocks.Emission.attribute_from_config/3 records an owner only
for an attribute the element actually carries, so an unguarded handler
records none without this call site testing for it twice.
A capturing handler
Each capture pair becomes one <assign> on that same transition,
ahead of the <raise>:
<state id="s_INT__armed">
<transition event="order.cancelled" target="s_INT__done">
<assign expr="_event.data.reason" location="order.cancel_reason"/>
<raise event="statifier_blocks.interrupt.abandon"/>
</transition>
</state>The pairs are ordered by their datamodel paths, sorted, so two
compiles of one document write one byte sequence. A handler with no
capture - the key absent, or an empty map - emits the bytes above
this section unchanged.
One example event payload, so a palette panel can show what _event.data
looks like when this handler fires.
Provisional: the accepted spellings are not settled
PROVISIONAL - see ADR-0002 decision 9. The atom-keyed spelling below
comes from an amendment to that decision which has not been accepted.
Until it is, treat the shape as the intended target rather than a
settled contract. That this callback exists, and returns term(), is
settled either way.
Under statifier-ui's docs/fixture-bundles.md, events is one sample
_event.data payload per event name. The name here is an example, not
this block's configured event - fixtures/0 takes no config and could
not read one.
The outcome's word, then the event name, as a chip list (ADR-0002 amendment H6).
The outcome comes first because it is what the block does; the event is only when. Each half is dropped on its own when it is not there or not well formed, so a handler mid-edit shows the half the author has filled in rather than nothing.
iex> StatifierBlocks.Core.OnEvent.summary(%{"outcome" => "abandon", "event" => "order.cancelled"})
["Abandon", "order.cancelled"]
iex> StatifierBlocks.Core.OnEvent.summary(%{"outcome" => "resume"})
["Resume"]
iex> StatifierBlocks.Core.OnEvent.summary(%{})
[]