# `Statifier.Session.Recording`
[🔗](https://github.com/riddler/statifier-ex/blob/v2.0.0/lib/statifier/session/recording.ex#L1)

The four-input replay recording (ADR-0029), as a value.

A recording holds exactly what a replay needs to reconstruct a run:

  - `machine` - the compiled document a session was started over
    (`Statifier.Machine`, the output of `Statifier.compile/1`).
  - `opts` - the normalized options `Statifier.Session.start_link/2` would
    have passed to `Statifier.MachineState.new/2`: `:session_id`, `:trace`,
    `:datamodel`, and `:max_macrostep_rounds`, defaulted and sorted so two
    recordings of the same run compare equal.
  - `entries` - the delivered events, timer firings, cancel markers, and
    `interpret/2` batches, in the session's serialized input order.

Together, `machine` and the first entry's implicit initialization plus the
rest of `entries` are the whole of what a run needs to be reproduced -
nothing else about a session (its pid, its subscribers, its live timer
references) is part of a recording, because none of it is an input; all of
it is either derived or process-shaped.

## The route snapshot rides on the entry, not as a fifth input

ADR-0048 decision 2 has the caller stamp a `Statifier.Send.Routes.t()`
snapshot onto `%MachineState{}` before every core drive; decision 3 has
each recorded entry that triggers a drive carry the snapshot that drive was
judged against. This is an *attribute* of an existing entry, not a new
recording input and not a new entry kind - ADR-0029's four inputs
(`machine`, `opts`, and the two entry-producing kinds it already named) are
unchanged in kind. Every `entry()` variant therefore widens by one trailing
`Statifier.Send.Routes.t() | nil` field, including `:cancel`, which becomes
`{:cancel, routes}` rather than a bare atom: a cancel drives
`Statifier.Interpreter.cancel/1`, whose exit walk can run `<onexit>` blocks
containing `<send>`, so it needs a snapshot exactly as every other drive
does. `nil` means the driver declared nothing - the same meaning `nil`
carries on `%MachineState{}.routes` itself - and `opts`'s own `:routes` key
carries the snapshot in force for the implicit session-start
initialization, riding where every other `Statifier.MachineState.new/2`
option already rides.

## `:session_id` is resolved, not supplied

A caller starting a session may never pass `:session_id` at all, letting
`Statifier.MachineState.new/2` generate a fresh `sess_` id (ADR-0008). A
recording still needs one concrete value stored, or replaying it would
regenerate a *different* id and diverge from the run it is meant to
reproduce. `new/2` therefore takes the id the session actually settled on -
read back off `machine_state.datamodel["_sessionid"]`, exactly as
`Statifier.Session.init/1` does - not whatever the caller passed (or did
not pass) as an option.

## A batch is one entry, not several

An `interpret/2` call hands a session a list of effects to plan and perform
as one unit, in the same `handle_cast` that serializes every other input.
Splitting that list into one entry per effect would lose the fact that they
arrived together, at one position in the input order, rather than as
several separate calls that happened to be adjacent - a distinction replay
needs, since `Statifier.Session.Effects.plan/1` and any effect-derived
routing decisions apply to the batch as `interpret/2` presented it. Storing
the whole batch as `{:interpret, effects, routes}` preserves that boundary
exactly.

## The timer `ref` is dropped, not recorded

A live session mints a `make_ref/0` correlation id purely to match a fired
`:statifier_delayed_send` message back to the `Statifier.Session.Timers`
entry that scheduled it - a detail of how one process tracks its own
in-flight timers, with no meaning outside that process and no reproducible
value across runs (a fresh reference is unequal to every past one, by
definition). What a recording needs from a firing is only its `send_id`
(`nil` for an unnamed send) and the `Statifier.Event.t()` it delivered, so
`put_timer/3` takes exactly those two and no reference ever reaches this
struct.

## Nothing here reads a clock

Ordering in `entries` is ordinal - each entry's position in the list - and
that is sufficient: ADR-0034 decided that replay reproduces firing *order*
and *relative* timing, never re-waiting the original delays, so a
wall-clock timestamp would be a value replay is obligated to ignore.
ADR-0029 named the four inputs a sound recording needs, and none of them is
a clock reading. This module calls no `Process.*` or `System.*` time
function, and the `Mix.Statifier.AdrGuard` allowlist that lets
`Statifier.Session` alone touch wall-clock time does not name this file.

## The binary contract

`to_binary/1` and `from_binary/1` (ADR-0057) give a recording a versioned
binary envelope: `{:statifier_recording, format_version, chart_blob, opts,
entries, anchor}`. A version-1 envelope omits the trailing `anchor` slot
entirely (five elements, not six carrying `nil`); `from_binary/1` reads
both shapes (see below). A version-2 envelope has all six slots but
predates `caller_context` on `%Statifier.Event{}` and the two
durable-timer effect structs (ADR-0063): its stored inputs decode with
`caller_context: nil` defaulted on import, which is safe exactly because
no context was ever attached to the inputs an older blob holds. Six
slots -

  - `format_version` - this module's own version tag, checked before the
    nested chart is touched (ADR-0057 decision 4): a future format this
    build cannot read reports as a version mismatch rather than failing
    confusingly further in.
  - `chart_blob` - `machine` travels as a nested `Statifier.Chart.to_binary/1`
    blob, never a compiled term (ADR-0014 item 2, ADR-0052 decision 3).
    `from_binary/1` recompiles it through `Chart.from_binary/1`, which
    checks its own format version and its own recompiled identity in its
    own order - a chart-format bump is therefore never forced to be a
    recording-format bump, or the reverse (ADR-0057 decision 3). A nested
    chart failure surfaces wrapped as `{:error, {:chart, reason}}` rather
    than flattened, so the caller always knows which decoder refused.
  - `opts` - the normalized session options, with `:invoke_handlers`
    written as module name strings rather than atoms.
  - `entries` - written in `entries/1`'s append order, not the struct
    field's internal reversed order; that reversal is a prepend-list
    storage optimization this module alone knows about (decision 1 is what
    makes restoring it on decode legal), and a blob that copied the raw
    field would bake that implementation detail into the format.
  - `anchor` - `anchor/1`'s blob, or `nil` - see the moduledoc's "Anchored
    recordings" section (ADR-0060 decision 6). Present only in a version-2
    envelope; a version-1 envelope's absent sixth slot decodes to the same
    `nil`.

`:invoke_handlers` cross the boundary as strings, never as atoms, because
`:safe` decoding refuses to create atoms a blob names and a module's atom
exists on a node only once that module is loaded (ADR-0052's Consequences,
ADR-0057 decision 5). `from_binary/1` resolves every string back with
`String.to_existing_atom/1`, collecting every failure - not just the first
- into `{:error, {:unknown_handler_modules, names}}`, sorted, so a host
learns the whole set of modules it needs to load in one round trip.

What the codec does not, and cannot, verify: that a resolved handler
module's planning callbacks (ADR-0051 decision 4) behave the way they did
when the recording was made. Replay's determinism depends on that
equivalence for any recording naming a handler, and ADR-0057 decision 5
records it as an accepted environmental limit - the same class as
ADR-0034's OTP `MapSet`-iteration caveat - rather than something a codec
could check. `perform/2`, the impure half of a handler, is never called by
replay at all (`lib/statifier/replay.ex`), so it needs no equivalence.

## Anchored recordings

ADR-0060 decision 6: a recording made by a *resumed* session begins
somewhere other than the chart's initial configuration - the position the
session was resumed from. `anchor/1` carries that starting point as a
`Statifier.Position.to_binary/1` blob, never a `%MachineState{}` and never
a `%Machine{}` - the blob keeps "no compiled term is ever serialized" true
by construction (ADR-0052 decision 3), and it is what
`Statifier.Position.from_binary/2` already knows how to identity-check
against `machine/1` on replay. `anchor: nil` (the default, and what every
recording made before this decision decodes to) means "start at
`Statifier.Interpreter.initialize/2`," exactly as before this field
existed. An anchored recording's `entries/1` stream contains no
initialization effects, because a resumed session performs no
initialization (ADR-0060 decisions 1 and 4 skip `initialize/2` entirely) -
`Statifier.Replay.run/1`'s anchored branch reflects that by performing no
effects of its own before folding the first entry.

# `entry`

```elixir
@type entry() ::
  {:event, Statifier.Event.t(), Statifier.Send.Routes.t() | nil}
  | {:invoked_event, invoke_id :: String.t(), Statifier.Event.t(),
     Statifier.Send.Routes.t() | nil}
  | {:cancel, Statifier.Send.Routes.t() | nil}
  | {:timer, send_id :: String.t() | nil, Statifier.Event.t(),
     Statifier.Send.Routes.t() | nil}
  | {:interpret, [Statifier.Effect.t()], Statifier.Send.Routes.t() | nil}
  | {:internal, kind :: :internal | :platform, name :: String.t(),
     Statifier.Event.Cause.origin(), opts :: keyword(),
     Statifier.Send.Routes.t() | nil}
```

One recorded input, in the session's serialized input order.

# `t`

```elixir
@opaque t()
```

# `anchor`

```elixir
@spec anchor(recording :: t()) :: binary() | nil
```

This recording's starting point - a `Statifier.Position.to_binary/1` blob,
or `nil` for a recording that starts at
`Statifier.Interpreter.initialize/2` (see the moduledoc's "Anchored
recordings" section). This is the reader `Statifier.Replay` uses.

# `entries`

```elixir
@spec entries(recording :: t()) :: [entry()]
```

Every recorded entry, in append order.

# `format_version`

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

# `from_binary`

```elixir
@spec from_binary(blob :: binary()) ::
  {:ok, t()}
  | {:error, :not_a_statifier_blob}
  | {:error, {:unsupported_format_version, term()}}
  | {:error, {:chart, term()}}
  | {:error, {:unknown_handler_modules, [String.t()]}}
```

Decodes a `to_binary/1` envelope back into a `t()`.

Checks run in this order: the envelope's own format version, then the
nested chart (recompiled and identity-checked by `Chart.from_binary/1`),
then handler-module resolution - version first because it is checked
before the nested chart is touched (ADR-0057 decision 4), chart before
handlers by this project's own plan default (OQ-1).

`{:error, {:chart, reason}}` carries `Chart.from_binary/1`'s own error
tuple unflattened - two envelopes means two version namespaces, and an
unwrapped `{:unsupported_format_version, v}` would not say which decoder
refused.

`{:error, {:unknown_handler_modules, names}}` collects every unresolvable
handler-module name in one round trip, sorted - not just the first - so a
host learns the whole set of modules it must load before it can decode
this blob.

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 `chart_blob` is not a binary or whose
`opts`/`entries` are not lists.

A version-1 envelope (five slots, written before `anchor` existed) is
read, not refused: it decodes to `anchor: nil` - the same
read-the-old-version courtesy `Statifier.Position.from_binary/2` extends
to its own version 1 (ADR-0059 decision 4, ADR-0060 decision 6's
Consequences). A version-2 envelope (six slots, written before
`caller_context` existed) is read the same way: its stored events and
durable-timer effects gain `caller_context: nil` on import (ADR-0063
decision 5's blessed default).

# `machine`

```elixir
@spec machine(recording :: t()) :: Statifier.Machine.t()
```

The compiled document this recording was made over.

# `new`

```elixir
@spec new(
  machine :: Statifier.Machine.t(),
  opts :: keyword(),
  anchor :: binary() | nil
) :: t()
```

Starts an empty recording over `machine`, normalizing `opts` to exactly
`:session_id`, `:trace`, `:datamodel`, `:max_macrostep_rounds`, `:routes`,
`:invoke_types`, and `:invoke_handlers` (`Statifier.MachineState.new/2`'s
own options, plus `Statifier.Session.start_link/2`'s `:invoke_handlers`),
defaulted the same way those are defaulted, and sorted by key so two
recordings of the same run compare equal regardless of the order their
options were supplied in. `:routes` defaults to `nil` - the
session-start initialization's snapshot (see the moduledoc's "route
snapshot" section). `:invoke_types` defaults to `nil` too - ADR-0051's
registered-type set, recorded once as a normalized option rather than per
entry, since it is fixed for the session's whole lifetime rather than
re-stamped per drive the way `:routes` is. `:invoke_handlers` defaults to
`%{}`, matching `start_link/2`'s own default - it joins `:invoke_types`
here (ADR-0051 decision 4) so a type registered during recording is not
re-classified as unregistered on replay: `Statifier.Replay`'s plan
context is built from this recorded map, not from an empty one.

`opts[:session_id]` should be the id the session actually resolved to
(`machine_state.datamodel["_sessionid"]`), not merely whatever the caller
passed when starting it - see the moduledoc's "`:session_id` is resolved,
not supplied" section.

`anchor`, defaulted `nil`, is the recording's starting point - see the
moduledoc's "Anchored recordings" section. Every existing two-argument
call site is unaffected by this arity widening.

# `opts`

```elixir
@spec opts(recording :: t()) :: keyword()
```

The normalized session options this recording was made under.

# `put_cancel`

```elixir
@spec put_cancel(recording :: t(), routes :: Statifier.Send.Routes.t() | nil) :: t()
```

Appends the cancel marker as the next entry, carrying the route snapshot in
force for the drive it triggers - a cancel drives
`Statifier.Interpreter.cancel/1`, whose exit walk can run `<onexit>` blocks
containing `<send>` (see the moduledoc's "route snapshot" section).

# `put_event`

```elixir
@spec put_event(
  recording :: t(),
  event :: Statifier.Event.t(),
  routes :: Statifier.Send.Routes.t() | nil
) :: t()
```

Appends a delivered external event as the next entry, carrying the
`Statifier.Send.Routes.t()` snapshot (or `nil`) in force for the drive it
triggers (see the moduledoc's "route snapshot" section).

# `put_internal`

```elixir
@spec put_internal(
  recording :: t(),
  kind :: :internal | :platform,
  name :: String.t(),
  origin :: Statifier.Event.Cause.origin(),
  opts :: keyword(),
  routes :: Statifier.Send.Routes.t() | nil
) :: t()
```

Appends a `Statifier.Interpreter.deliver_internal/5` call as the next
entry - `kind`, `name`, `origin` and `opts` exactly as
`Statifier.Session` passed them to that seam (ADR-0039), plus the route
snapshot in force for the drive it triggers. This is *not*
deterministic from the recorded effect stream alone - whether a
`#_scxml_<sessionid>` target resolved depends on which sessions were
alive when the sending session performed its effects - so it has to be an
input in its own right, exactly as a fired timer is (`put_timer/4`
above). It is also the delivery path for an entirely successful
`<send target="#_internal">`, not only for the two spec-6.2.4 failures.

# `put_interpret`

```elixir
@spec put_interpret(
  recording :: t(),
  effects :: [Statifier.Effect.t()],
  routes :: Statifier.Send.Routes.t() | nil
) :: t()
```

Appends one `interpret/2` batch as a single entry, preserving its boundary
(see the moduledoc's "A batch is one entry" section), plus the route
snapshot in force for the drive it triggers.

# `put_invoked_event`

```elixir
@spec put_invoked_event(
  recording :: t(),
  invoke_id :: String.t(),
  event :: Statifier.Event.t(),
  routes :: Statifier.Send.Routes.t() | nil
) :: t()
```

Appends an external event one of this session's own invocations delivered,
keyed by the `invoke_id` that delivered it - the input `Statifier.Replay`
needs to reproduce 6.4.3's drain-time discard, which reads the entry's
origin rather than `event.invokeid` (`Statifier.Session.Inbox`'s `entry`
typedoc) - plus the route snapshot in force for the drive it triggers.

# `put_timer`

```elixir
@spec put_timer(
  recording :: t(),
  send_id :: String.t() | nil,
  event :: Statifier.Event.t(),
  routes :: Statifier.Send.Routes.t() | nil
) :: t()
```

Appends a fired delayed-send timer as the next entry - `send_id` (`nil` for
an unnamed send), the delivered `event`, and the route snapshot in force
for the drive it triggers, with the live session's own correlation
reference dropped (see the moduledoc).

# `size`

```elixir
@spec size(recording :: t()) :: non_neg_integer()
```

The number of recorded entries.

# `to_binary`

```elixir
@spec to_binary(recording :: t()) :: {:ok, binary()} | {:error, :unidentified_chart}
```

Encodes `recording` as a tagged, versioned binary envelope carrying its
chart as a nested `Statifier.Chart.to_binary/1` blob, its normalized
`opts` (with `:invoke_handlers` written as module name strings), and its
`entries/1` in append order - never a compiled term (see the moduledoc's
"The binary contract" section).

Returns `{:error, :unidentified_chart}` exactly when `Chart.to_binary/1`
refuses `recording`'s `machine` - a recording made over a `Machine` built
without `Statifier.compile/2` (so carrying no `identity` and no `source`)
has nothing for a future `from_binary/1` to recompile from or check
against, so no blob is produced for it at all. Recording and replaying
such a session in memory is unaffected; only persistence is refused.

Writes `anchor/1`'s blob (or `nil`) as the envelope's trailing slot -
see the moduledoc's "Anchored recordings" section.

---

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