Statifier.Position (Statifier v2.8.0)

Copy Markdown View Source

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, send_types, and machine: internal_queue because export/1 refuses a non-empty one outright (below), routes, invoke_types and send_types because all three are per-drive or per-session snapshots a driver re-stamps before the next drive (ADR-0048, ADR-0051, ADR-0069) 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 five was forgotten; import/2 always sets internal_queue to a fresh empty queue and routes/invoke_types/send_types to nil, leaving all three for the driver to re-stamp. routes, invoke_types and send_types are omitted the same way from to_binary/1's payload, and from_binary/2 blanks all three 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

Whether the execution whose export/1 map is exported, pinned to from_machine, is untouched at its position by the edit that produced to_machine (ADR-0072 decision 4). Answers true only when every condition below holds, and false otherwise.

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.

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.

Encodes machine_state as a tagged, versioned binary envelope carrying its chart's Statifier.Machine.Identity.t() - never the chart itself.

Types

exported()

@type exported() :: %{required(atom()) => term()}

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

compatible_at?(from_machine, to_machine, exported)

@spec compatible_at?(
  from_machine :: Statifier.Machine.t(),
  to_machine :: Statifier.Machine.t(),
  exported :: term()
) :: boolean()

Whether the execution whose export/1 map is exported, pinned to from_machine, is untouched at its position by the edit that produced to_machine (ADR-0072 decision 4). Answers true only when every condition below holds, and false otherwise.

  • Both machines carry a source, and exported is a map import/2 accepts onto both of them: every id it names, in configuration, entered_states, states_to_invoke, history_values and active_invocations, resolves in to_machine, so import/2 onto to_machine would not refuse.
  • The configuration is legal in to_machine. Each active state has the same kind and the same parent id in both machines, and the configuration resolved in to_machine meets SCXML spec 3.11: it holds exactly one child of the <scxml> element, one or more atomic states, every <state> and <parallel> ancestor of each atomic state it holds, one and only one child of each non-atomic <state> it holds, and every child of each <parallel> it holds. A <history> pseudo-state is never a member of a legal configuration.
  • Each active state's own outgoing surface is byte-identical. For every state in the configuration, the source slices (Statifier.Parser.Location.slice/2 of each element's location over each machine's Statifier.Machine.source/1) of its selectable transitions, of its <onexit> blocks and of its <invoke> elements compare equal, element by element and in order. A slice covers the element and everything inside it, so a changed target, condition, event, executable content, parameter or child content answers false. <onentry> is not compared: it already executed.
  • A changed transition on an ancestor of an active state answers false. The configuration is full, so every ancestor of an active state is itself in it and its transitions are compared by the rule above: a transition on an ancestor is selectable from the active configuration. The one ancestor exported does not name is the root, which export/1 drops and import/2 re-adds; the root holds no transition, <onexit> or <invoke>, so nothing of it is compared, and legality is checked on the configuration with the root re-added.
  • history_values. Every recorded key resolves in to_machine to a <history> pseudo-state with the same history_type and the same parent id as in from_machine, and every recorded member resolves to a descendant of that parent. A recorded value is a configuration the execution will re-enter.
  • states_to_invoke is empty. A non-empty set is a position inside a macrostep, before its invoke pass, and not a position to move across charts.
  • active_invocations needs nothing further: each key names an active state and an index into its <invoke> list, which is compared slice by slice above.

Structural is not behavioural. The predicate does not look past an active state's own surface: an unchanged transition may target a state whose content changed, and that is the new chart's behaviour, not a change at the position. It reads no datamodel and no timer - a pending timer is not in the export at all, only the timer_counter ordinal is - compares no identity, and takes no mapping, so a renamed active state answers false.

The predicate is pure and total: it changes nothing, raises on no input, and answers false for an argument it cannot read. Nothing in this library calls it - not import/2, not Statifier.Chart.diff/3, not a session. Whether an execution moves, and onto which chart, is the host's explicit decision.

export(machine_state)

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

format_version()

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

from_binary(blob, machine)

@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, invoke_types and send_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 any of the three keys. All three come back nil (struct!/2 fills the now-absent keys with their defaults, and all three 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.

import(machine, exported)

@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/send_types as nil - the driver re-stamps all three 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.

to_binary(machine_state)

@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, :invoke_types, and :send_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, invoke_types and send_types are dropped for the same reason export/1 drops them (this module's "export/1 and import/2" section above, and ADR-0064): all three 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.