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

Re-drives a `Statifier.Session.Recording` through the pure core, with no
process and no timer (ADR-0034).

## The reuse boundary

`run/1` reuses every *deciding* component a live `Statifier.Session`
uses, unchanged: `Statifier.Interpreter.initialize/2`,
`Statifier.Interpreter.handle_event/2`, `Statifier.Interpreter.cancel/1`,
`Statifier.Session.Effects.plan/1`, and `Statifier.Session.Inbox`. This
module's `drain/1` mirrors `Statifier.Session`'s
`handle_continue(:drain, _)` line for line, and its `perform/2` mirrors
`perform_instruction/3` for four of the seven instruction kinds
`Statifier.Session.Effects.instruction()` names:
`{:notify, effect}`, `{:enqueue_event, event}`, `{:unroutable, effect}`,
and `{:halt, reason}` are handled exactly as the live session handles
them.

The other three - `{:schedule, ...}`, and the process-facing halves of
`{:notify, ...}`/`{:unroutable, ...}` that `send/2` to subscribers - are
the ones `lib/statifier/session.ex` performs by calling
`Process.send_after/3` or delivering to a subscriber process. This module
is not a process and has no subscribers, so it records what those
instructions mean instead of performing them: a `{:schedule, ...}`
becomes a pending-timer credit rather than a real `Process.send_after/3`
call, and every `{:notify, ...}`/`{:unroutable, ...}` becomes a message
appended to `result().stream` instead of a `send/2`. Everything above
that line (deciding what to do) is reused byte for byte; everything below
it (performing it against a real process and a real clock) is replaced,
which is exactly the split `lib/statifier/session.ex`'s own moduledoc
documents and ADR-0003 is the warrant for (ADR-0034).

## Why replacing `{:schedule, ...}` dissolves the double-delivery seam

A live session's `{:schedule, ...}` clause arms a real
`Process.send_after/3` timer. Feeding a recording that already holds both
the `{:interpret, effects, routes}` entry that scheduled a `:send_delayed`
and the later `{:timer, send_id, event, routes}` entry recording its firing
into a *live*
session would arm a second, real timer on top of the firing already
waiting in the recording - delivering the event twice. This module never
arms a timer at all: `{:schedule, send_id, _delay_ms, _event}` only
increments a plain count under `send_id` in `pending`, and a
`{:timer, send_id, event, routes}` entry draws one credit from that count
before
enqueuing the event. Each recorded firing is therefore delivered exactly
once, at its recorded position (ADR-0034 decision 1).

`pending` and `raced` are plain `%{send_id() => non_neg_integer()}` count
maps rather than `Statifier.Session.Timers`, which is keyed by
`reference()` - minting a reference here would be the one impure call in
a module whose entire claim is purity. Counts are sufficient because
replay never cancels a *real* timer and never needs to correlate a firing
to the specific arming that produced it; spec 6.3's "cancel them all" is a
whole-key move either way. `nil` (an unnamed `send_delayed`) is a
legitimate key in both maps.

A `{:cancel_timers, send_id}` instruction does not delete `send_id`'s
pending count - it moves the whole count from `pending` into `raced`.
This models `Process.cancel_timer/1` returning `false` when the delay has
already elapsed and the delayed-send message is already sitting in the
live session's mailbox: the cancel does not unsend it, and
`lib/statifier/session.ex`'s `handle_info/2` enqueues it unconditionally
when it arrives. A recording can therefore legitimately hold a
`{:timer, send_id, event, routes}` entry *after* the `{:cancel, ...}` effect
that
cancelled that same id - the cancel and the fire raced, and the fire won.
A firing draws credit from `pending` first and `raced` second, delivered
normally either way; only a firing with credit in neither map means the
recording is inconsistent with the machine it is being replayed against,
which `run/1` reports as `{:error, {:unscheduled_timer_firing, send_id}}`
rather than silently proceeding.

## `{:deliver, ...}`/`{:raise, ...}` defer to the recorded `{:internal, ...}` entry

A live session's `{:raise, ...}` instruction and every `{:deliver, ...}`
route except a self-addressed `{:session, sid}` reach
`Statifier.Interpreter.deliver_internal/5` through
`Statifier.Session`'s own private seam, and every one of those calls is
recorded as an `{:internal, kind, name, origin, opts, routes}` entry
(ADR-0039, ADR-0029) at its own position in the session's serialized
input order - interleaved with, not nested inside, the entry that
triggered it. Performing the delivery again while re-deriving that
triggering entry's own effects would raise the same internal event twice,
so this module's `perform_instruction/3` clauses for `{:raise, ...}` and
`{:deliver, ...}` (other than the self-match) are no-ops: the *only*
place `Interpreter.deliver_internal/5` is called from this module is
`apply_entry/2`'s own `{:internal, ...}` clause, walking the recording's
flat entry list. A self-addressed `{:session, sid}` needs no recorded
entry at all and resolves locally by plain equality against
`state.machine_state.datamodel["_sessionid"]`, deterministically, with no
registry involved live or in replay (decision 10) - the same reason
`Statifier.Session`'s own resolver never calls `deliver_internal/5` for
that one route either.

## Replay re-supplies the recorded snapshot rather than rebuilding one

ADR-0048 decision 2 has the caller stamp a route snapshot before every core
drive; decision 3 has each recorded entry carry the snapshot that drive was
judged against. `apply_entry/2` therefore does exactly one thing with each
entry's trailing `Statifier.Send.Routes.t() | nil` field before triggering
the drive it names: `Statifier.MachineState.put_routes/2` it onto
`state.machine_state`. It never recomputes a snapshot from
`Statifier.Registry` or from any other live fact - this module holds no
registry reference anywhere, which is exactly what keeps it a pure fold
over the recording (ADR-0034) rather than a second implementation of
ADR-0048's session-side construction. Re-supplying rather than rebuilding
is also what keeps replay cost bounded by the recording's own size, the
ground ADR-0048 decision 1 argues from.

## No Appendix D function is reimplemented

Every state transition this module performs is a call into
`Statifier.Interpreter` or `Statifier.Session.Effects`; nothing here
duplicates a pseudocode-named function under a new name. `drain/1` is the
one loop this module owns, and it is not part of Appendix D itself - it is
this codebase's own port of `mainEventLoop`'s dequeue tail
(`lib/statifier/session.ex:750-757`'s own comment), reused here with a
`Statifier.Session.Inbox` in place of a process mailbox, exactly as the
live session already reuses it in place of the blocking
`externalQueue.dequeue()`.

# `halt_reason`

```elixir
@type halt_reason() :: :done | :cancelled | :budget_exhausted
```

The three ways a replayed run can come to a stop, mirroring `Statifier.Session`'s `halted`.

# `message`

```elixir
@type message() ::
  {:effect, Statifier.Effect.t()}
  | {:unroutable, Statifier.Effect.t()}
  | {:halted, halt_reason()}
```

One replayed stream message - the subscriber shapes, envelope stripped.

# `result`

```elixir
@type result() :: %{
  machine_state: Statifier.MachineState.t(),
  stream: [message()],
  status: :running | halt_reason()
}
```

What `run/1` returns on success: the terminal position, the replayed stream, and its status.

# `run`

```elixir
@spec run(recording :: Statifier.Session.Recording.t()) ::
  {:ok, result()}
  | {:error, {:unscheduled_timer_firing, String.t() | nil}}
  | {:error, {:anchor, term()}}
```

Replays `recording` from its starting point, then folds `Recording.entries/1`
in order, draining after each one exactly as the live session that produced
the recording did.

The starting point is `Recording.anchor/1`: `nil` (every recording made
before ADR-0060, and any made since without a resume behind it) replays
from scratch, `Statifier.Interpreter.initialize/2` over `Recording.machine/1`
and `Recording.opts/1` - today's path, unchanged. A blob anchor instead
decodes the persisted position via `Statifier.Position.from_binary/2`
against `Recording.machine/1`, performing no effects and draining nothing
before the first entry - a resumed session performed no initialization
either (see the moduledoc's anchored-recordings cross-reference and
`Statifier.Session.Recording`'s "Anchored recordings" section).

Returns `{:error, {:anchor, reason}}` when the anchor blob's identity does
not match `Recording.machine/1`, or is otherwise malformed -
`Statifier.Position.from_binary/2`'s own error, wrapped rather than
flattened so a caller can tell an anchor failure from an
`{:unscheduled_timer_firing, _}` one.

Returns `{:error, {:unscheduled_timer_firing, send_id}}` the moment a
`{:timer, send_id, _event}` entry has no credit in either the `pending`
or `raced` bookkeeping - see the moduledoc for what that means and the
one legitimate case (the cancel/fire race) it does not misfire on.

Also supports a mid-run use case: a recording captured from a session
still running replays to `status: :running` and a `stream` that is the
notified prefix so far, which is what
`Statifier.Session.subscribe(server, pid, catch_up: true)` hands its
caller for a late subscriber to catch up on (ADR-0049).

---

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