StatifierBlocks.Core.Subchart (StatifierBlocks v0.15.0)

Copy Markdown View Source

core.subchart: a step that runs another chart and waits for it to finish, routing on the outcome that chart finished with (ADR-0004's 2026-08-29 amendment, C1 through C3).

This type names a chart and an invoke type; it never runs either. A subchart is not a new execution mechanism - it is a particular invoke whose handler happens to start a child session, so it stands on the same two-registry seam core.invoke does (ADR-0002 decision 2, statifier-ex ADR-0051).

The child is a reference, never a body

A document is a tree and a chart is a build product of one, so a subchart whose body lived inside the parent would be a second copy of a document that already exists on its own, with its own id, its own revision and its own runs. The child is therefore named by the chart config field and this type declares no body slot at all.

How a child's outcome reaches the parent

Raised events are internal to the session that raises them, so done.outcome.<state id>.<outcome> - the event ADR-0004's outcome amendment gives a block - does not cross an <invoke>. What a parent observes is the completion event and the data the child chose to send with it (SCXML 3.7 and 5.5). C1 fixes the child half: a document compiled for use as a child (StatifierBlocks.Compiler's :child_use option) emits one top-level <final> per root-block outcome, carrying <donedata><param name="outcome" expr="'<outcome>'"/>. C2 fixes this half: the block's state routes done.invoke on _event.data.outcome, one conditioned transition per declared outcome and an unconditioned one last as the default path.

The condition is ===, not ==

Statifier's == against an absent _event.data.outcome is non-boolean and raises a spurious error.execution beside the default arm's routing (statifier-ex st-iz97); === is clean, and an explicit nil donedata reads as null rather than as undefined. So the conditioned transitions are written _event.data.outcome === '<outcome>'. The record fixes the routing, not the operator; this is the campaign's recorded ruling on which operator expresses it.

Which outcomes, and where the author says so

A block type cannot read the document it references - emit/2 is a pure function of its block and its context, and the context deliberately carries no palette and no other document. So the outcomes the referenced chart declares are declared here, in the outcomes config field, one name per line, in the order they should be routed. A subchart that declares none has the one outcome an ordinary block has, done.

The failure outcome error is appended to whatever the author declared, unless they declared it themselves - a child chart may well finish with an outcome it calls error, and then the two are one outcome with one final and one slot rather than two spellings of the same thing.

Every outcome gets a slot, on_error included

An outcome path is a slot, never a port (D13, ADR-0002's amendment A2): every edge in a document is a parent/slot/child relationship, which is the invariant the editor's rendered connectors rest on. So each declared outcome gets one zero_or_one slot, on_<outcome>, holding what runs when the child finishes that way, and on_error is exactly core.invoke's slot with exactly core.invoke's :failure style - the same declaration for the same concept, not a second wording of it.

A slot left empty is not a missing path: the routing transition simply targets that outcome's <final> directly, and the block finishes there.

What it compiles to

<state id="s_blk_ELIG" initial="s_blk_ELIG__running">
  <state id="s_blk_ELIG__running">
    <invoke id="blk_ELIG" src="bdoc_CHILD" type="statifier_blocks:subchart"/>
    <transition cond="_event.data.outcome === 'done'"
                event="done.invoke" target="s_blk_ELIG__o_done"/>
    <transition cond="_event.data.outcome === 'abandoned'"
                event="done.invoke" target="s_blk_ELIG__o_abandoned"/>
    <transition event="done.invoke" target="s_blk_ELIG__o_done"/>
    <transition event="error.communication.invoke" target="s_blk_PARK"/>
  </state>
  ...
</state>

<invoke> carries an explicit id (C3) so _event.invokeid is static and a parent running subcharts in parallel can tell its concurrent children apart by a value it knows at compile time. The id is the block's own id, which ADR-0001 already guarantees is document-unique and never reused, so nothing new has to be minted or kept unique.

Both done.invoke transitions and the error.communication.invoke one match by SCXML's descriptor prefix rule and name no invocation, which is safe for core.invoke's reason: they sit on the inner state, active only while this block's own call is outstanding.

What src names, and what it does not

The emitted src is the document id the author typed into chart, and nothing else (ADR-0004's subchart-src amendment). It is not statifier-ex ADR-0052 chart identity: that identity is a hash of emitted bytes, so it moves every time the child is republished and cannot be known when the parent is authored. A document id is the stable authoring-time reference, and the host's handler registered under statifier_blocks:subchart (st-ADR-0051) resolves it to whichever chart the host currently publishes for that document. Pinning a particular child revision at publish time is a host provenance concern, carried in run metadata; the compiler does not do it.

One thing a compile of one document can decide about that id, and it does: a subchart may not name the document it sits in. See StatifierBlocks.Compiler.SelfReference, which also says why a cycle through two or more documents is the host resolver's to refuse.

Summary

Functions

The outcomes the referenced chart declares, in the order the author wrote them, defaulting to ["done"].

A compound state that runs the child chart in an inner state and finishes at the <final> of whichever outcome the child reported.

The invoke type every core.subchart emits: the host-registered child-chart invoke type.

core.invoke's io/1 exactly: a step with several outcomes, so produces is :unknown rather than a join over the subtrees that reach each one - the lattice ADR-0003 decision 4 refuses to build - and no consumes, because a subchart reads its inputs through params.

The outcomes this block declares: the ones the author listed, then error unless they listed it.

The outcomes field's rows: one outcome name per line, blank lines ignored, in the order the author wrote them.

The outcomes the referenced chart declares, as the author listed them, with error appended unless they listed it themselves (ADR-0002 amendment A1, ADR-0004's outcome amendment 2a).

One zero_or_one slot per declared outcome, named on_<outcome>, in declaration order with on_error last.

Functions

child_outcomes(config)

@spec child_outcomes(StatifierBlocks.Block.config()) :: [String.t()]

The outcomes the referenced chart declares, in the order the author wrote them, defaulting to ["done"].

Total: a field this type's validate_config/1 rejects reads as the default rather than raising, so every callback stays answerable for a config the compiler will refuse anyway.

emit(block, context)

A compound state that runs the child chart in an inner state and finishes at the <final> of whichever outcome the child reported.

Every declared outcome gets one conditioned done.invoke transition, in declaration order, and the unconditioned one comes last (C2): document order decides which of several matching transitions is taken, so an unconditioned transition placed anywhere but last would shadow every conditioned one after it. Where it lands is this type's call, and it lands on the first declared outcome - the outcome an author who declared only one has, so a subchart that declares nothing behaves exactly like a core.invoke.

error.communication.invoke routing is core.invoke's, unchanged: it is emitted only when the on_error slot is occupied, and with the slot empty the failure propagates as it does today.

Who owns what

Everything here is this block's except one transition per occupied slot: the one leaving the slot's child for that outcome's final is attributed to that child, because "what happens after the parking step finishes" is a fact about the child (ADR-0004 decision 5, the rule Emit.chain/2 follows). The src attribute's value is stamped as coming from chart and the location's from assign_to, and each condition as coming from outcomes, so an upstream finding inside one is the author's typo rather than a bug in this type.

invoke_type()

@spec invoke_type() :: String.t()

The invoke type every core.subchart emits: the host-registered child-chart invoke type.

It is a constant rather than a config field because which handler starts a child session is deployment state, not authoring state (st-ADR-0051): an author picks a chart, and the host registers one handler that knows how to run one. A host that registers nothing under this name gets the ordinary two-registry lint (StatifierBlocks.Compiler.InvokeTypes) rather than a runtime surprise, which is the whole point of naming it here where the lint can see it.

io(config)

core.invoke's io/1 exactly: a step with several outcomes, so produces is :unknown rather than a join over the subtrees that reach each one - the lattice ADR-0003 decision 4 refuses to build - and no consumes, because a subchart reads its inputs through params.

outcome_names(config)

@spec outcome_names(StatifierBlocks.Block.config()) :: [String.t()]

The outcomes this block declares: the ones the author listed, then error unless they listed it.

Public because the editor and the tests both need the same reading of the flattened field, and two spellings of it would be two chances for them to disagree.

outcome_rows(value)

@spec outcome_rows(term()) :: {:ok, [String.t()]} | {:error, String.t()}

The outcomes field's rows: one outcome name per line, blank lines ignored, in the order the author wrote them.

outcomes(config)

The outcomes the referenced chart declares, as the author listed them, with error appended unless they listed it themselves (ADR-0002 amendment A1, ADR-0004's outcome amendment 2a).

slots(config)

One zero_or_one slot per declared outcome, named on_<outcome>, in declaration order with on_error last.

Arity is zero_or_one for core.invoke's reason: an outcome path is one continuation, not a list of them, and an author who wants several steps there puts a core.sequence in it exactly as anywhere else a single child is asked for.