The questions a host asks about a chart - a compiled
Statifier.Machine.t() - without running it. Two are answered here: its
versioned binary contract (to_binary/1, from_binary/1) and its event
vocabulary (events/1), with the check of a declaration against that
vocabulary (check_accepts/2).
The binary contract
The versioned binary contract for a chart is a Statifier.Machine.t()
reduced to the inputs that reproduce it: its SCXML source, the persisted
subset of the options it was compiled with, and its
Statifier.Machine.Identity.t(). No compiled term is written - from_binary/1
rebuilds a Machine.t() by recompiling the stored source with the stored
options through Statifier.compile/2, the same pipeline any other caller
runs, rather than by deserializing compiler output directly.
This is boundary work, not core work (docs/architecture.md principle 2),
and it could not live on Machine even if that boundary argument were set
aside: from_binary/1 calls Statifier.compile/2 to rebuild its result,
and Statifier.compile/2 itself builds a Machine.t() (ADR-0003's
layering - the thing produced does not call back into its own producer).
Putting the pair here instead keeps the dependency pointing one direction:
Statifier.Chart depends on Statifier and Statifier.Machine, never the
reverse. It also keeps lib/statifier/machine.ex's moduledoc - already
carrying the full 100% Doctor burden for the compiled struct itself - free
of a second concern (persisting a chart across a process or storage
boundary) that has nothing to do with what the struct means once compiled.
to_binary/1 refuses to encode a Machine carrying no identity or no
source ({:error, :unidentified_chart}): a Machine built without
either has nothing for a future from_binary/1 to recompile from or check
against, so no blob is produced for it at all. from_binary/1 decodes
safely, checks the envelope's tag and shape, checks its format version,
recompiles the stored source under the stored options, and only then
compares the recompiled Machine's identity against the blob's - in that
order, for the same reason Statifier.Position checks version before
identity: a future format whose identity representation changed should
report the version mismatch, not a confusing identity one.
The event vocabulary
events/1 answers which event descriptors the chart listens for: every
descriptor on a transition whose source state can be active, each
returned as authored, a pattern reported as a pattern and never expanded.
"Can be active" is a static rule over the chart's structure, stated on
events/1; it over-counts and never under-counts, and it reads no cond.
The function reads only the compiled machine - no source text, no
identity - and runs nothing.
check_accepts/2 compares a declaration of the event names a chart
accepts with that vocabulary, under the descriptor matching transition
selection uses, and answers the names the chart can never select on
(unreachable) and the descriptors the declaration does not state
(undeclared). It reports and refuses nothing: which list a host refuses a
publish on, if either, is the host's decision. With no declaration (nil)
the computed vocabulary is the contract, so both lists are empty; asked
with a one-name declaration, it is the membership answer for a receiver
that declares nothing.
Both live here, not in Statifier.Validator: validate/3 judges a
document against the spec and takes no deployment state, and the
vocabulary is what a host compares a deployment's claims against before
any execution starts - the same posture as
Statifier.Send.Types.unsupported_sends/2 (ADR-0071, after ADR-0069
decision 3). They keep this module's layering: they depend on
Statifier.Machine (and check_accepts/2 on
Statifier.Interpreter.NameMatch), never the reverse.
No I/O
No function here performs I/O; encoding and decoding a binary in memory,
recompiling source already held in memory, and walking a compiled machine
are not effects a caller has to route around (ADR-0003 does not apply
here, and this module is not listed in @effect_interpreter_paths).
Summary
Types
What check_accepts/2 answers: the declared names no descriptor in the
vocabulary matches, and the vocabulary's descriptors that match no declared
name.
Functions
Compares declared, the event names a chart is declared to accept, with
the chart's event vocabulary (events/1).
The chart's event vocabulary: every event descriptor on a transition whose
source state can be active, computed from the compiled machine alone.
The version tag to_binary/1 writes and from_binary/1 checks. A bare
integer, so a future format change is a version bump here rather than an
inference from the blob's shape.
Decodes a to_binary/1 envelope and recompiles it into a Machine.t().
Encodes machine as a tagged, versioned binary envelope carrying its SCXML
source, its persisted compile_opts, and its Statifier.Machine.Identity.t()
Types
What check_accepts/2 answers: the declared names no descriptor in the
vocabulary matches, and the vocabulary's descriptors that match no declared
name.
Functions
@spec check_accepts(machine :: Statifier.Machine.t(), declared :: [String.t()] | nil) :: accepts_check()
Compares declared, the event names a chart is declared to accept, with
the chart's event vocabulary (events/1).
A descriptor matches a declared name under the descriptor semantics
transition selection uses: Statifier.Interpreter.NameMatch.name_match?/2
over the descriptor's tokens and the name's tokenize/1 tokens, on token
boundaries. A declared loan.renew is matched by the descriptor
loan.renew, by loan.*, by loan., by loan and by *, and not by
loan.renewal or loan.renew.late. One relation answers both lists:
unreachable- each declared name that no descriptor in the vocabulary matches, in the declaration's order and without duplicates: a name the declaration promises and the chart can never select on.undeclared- each descriptor in the vocabulary that matches no declared name, inevents/1's order: a name the chart listens for that the declaration does not state.
A declared entry is a name, not a descriptor: a * in it is an ordinary
token and never a pattern, so a declared loan.* is matched by the
descriptor loan but not by loan.renew. An empty list is a declaration
that the chart accepts nothing: unreachable is [] and undeclared is
the whole vocabulary.
With nil - no declaration - the computed vocabulary is the contract,
which cannot disagree with itself, so both lists are empty. A host asking
whether one name n is in a chart's computed vocabulary calls
check_accepts(machine, [n]) and reads unreachable: [] means some
reachable descriptor matches n, and [n] means none does.
The function reports and refuses nothing; which list a host refuses a
publish on, if either, is the host's decision. Pure and total over a
%Statifier.Machine{} and a list of strings or nil; like events/1 it
reads no source text and needs no identity or source on the machine.
@spec events(machine :: Statifier.Machine.t()) :: [String.t()]
The chart's event vocabulary: every event descriptor on a transition whose
source state can be active, computed from the compiled machine alone.
Each descriptor is returned as authored - its dot-split tokens joined back
with ., so loan.renew returns loan.renew and loan. returns
loan.. A pattern is reported as a pattern, never expanded: * and
loan.* come back as written, and the function never guesses which names
a pattern stands for. Platform and internal descriptors (done.state.,
error., a name the chart raises itself) are descriptors the chart
listens for and are included. An eventless transition contributes
nothing; a chart with no transition carrying an event answers [].
Descriptors appear in t_index order (states in document order, each
state's own transitions before its children's), and within one event
attribute in the order written; a descriptor equal, as a string, to one
already returned is dropped. A document given inline to <invoke> is its
own chart and is not read.
"Can be active" is a static rule over the chart's structure: a state
some path enters, its ancestors included. It follows Appendix D's
addDescendantStatesToEnter and addAncestorStatesToEnter. The root is
entered by its default. Entering a state by its default enters it and
then its initial states as targets (a compound state or the root),
every child that is not a history by its default (a parallel state), or
its history_default transition's targets as targets (a history
pseudo-state). Entering a state as a target enters it by its default,
enters each of its proper ancestors, and, for each parallel ancestor,
enters by its default every child region that holds none of the
transition's targets. Every transition in an entered state's
transitions enters its targets as targets. A transition's cond and
event are not read, so a transition whose condition is never true in
practice still counts. A state's descriptors join the vocabulary when it
is entered and is not a history pseudo-state.
So a transition on an ancestor of an active state is in the vocabulary, a descriptor on a state no path enters is not, a history's default target counts as entered, and every region of a reachable parallel state is reachable. The rule over-counts and never under-counts: a descriptor missing from the answer is one the chart can never select on.
Pure and total over a %Statifier.Machine{}; it reads no source text and
needs no identity or source on the machine.
@spec format_version() :: pos_integer()
The version tag to_binary/1 writes and from_binary/1 checks. A bare
integer, so a future format change is a version bump here rather than an
inference from the blob's shape.
@spec from_binary(blob :: binary()) :: {:ok, Statifier.Machine.t()} | {:error, :not_a_statifier_blob} | {:error, {:unsupported_format_version, term()}} | {:error, {:compile_failed, [Statifier.error()]}} | {:error, {:identity_mismatch, expected :: Statifier.Machine.Identity.t(), actual :: Statifier.Machine.Identity.t() | nil}}
Decodes a to_binary/1 envelope and recompiles it into a Machine.t().
Checks run in this order, and the order matters: decode safely, then check
the envelope's tag and shape, then its format version, then recompile the
stored source under the stored options through Statifier.compile/2, then
compare the recompiled Machine's identity against the blob's own. Version
before recompile before identity, because the identity being checked is
the recompiled Machine's - there is no identity to compare until the
recompile has run, and a version this build cannot read at all should
report as a version mismatch rather than failing to compile for reasons
that have nothing to do with the source.
{:error, {:compile_failed, errors}} carries Statifier.compile/2's own
[Statifier.error()] list unchanged - a blob whose source no longer
compiles under this build (for instance a validator check tightened across
a library upgrade) is a real, distinct failure and must not be flattened
into :not_a_statifier_blob.
{:error, {:identity_mismatch, expected, actual}}'s expected is the
blob's own stored identity and actual is the recompiled Machine's -
Position's own argument order. Both are compared with
Statifier.Machine.Identity.matches?/2, never ==/2 on the struct
(ADR-0052 decision 1): a future identity field addition should not
silently change what "the same chart" means at this call site either.
Returns {:error, :not_a_statifier_blob} for anything that is not this
module's tagged envelope - a foreign term_to_binary blob, garbage bytes,
or a well-formed envelope whose source is not a binary or whose opts are
not a keyword list.
@spec to_binary(machine :: Statifier.Machine.t()) :: {:ok, binary()} | {:error, :unidentified_chart}
Encodes machine as a tagged, versioned binary envelope carrying its SCXML
source, its persisted compile_opts, and its Statifier.Machine.Identity.t()
- never a compiled term.
Returns {:error, :unidentified_chart} when machine.identity or
machine.source is nil - a Machine built without either (for instance
one that came straight from Statifier.Compiler.compile/1 rather than
Statifier.compile/2) has nothing for from_binary/1 to recompile from or
check a future load against, so no blob is produced for it at all.
The payload is machine.source and machine.compile_opts verbatim, never
machine itself - the whole point of this module is that a chart's binary
form holds nothing Statifier.compile/2 cannot reproduce, which is what
keeps the blob far smaller than term_to_binary(machine) for the same
chart: the compiled states, transitions, and expressions are the
overwhelming majority of a Machine's bytes.