StatifierBlocks.Core.Emit (StatifierBlocks v0.30.0)

Copy Markdown View Source

The SCXML shapes the core.* vocabulary compiles to (ADR-0004 decisions 2-4), and the small builders the core 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. A handler raises the bare pair, and the compiler salts both halves with the group's own state id as it emits them (ADR-0010 decision 8), so the group's own state carries:

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

and the <raise> a handler on that rail wrote as statifier_blocks.interrupt.resume is emitted as statifier_blocks.interrupt.resume.s_G.

That salt is what makes nesting reliable. Two nested groups used to carry the same two transitions, and only the raise's position in the active configuration decided which of them took it - which is right for a raise from the inner group's own rail and wrong for a raise from the outer group's rail while an inner group is active, where the outer group's resume was captured by the inner group's rail and the outer group's history re-entry never fired. After the salt each group's rail matches only its own pair, so a rail can only be reached by a raise from that group's own rail, at any nesting depth and whichever transition an engine would otherwise have preferred.

Nothing an author or a host type writes moves: a host interrupt handler still joins the protocol by raising the same two bare events, and interrupt_events/0 is still where they are named. The salt is applied at emit and is invisible above it - StatifierBlocks.Compiler.Interrupts rewrites the raises inside a rail, and a raise outside any rail is left exactly as it was written.

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 same two events, salted with the emitted state id of the group whose rail they belong to (ADR-0010 decision 8).

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 - salted with that state's id, so this group's rail is the only rail its handlers can reach (ADR-0010 decision 8; see the moduledoc).

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.

An id in the reserved o_ namespace is an outcome final, so it also raises that outcome's own completion event (ADR-0004's outcome amendment, 2c):

<final id="s_blk_AUTH__o_done">
  <onentry><raise event="done.outcome.s_blk_AUTH.done"/></onentry>
</final>

Any other id stays a bare <final> - the body_done role guarded/4 mints is a completion marker inside the block, not an outcome a parent can wire on.

The raise is what makes the summary honest. 2e's child summary advertises done.outcome.<state id>.<name> for every child, single-outcome ones included, and a summary naming an event nothing raises would be a lie the compiler told its own parents. 2c makes the raise constitutive of an outcome final, and core.invoke already emitted exactly this shape by hand before this builder did.

The id is inverted back to {state id, outcome} by StatifierBlocks.Compiler.StateId.unoutcome_id/1, which is exact because a generated id contains exactly one "__" - a block id carries none and a role is refused if it does.

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.

interrupt_events(state_id)

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

The same two events, salted with the emitted state id of the group whose rail they belong to (ADR-0010 decision 8).

This is the pair a group's own two transitions match, and the pair a <raise> inside that group's rail is emitted as. StatifierBlocks.Compiler.Interrupts reads it back to rewrite the raises, so the salt's shape is written down once, here, rather than in each half of the convention.

No two groups share an emitted state id (StatifierBlocks.Compiler.StateId mints them), so no two rails share a name.

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 - salted with that state's id, so this group's rail is the only rail its handlers can reach (ADR-0010 decision 8; see the moduledoc).

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.