Statifier.Position (Statifier v2.0.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, 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.

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

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

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

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