# `StatifierBlocks.Core.OnEvent`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/core/on_event.ex#L1)

`core.on_event`: an interrupt handler, valid inside an `interrupts` slot
and nowhere else (ADR-0002 decision 10).

A leaf with five 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, an optional `capture` that
writes values out of the firing event's payload into the datamodel
before the outcome is raised, and an optional `payload` that declares
what that event carries - which is what makes a `capture` reading past
it a refusal at compile rather than an unbound marker at run time.

## 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_event` dropped into a `body` slot fails, because `body`
    declares `[:step]` and the two sets do not intersect;
  * an ordinary step dropped into `interrupts` fails, because
    `interrupts` declares `[: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`

`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 `:step` kind,
    which is `body` on `core.group` and `core.resumable_group` and whatever
    a host group calls the slot it declares the same way. The handler's own
    `interrupts` slot 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/1` a single default
    `done`, 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/1` is untouched.** The field declaration gains no
    candidate key; the derivation is the editor's and is keyed on this
    type. `core.send`, `core.raise` and `core.await` each declare an
    `event` key 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.

## Naming the outcome this handler finishes with

`finish_as` is an optional free-text field beside the `outcome` select,
and it says *what this handler's own completion is called* when it
abandons its group. The two keys answer different questions: `outcome`
says what the arrival does to the group, `finish_as` names the
completion this handler reaches, and the select above keeps exactly its
two values.

| `finish_as` | This handler finishes as |
|---|---|
| blank, or absent | `done`, exactly as it always has |
| a role-shaped name | that name |

Named, `outcomes/1` answers `[{name, name}]` - the name is its own
label, because the author's words for a button live on the button and
not on the watcher - and `emit/2` mints the handler's final at its
outcome id, so the final raises `done.outcome.<handler state>.<name>`.
That is the event an enclosing declaring composite routes on, which is
what lets a composite declare an outcome one of its interrupt handlers
reaches. The abandon raise on the watcher's transition is unchanged and
still comes first, so the group is abandoned and only then is the named
completion routed.

A name is honoured with `outcome: "abandon"` only, must be role-shaped,
and may not be `done`; each is a `validate_config/1` finding on the key.

Blank or absent, every emitted byte is the byte that was emitted before
this key existed - which is what keeps it an additive key rather than a
document schema change, exactly as `cond` below and `capture` are. See
ADR-0002's Amendment of 2026-09-14, `C8`.

## 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 into the datamodel on the transition this
handler emits. 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
`capture` of `%{"order.cancel_reason" => "reason"}` on a handler for
`order.cancelled` writes that event's `reason` into
`order.cancel_reason`.

`core.await` takes the same map, validated and emitted by the same code,
on the transition its awaited event selects (ADR-0002's Note of
2026-10-19). The two source forms, the absent-source rule, the literal
spelling and the sorted order below hold for both types; where the
assigns sit relative to a `<raise>`, and the `payload` declaration, are
this type's alone.

A source takes either of two forms, and they are told apart by
**shape**, never by content (ADR-0002's Note of 2026-09-12, `N1`):

  * a **string** is a path inside `_event.data`, which is what a source
    has always been and means exactly what it has always meant. No
    string is reinterpreted as a literal because of what it happens to
    spell;
  * a **two-element array tagged `"const"`** - `["const", value]` in the
    stored document, `{"const", value}` as this module reads it - is the
    literal `value`, read from the document rather than from the
    payload. `value` is taken as it stands: it is not parsed, not
    evaluated, and not resolved against the datamodel.

The literal form is what lets two handlers on one screen record which of
them fired - each writes its own value - rather than depending on the
host to put different values in the payload, a contract neither document
states.

From `0.28.0` on, a pair whose source is a **path the firing payload
does not carry writes nothing at all** (ADR-0002's Note of 2026-09-12,
`N2`): the destination is left as it was - absent if nothing wrote it
before, and carrying its previous value if something did. So a reader
tests a captured destination the way it tests any other datamodel path,
by asking whether it is there, and a guard on a path a screen never
wrote reads an absence rather than a value. A payload that carries the
source with a JSON `null` still writes: "not answered" and "answered
with nothing" are different values, which is the whole of what the
clause decides. There is no per-pair opt-in to the old behaviour. A
literal source has no path to be absent and always writes.

A literal is emitted as a predicator literal expression: a string
double-quoted with `\` and `"` escaped, an integer as its digits, a
negative integer behind the `-` predicator reads as a unary minus,
`true` / `false` / `null` as themselves, an array as `[a,b]` and an
object as `{"k":v}` with its keys in sorted order. A value this module
cannot spell so that the engine reads back what the document carried is
a **malformed pair** - `validate_config/1` refuses it on the `capture`
key like any other. What is refused is very nearly a matter of TYPE
alone: a float never arises, because a block document may not carry one
at all, and a string is admitted whatever it carries with one exception.
A **raw C0 control character** other than tab, line feed and carriage
return is refused on the `capture` key (ADR-0002's line of 2026-09-13):
a literal's characters reach an XML attribute value raw, and XML 1.0
admits no control character below `U+0020` there. The three that are
admitted are the three `escape/1` already writes as predicator string
escapes rather than raw.

The one restriction beyond type this key used to carry - a string had
to stay inside printable ASCII, tab, newline and carriage return - was
**interim** and ended on 2026-09-13 (ADR-0002's Note of that date). It
was earned only while predicator 9.4.0's string lexer wrote a literal's
codepoints back a byte at a time; 9.4.1 fixed that lexer, `mix.exs`
requires `>= 9.4.1` (and `~> 9.4`), and a positive round trip over a
non-ASCII literal in the suite is what says the resolved version is one
that reads a string back whole.

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:

  * `_event` is always bound, so a `capture` whose source path is not in
    the payload writes the interpreter's explicit **unbound marker** and
    raises nothing. The marker is not `nil` and is not `nil`'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.execution` and writes nothing.
    That is predicator's `on_unbound: :error` policy reaching
    `Statifier.Interpreter.Content`'s one raise site, and no `capture`
    compiles 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 is the optional `payload` declaration below, and
it is what the paragraph above stops being the whole story for: on a
document that declares its payload the marker write never happens,
because the document does not compile.

## The optional `payload` declaration

`payload` declares what `_event.data` carries **for the event this
handler names** (ADR-0002's amendment of 2026-09-06, P1). It is not a
fact about the event name anywhere else in the document and it is not
the datamodel, which the `:declare` and `:datamodel` compile options
already own: two handlers for the same event may declare different
payloads and neither is thereby wrong, because each governs its own
`capture`.

P2 of that amendment names **two** arms, and both are spelled today.
The value is either the **name of a type the datamodel document
declares** - a `record` or a `shape` in its `types` key, read through
`StatifierDatamodel.Declarations` - or an **inline shape** written where
the payload is declared, a list of members read through
`StatifierBlocks.Environment.inline_shape/1`. The field type is
`{:type_expr, opts}`, the ninth member of ADR-0002 decision 7's set that
its amendment of 2026-09-06 added and whose clause 7 migrated this field
onto: P3 deferred the second arm rather than refusing it, and the two
arms are told apart by the stored JSON type alone, a string never being
a member list. A `payload` stored as text before that date is the name
arm, unchanged in every particular - the same bytes, the same
resolution, the same findings.

`payload` is a **declaration, not an emission**. Nothing about it
reaches the compiled SCXML: a handler that gains one compiles to the
bytes it compiled to without it, and a handler that has none is
unchanged in every respect - no new finding of any kind, at any
severity. A `payload` naming a type the datamodel does not declare, and
a compile with no `:datamodel` at all, are that same unchanged case
reached by a second route (P4): the name resolves to nothing, and
nothing is refused against nothing.

## The refusal `payload` buys: `payload_capture_findings/2`

With a payload declared, a `capture` pair whose **source** path reads a
member the payload does not carry is a `:config` refusal at compile
(P5). The other reading - a declared member no pair reads - is not a
finding: a payload may legitimately carry more than one handler wants.

P5 is a rule about a source **path**, so a literal pair is not reached
by it at all: a `["const", value]` source has no path to read past the
payload, and `unread_pairs/3` walks only the pairs whose source is a
path. Nothing about that refusal changes, and a handler may declare a
payload and capture a literal beside a path from it.

The check needs the datamodel document, which `validate_config/1` does
not get, so it is a function of its own that the compiler's config stage
calls with the declarations it has already indexed. One finding is
reported for the whole `capture` key rather than one per pair, for the
same reason `validate_config/1`'s capture check gives: the key is the
only anchor an editor can use. The message names the offending pairs
and the declared payload, because the anchor cannot.

How deep it goes is P5's rule, kept literally. The **first** segment of
a source path is checked against the payload's field names. A deeper
segment is checked only where the field's own type resolves, through the
same declarations, to a declaration whose fields are in hand; a field
whose type is a scalar, an opaque string, a list or `:unknown` **stops
the walk and refuses nothing beyond it**. That adds no structural rule
`statifier_datamodel` does not already have - its read check is nominal,
permissive on the unknown, and descends into no list's element type.

An **inline** payload is checked by that same rule against the members
it writes, and P5 gains nothing else from the second arm: the first
segment is checked against the member names, a member typed by a
declared name descends into that declaration, a member whose own type is
another inline shape descends into its members, and every other member
type stops the walk. What the two arms share is that the check needs a
set of member names in hand and refuses only a read that is not in it -
the arms differ in where those names come from and in nothing else. An
inline payload writing no well-formed member carries no member, so every
read is a read past it, which is what a payload naming a declaration
with no fields already does.

The destination side of a pair is untouched by all of this:
the capture-pair writes `StatifierBlocks.Environment.write_signatures/3`
reports still carry `:unknown` there. This types the source side at
compile, and typing the destination from the payload is a widening of
ADR-0011 that no ruling has asked for.

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

# `emit`

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. Which group the raise reaches is not left to the
engine's transition selection: the compiler salts the raise, and the
matching transitions, with the state id of the group whose rail this
handler sits on (ADR-0010 decision 8), so a handler reaches its own
group's rail and no other at any nesting depth. That is why the event
name written above is the one this type emits, and the one in the
compiled chart carries `.<group state id>` after it.

## 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>`. A pair whose source is a **path** carries that
assign inside an `<if>` that tests the path for presence, so a payload
that does not carry it leaves the destination alone (ADR-0002's Note of
2026-09-12, `N2`); a pair whose source is a **literal** has no path to
be absent and is written bare:

    <state id="s_INT__armed">
      <transition event="order.cancelled" target="s_INT__done">
        <if cond="_event.data.reason !== undefined">
          <assign expr="_event.data.reason" location="order.cancel_reason"/>
        </if>
        <assign expr="&quot;cancelled&quot;" location="order.mark"/>
        <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.

# `fixtures`

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 {: .warning}
>
> 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.

# `outcomes`

The outcome this handler finishes with.

Named, the single outcome `finish_as` carries, labelled with the name
itself. Unnamed, ADR-0002 A1's default - which is what this type
answered before it implemented this callback at all, and answering it
here byte for byte is what keeps the key additive.

# `payload_capture_findings`

```elixir
@spec payload_capture_findings(
  StatifierBlocks.Block.config(),
  StatifierDatamodel.Declarations.t()
) :: [
  {String.t(), String.t()}
]
```

The `capture` pairs this handler's declared `payload` refuses
(ADR-0002's amendment of 2026-09-06, P5).

Returns `validate_config/1`'s own `{key, message}` shape, so the
compiler's config stage renders it exactly as it renders that
function's findings: at most one finding, on the `"capture"` key.

It is a separate function rather than another clause of
`validate_config/1` because it needs something that callback is not
given - the datamodel document's declarations, indexed by
`StatifierDatamodel.Declarations.from_document/1` - and the compiler
reads that document once and hands it here.

Both arms of the field are read here. A `payload` holding a name is
resolved through `StatifierDatamodel.Declarations.fetch/2` and checked
against that declaration's fields; a `payload` holding an inline shape
is read through `StatifierBlocks.Environment.inline_shape/1` and checked
against its members. The walk below the first segment is the same rule
in both cases.

Total, and empty in every case the amendment says is not a finding: no
`payload`, a blank one, a name the declarations do not carry, no
`capture`, a malformed one (`validate_config/1`'s capture check owns
that verdict), and a source path whose walk stops at a field this
package cannot see into.

    iex> alias StatifierBlocks.Core.OnEvent
    iex> declarations = StatifierDatamodel.Declarations.from_document(%{"types" => [
    ...>   %{"name" => "cards.declined", "kind" => "record", "label" => "Declined",
    ...>     "fields" => [%{"name" => "reason", "type" => "string"}]}]})
    iex> config = %{"event" => "cards.declined", "outcome" => "abandon",
    ...>   "payload" => "cards.declined", "capture" => %{"card.why" => "reason"}}
    iex> OnEvent.payload_capture_findings(config, declarations)
    []
    iex> OnEvent.payload_capture_findings(%{config | "capture" => %{"card.why" => "code"}},
    ...>   declarations) |> Enum.map(&elem(&1, 0))
    ["capture"]

    iex> alias StatifierBlocks.Core.OnEvent
    iex> inline = [%{"name" => "reason", "type" => "string"}]
    iex> config = %{"event" => "cards.declined", "outcome" => "abandon",
    ...>   "payload" => inline, "capture" => %{"card.why" => "reason"}}
    iex> OnEvent.payload_capture_findings(config, %{})
    []
    iex> OnEvent.payload_capture_findings(%{config | "capture" => %{"card.why" => "code"}},
    ...>   %{}) |> Enum.map(&elem(&1, 0))
    ["capture"]

# `sentence`

This block as one line of prose (ADR-0002's 2026-09-07 amendment).

The event that fires the handler, then the outcome it raises. It is the
reverse of `summary/1`'s chip order, and deliberately so: a chip list has
no grammar to carry "when", so the outcome leads there because it is what
the block does; a sentence has that grammar, and a reader scanning an
`interrupts` slot asks which handler answers which event before asking
what it does to the group.

Each half is held to the same test the card holds it to. An event name
that is not well formed, and an outcome that is not one of the two
declared values, are findings on the card, and repeating either in a line
that reads as settled would bury the finding rather than report it.

A handler with no outcome yet falls back to the one thing both declared
outcomes have in common - it interrupts the group it sits in - rather
than naming a default the emission does not use.

    iex> StatifierBlocks.Core.OnEvent.sentence(%{"event" => "card.authz_timed_out", "outcome" => "abandon"})
    "When card.authz_timed_out, abandon"

    iex> StatifierBlocks.Core.OnEvent.sentence(%{"event" => "order.cancelled"})
    "When order.cancelled, interrupt the group"

    iex> StatifierBlocks.Core.OnEvent.sentence(%{})
    "When an event, interrupt the group"

# `summary`

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(%{})
    []

# `validate_config`

The three fields with checks of their own, and `capture`.

`payload` is not among them. It is a `{:type_expr, opts}` field, and
what a value of one may be is
`StatifierBlocks.BlockType.type_expr_findings/2`'s single check for
every field of that type - the compiler's `:config` stage and the
editor's view model both consult it, so an author is shown the set a
compile refuses. Re-implementing the same test here would report the
same bytes twice on the same key, and whether the name *resolves* is not
a finding on either side: that is ADR-0002's P4 case, the undeclared
payload, unchanged behaviour.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
