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 ofStatifier.compile/1).opts- the normalized optionsStatifier.Session.start_link/2would have passed toStatifier.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, andinterpret/2batches, 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-machinetravels as a nestedStatifier.Chart.to_binary/1blob, never a compiled term (ADR-0014 item 2, ADR-0052 decision 3).from_binary/1recompiles it throughChart.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_handlerswritten as module name strings rather than atoms.entries- written inentries/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, ornil- 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 samenil.
: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.
Summary
Functions
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.
Every recorded entry, in append order.
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.
Decodes a to_binary/1 envelope back into a t().
The compiled document this recording was made over.
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.
The normalized session options this recording was made under.
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).
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).
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.
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.
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.
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).
The number of recorded entries.
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).
Types
@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.
@opaque t()
Functions
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.
Every recorded entry, in append order.
@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.
@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).
@spec machine(recording :: t()) :: Statifier.Machine.t()
The compiled document this recording was made over.
@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.
The normalized session options this recording was made under.
@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).
@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).
@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.
@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.
@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.
@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).
@spec size(recording :: t()) :: non_neg_integer()
The number of recorded entries.
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.