StatifierBlocks.Core.OnEvent (StatifierBlocks v0.28.0)

Copy Markdown View Source

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 (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 :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:

outcomeMeans
"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 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.

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. That is one restriction beyond type: a string carrying a character outside printable ASCII, because predicator's string lexer has no escape for one and writes what it reads back a byte at a time. Tab, newline and carriage return are in; a float never arises, because a block document may not carry one at all.

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 check_capture/2 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: StatifierBlocks.Environment.capture_writes/1 still writes :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.

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 capture pairs this handler's declared payload refuses (ADR-0002's amendment of 2026-09-06, P5).

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

The outcome's word, then the event name, as a chip list (ADR-0002 amendment H6).

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

Functions

emit(block, context)

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

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.

payload_capture_findings(config, declarations)

@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 (check_capture/2 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(config)

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(config)

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