The versioned binary contract for a position - a Statifier.MachineState.t()
with the compiled chart it walks stripped out and its Statifier.Machine.Identity.t()
carried alongside instead.
This is boundary work, not core work (docs/architecture.md principle 2),
which is why it lives here rather than as MachineState.to_binary/1:
lib/statifier/machine_state.ex already carries the 100% Doctor moduledoc
burden for the core position struct, and encode/decode-with-identity-check
is a concern of persisting a position across process or machine boundaries,
not of computing one. The substance the bead asked for - a to_binary/
from_binary pair with an explicit format version for a MachineState -
is met exactly; only the module the pair lives on differs.
to_binary/1 refuses to encode a MachineState whose Machine carries no
identity ({:error, :unidentified_chart}): that is the structural
guarantee that no position blob can exist that from_binary/2 cannot
check. from_binary/2 decodes safely, checks the envelope's tag, checks
its format version, checks the supplied Machine's identity against the
blob's, and only then rebuilds the MachineState.
Neither function performs I/O; encoding and decoding a binary in memory is
not an effect this module's caller has to route around (ADR-0003 does not
apply to it, and it is not listed in @effect_interpreter_paths).
export/1 and import/2: the migration vocabulary
to_binary/1/from_binary/2 above are the same-revision contract: they
refuse to cross a chart revision at all. export/1 and import/2 are the
deliberate counterpart - a position in ADR-0005 boundary terms ("string
IDs appear only at the API", ADR-0005's Consequences) so a host holding a
position saved against revision A can load it onto revision B on
purpose. import/2 performs no identity check: it does not compare
export/1's :identity key to the target Machine's own identity, and
the malformed-export check does not require :identity to be present or
well-formed. A host hand-editing an export may update, delete, or leave
stale that key, and all three import identically - the key is provenance
for a host that wants to log "migrated from revision X to revision Y", not
a check this module performs for it.
The exported map deliberately omits internal_queue, routes,
invoke_types, and machine: internal_queue because export/1 refuses
a non-empty one outright (below), routes and invoke_types because both
are per-drive/per-session snapshots a driver re-stamps before the next
drive (ADR-0048, ADR-0051) rather than durable position state, and
machine because the whole point of the string-id vocabulary is to let a
host load the exported map onto a different Machine than the one that
produced it. A host reading the map should not conclude any of the four
was forgotten; import/2 always sets internal_queue to a fresh empty
queue and routes/invoke_types to nil, leaving both for the driver
to re-stamp. routes and invoke_types are omitted the same way from
to_binary/1's payload, and from_binary/2 blanks both to nil on
decode regardless of what the blob carries (ADR-0064): the omission is
common to both vocabularies, not particular to the export one.
Summary
Types
The string-id boundary vocabulary export/1 produces and import/2
consumes: configuration, entered_states, and states_to_invoke as
MapSet.t(String.t()); history_values as
%{optional(String.t()) => MapSet.t(String.t())}; active_invocations as
%{optional({String.t(), non_neg_integer()}) => String.t()}; the
invoke_counter/send_counter/timer_counter/datamodel/running/
status/macrostep/microstep/round/trace/max_macrostep_rounds
fields carried verbatim
from MachineState.t(); and identity, the source chart's
Statifier.Machine.Identity.t() | nil - provenance only, per this
module's export/1/import/2 section above.
Functions
Translates machine_state into the string-id migration vocabulary
(exported/0) - the deliberate counterpart to to_binary/1's refusal to
cross a chart revision. See this module's "export/1 and import/2"
section above for what is carried, what is dropped, and why.
The version tag to_binary/1 writes and from_binary/2 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 rebuilds it into a MachineState.t()
walking machine.
Encodes machine_state as a tagged, versioned binary envelope carrying its
chart's Statifier.Machine.Identity.t() - never the chart itself.
Types
The string-id boundary vocabulary export/1 produces and import/2
consumes: configuration, entered_states, and states_to_invoke as
MapSet.t(String.t()); history_values as
%{optional(String.t()) => MapSet.t(String.t())}; active_invocations as
%{optional({String.t(), non_neg_integer()}) => String.t()}; the
invoke_counter/send_counter/timer_counter/datamodel/running/
status/macrostep/microstep/round/trace/max_macrostep_rounds
fields carried verbatim
from MachineState.t(); and identity, the source chart's
Statifier.Machine.Identity.t() | nil - provenance only, per this
module's export/1/import/2 section above.
Functions
@spec export(machine_state :: Statifier.MachineState.t()) :: {:ok, exported()} | {:error, :internal_queue_not_empty} | {:error, {:unnameable_states, [non_neg_integer()]}}
Translates machine_state into the string-id migration vocabulary
(exported/0) - the deliberate counterpart to to_binary/1's refusal to
cross a chart revision. See this module's "export/1 and import/2"
section above for what is carried, what is dropped, and why.
Every state index in every translated field is looked up with
Statifier.Machine.id/2. The root, index 0, has no written id and is
present in every configuration by construction (ADR-0005's full
configuration) - and, empirically, in entered_states too, since the
initial macrostep's own enterStates walk reaches it as an ancestor. It
is the one exception to the rule below, dropped here wherever it appears
and re-added by import/2 to configuration and entered_states, the
two fields it can structurally appear in (states_to_invoke can never
hold it: only a real <state>'s own <invoke> children populate that
field, and the root is not a <state>). Any other index for which
Machine.id/2 returns nil (a state compiled with no author-written id)
makes the whole export refuse rather than silently drop the state:
{:error, {:unnameable_states, indexes}}, sorted ascending, naming every
offending index across every field at once.
active_invocations' invoke_index half of each key stays the integer it
already is - a within-state document-order ordinal over that state's own
<invoke> children (MachineState's own moduledoc), not itself a state
id. It survives states being added or reordered elsewhere in the chart,
but not an edit to that one state's own <invoke> children.
Refuses a machine_state whose internal_queue is non-empty
({:error, :internal_queue_not_empty}, checked with
MachineState.internal_queue_empty?/1 rather than by materializing the
list): the queued internal events were selected against the source
chart's own transitions, so a position mid-macrostep is not a thing to
move across chart revisions. A host drains to quiescence first.
@spec format_version() :: pos_integer()
The version tag to_binary/1 writes and from_binary/2 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(), machine :: Statifier.Machine.t()) :: {:ok, Statifier.MachineState.t()} | {:error, :not_a_statifier_blob} | {:error, {:unsupported_format_version, term()}} | {:error, {:identity_mismatch, expected :: Statifier.Machine.Identity.t(), actual :: Statifier.Machine.Identity.t() | nil}} | {:error, :unidentified_chart}
Decodes a to_binary/1 envelope and rebuilds it into a MachineState.t()
walking machine.
Checks run in this order, and the order matters: decode safely, then check
the envelope's tag, then its format version, then the blob's identity
against machine's, then reattach machine and rebuild the struct.
Checking the version before the identity means a future format whose
identity representation changed reports the version mismatch rather than a
confusing identity one.
A version-1 blob (written before timer_counter existed) is read, not
refused: its payload is upgraded with timer_counter: 0 before the struct
is rebuilt (ADR-0059 decision 4) - 0 is the only correct value, since no
ordinal was ever minted against a version-1 position.
routes and invoke_types are dropped from the decoded payload before
the struct is rebuilt, unconditionally - regardless of blob vintage, and
regardless of what a hand-written or old-encoder blob carries for either
key. Both come back nil (struct!/2 fills the now-absent keys with
their defaults, and both fields default to nil), the same contract
import/2 already gives them: per-drive/per-session snapshots a driver
re-stamps before the next drive, never durable position state
(ADR-0064).
{:error, {:identity_mismatch, expected, actual}}'s expected is the
blob's own identity and actual is the supplied machine's - both carried
in the error so a host can log which chart revision it has and which one
it needed. What to do about it is a choice between two migration
strategies - drain the old revision, or migrate the position with
export/1 and import/2 - laid out in docs/persistence.md. When machine itself carries no identity, the error is
{:error, :unidentified_chart} instead: the host handed over a Machine
it built without a recorded source, which is a different mistake with a
different fix (recompile with a source, or via Statifier.compile/2).
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 payload is not a map.
@spec import(machine :: Statifier.Machine.t(), exported :: exported()) :: {:ok, Statifier.MachineState.t()} | {:error, {:unknown_state_ids, [String.t()]}} | {:error, {:malformed_export, term()}}
Reverses export/1: resolves every string id in exported against
machine (Machine.index/2) and rebuilds a MachineState.t() walking
it. Performs no identity check - see this module's "export/1 and
import/2" section above; exported[:identity] is read by nobody here.
Collects every unknown id before returning, rather than failing on the
first: {:error, {:unknown_state_ids, ids}}, ids sorted ascending, so a
host migrating a position across chart revisions sees the whole list of
states its new revision dropped in one round trip. Re-adds the root index
(0) to configuration and entered_states - the reverse of export/1's
one documented drop.
Rebuilds internal_queue as :queue.new() and routes/invoke_types as
nil - the driver re-stamps both before the next drive, exactly as
export/1's doc names them as dropped. machine is the supplied
argument.
{:error, {:malformed_export, reason}} covers a map missing a required
key, or carrying a value of the wrong shape for its field - a host may
have hand-edited the export, which is the entire point of a string-id
vocabulary, and a value struct!/2 would silently misassign is exactly
what this check exists to catch instead.
@spec to_binary(machine_state :: Statifier.MachineState.t()) :: {:ok, binary()} | {:error, :unidentified_chart}
Encodes machine_state as a tagged, versioned binary envelope carrying its
chart's Statifier.Machine.Identity.t() - never the chart itself.
Returns {:error, :unidentified_chart} when machine_state.machine
carries no identity (Statifier.Machine.identity/1 is nil) - a Machine
built without a recorded source has nothing for from_binary/2 to check a
future load against, so no blob is produced for it at all.
On success, the payload is machine_state as a plain map with :machine,
:routes, and :invoke_types deleted - never %{machine_state | machine: nil}. MachineState's t() declares machine: Machine.t(), not
Machine.t() | nil (lib/statifier/machine_state.ex:415), so assigning
nil there is a dialyzer contract violation, and dialyzer is a full-gate
stage. Dropping :machine from the payload instead violates no type,
keeps ADR-0014 item 2's premise true (no %Predicator.Compiled{}
instruction list or span table is ever written to a blob), and is what
makes the blob far smaller than a naive term_to_binary(machine_state) -
the compiled chart is the overwhelming majority of a small position's
bytes. routes and invoke_types are dropped for the same reason
export/1 drops them (this module's "export/1 and import/2" section
above, and ADR-0064): both are per-drive/per-session snapshots a driver
re-stamps before the next drive, not durable position state, and
Routes.t() in particular holds live session ids that have no business
sitting in a durable blob at rest.