StatifierBlocks.Core.Emit (StatifierBlocks v0.1.0)

Copy Markdown View Source

The SCXML shapes the core.* vocabulary compiles to (ADR-0004 decisions 2-4), and the small builders the seven types share.

The one convention everything else rests on

Decision 2 fixes that a block compiles to exactly one state and signals completion with done.state.<state id>. SCXML raises that event when a compound state's configuration enters a <final> child, so every core type emits a compound state carrying a <final> under the role "done", and arranges its own work to reach it. That is the whole of what a parent needs: StatifierBlocks.Compiler.Context hands it a child's state id and done event, never the child's SCXML.

Sequencing is therefore always the same shape - transitions on the parent's own state, one per adjacent pair:

<state id="s_SEQ" initial="s_c1">
  <transition event="done.state.s_c1" target="s_c2"/>
  <transition event="done.state.s_c2" target="s_SEQ__done"/>
  ...c1's subtree... ...c2's subtree...
  <final id="s_SEQ__done"/>
</state>

A transition on a compound state fires while any descendant is active, so the parent can wire its children without any of them knowing they were wired. An empty run degenerates cleanly: initial points straight at the <final>, and entering the block completes it.

Interrupts: two regions and a two-event protocol

core.group and core.resumable_group have to run their body while interrupt handlers watch for events. A handler is a block like any other (decision 2 admits no exception), so it compiles to a state, and a state only sees events while it is active - which means the handlers must run concurrently with the body. The group therefore wraps both in a <parallel>: one region for the body, one region per handler.

That leaves one problem. What a handler's arrival should do - abandon the group, or resume it - is the handler's outcome config, and decision 4 deliberately keeps a child's config out of the parent's context. The group cannot read it and must not try.

So the group wires both outcomes unconditionally and the handler picks one by raising an event:

<transition event="statifier_blocks.interrupt.abandon" target="s_G__done"/>
<transition event="statifier_blocks.interrupt.resume"  target="s_G__run"/>

The handler <raise>s whichever its config names. Nesting behaves the way an author would expect for free: both groups carry the same two transitions, the raise happens inside the inner group's region, and SCXML selects the transition whose source is the deepest active state - the inner group's. A host interrupt handler joins the protocol by raising the same two events; interrupt_events/0 is where they are named.

core.group has nothing to remember, so its resume target is the <parallel> itself and the body restarts. core.resumable_group targets a <history> inside the body region instead, of the type its history config names - which is the whole of what that config buys, and why ADR-0002 decision 10 could leave it as one :select field.

Summary

Functions

Sequences summaries and lands on exit_target.

A <final>, the state whose entry raises the owning block's done.state.

The two events an interrupt handler raises to tell the group it sits in what to do. A host handler that wants to work inside core.group raises one of these; a host group that wants to admit core.on_event transitions on both.

The interruptible shape: the body in one region, each handler in its own, and the two-event protocol wired on the group's own state.

The shape every plain ordered container emits: a compound state running summaries in order and finishing at its own <final>.

A <state>; initial is dropped when nil, which is how an atomic state is written.

A <transition>. opts carries :event, :cond, :target and :internal; an absent one is simply not written, so an eventless conditional transition and an unconditional one are the same builder.

Functions

chain(summaries, exit_target)

Sequences summaries and lands on exit_target.

Returns {initial, transitions, child_refs}: the state to enter first (the first child, or exit_target for an empty run), one transition per adjacent pair plus one from the last child to exit_target, and a placeholder per child for the compiler to splice.

Each transition is attributed to the child it leaves, not to the container that emitted it (ADR-0004 decision 5). "What happens after the authorize step" is the fact an author would recognise, so a finding against that transition belongs on the authorize block than on the sequence around it. Attribution carries no bytes, so this changes nothing about the generated chart.

final(id)

@spec final(String.t()) :: StatifierBlocks.Emission.t()

A <final>, the state whose entry raises the owning block's done.state.

interrupt_events()

@spec interrupt_events() :: %{abandon: String.t(), resume: String.t()}

The two events an interrupt handler raises to tell the group it sits in what to do. A host handler that wants to work inside core.group raises one of these; a host group that wants to admit core.on_event transitions on both.

interruptible(ctx, history)

@spec interruptible(StatifierBlocks.Compiler.Context.t(), String.t() | nil) ::
  {:ok, StatifierBlocks.Emission.t()}
  | {:error, {:invalid_role, String.t(), String.t()}}

The interruptible shape: the body in one region, each handler in its own, and the two-event protocol wired on the group's own state.

history is nil for a group with nothing to remember, or "shallow" / "deep" for one that resumes where it left off.

ordered(ctx, summaries)

The shape every plain ordered container emits: a compound state running summaries in order and finishing at its own <final>.

state(id, initial, children)

A <state>; initial is dropped when nil, which is how an atomic state is written.

transition(opts, children \\ [])

A <transition>. opts carries :event, :cond, :target and :internal; an absent one is simply not written, so an eventless conditional transition and an unconditional one are the same builder.

internal: true writes type="internal", and every transition a container puts on its own state targeting one of its descendants needs it. A transition is external by default, and an external transition exits and re-enters its source even when the target is inside it - which for a <parallel>'s region means tearing down the other regions mid-flight. That is not a subtlety a block-type author should have to rediscover, so the builders below set it and this note says why.

cond_key names the config field the cond came from verbatim, which is what makes an upstream expression error the author's typo rather than a bug in the block type (ADR-0004 decision 9). Pass it whenever the condition is an author's :expression field rather than something the type composed.