# `Statifier.Position`
[🔗](https://github.com/riddler/statifier-ex/blob/v2.0.0/lib/statifier/position.ex#L1)

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.

# `exported`

```elixir
@type exported() :: %{required(atom()) =&gt; 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.

# `export`

```elixir
@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
(`t: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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
