StatifierBlocks.Core.Subchart (StatifierBlocks v0.26.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.

What the host knows that this type cannot (sb-r4w7)

The paragraph above is a statement about what a compile of one document can see. A host sees more: it holds every stored document, so it knows which of them it compiles with :child_use and what finals each of those emits. Two things follow, and both are the editor's rather than this type's.

  • The editor offers those finals as candidates on the outcomes field, keyed on the document id in chart, from the host's chart_outcomes assign. It is a <datalist> on a field that is still a :string: a free-typed name validates exactly as it did.
  • StatifierBlocks.ViewModel.outcome_findings/3 reports a disagreement between what the author declared here and what the host says that chart finishes with, anchored on this key.

The comparison is against child_outcomes/1 - the author's own list - and not outcome_names/1, because the appended failure outcome is ADR-0068's event rather than a <final> the child reports, and a child chart is not expected to have one. A ref the host said nothing about produces nothing: unknown is not disagreement, which is ADR-0005 amendment 11f's posture for the datamodel repeated here for the same reason.

None of it constrains. The disagreement is a :warning, not an error: the document compiles either way, and what a mismatch actually costs is a conditioned transition that can never match - a routing arm that is dead rather than wrong. validate_config/1 is untouched, because this type still cannot read the chart it names.

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.

Where the outcome is written

assign_to names a location in the host's datamodel, so it is declared a {:path, %{}} field - ADR-0002 decision 7's eighth field type, added by its 2026-09-05 amendment, which names this field as the one core field that held a path and declared nothing about it. Two things follow and a third deliberately does not:

  • the editor offers the declared datamodel paths as candidates, drawn from StatifierBlocks.Datamodel.candidates/3; and
  • a value the datamodel does not declare gets ADR-0005 clause 11e's :info advisory anchored on the assign_to key, which is a remark and not a refusal.

The field accepts a datamodel path (ADR-0011 decision 13): any non-empty string with no whitespace in it, which is exactly what core.assign accepts for the path it writes, read out of one StatifierBlocks.Core.Config.datamodel_path?/1. validate_config/1 and emit/2 are widened together, because the emission has to answer for a config the validation would have rejected.

It is a widening and nothing else: a bare identifier is a one-segment path, so every document written before this type existed keeps validating and keeps compiling to the same bytes, and emit/2 still writes the author's string verbatim into the <assign>'s location. What it settles is that the field no longer refuses the dotted paths its own candidate list offers - a control that offers what its validation refuses is a defect either way round. The identical refusal on an <assign> location elsewhere in the vocabulary is untouched: that decision was ruled about this field, and widening the others on the strength of one field's argument is a sweep it did not make.

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.

error is failure-classed: a child that reported error is a child that finished badly (the campaign-033 failure seam, 2026-09-06).

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

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

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: the transition is always emitted, targeting the on_error child when the slot holds one and the error final directly when it does not (ADR-0002's amendment of 2026-09-06, section 2). That final is emitted in both cases, whatever the referenced chart's own declared outcomes say, because a class is read off a final.

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.

failure_outcomes(config)

error is failure-classed: a child that reported error is a child that finished badly (the campaign-033 failure seam, 2026-09-06).

It is the one outcome this type appends itself, and the moduledoc already calls it "the failure outcome"; nothing else the author listed is classed, because this package cannot know what a chart's own declined or expired means. The class is a second axis on an outcome that already exists, so the outcome list, the on_<outcome> slots and the error.communication.invoke routing are all unchanged - and so is the slot_style :failure the palette entry already gives on_error, which is the editor's word for the same fact.

It changes one thing, and only for a document whose root block is a core.subchart: the top-level <final> for error carries the reserved <donedata> <param> that tells a durable stepper the run failed.

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

sentence(config)

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

The chart it runs and the fact that it waits, which are the two things that separate this block from every other step in a list. The chart name is repeated as the author wrote it - it is a key into the host's chart store, and a line that paraphrases it names nothing the author can look up.

A block with no chart named yet says what it is and stops.

iex> StatifierBlocks.Core.Subchart.sentence(%{"chart" => "signup.identity"})
"Run signup.identity and wait"

iex> StatifierBlocks.Core.Subchart.sentence(%{})
"Run a chart and wait"

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.