# `StatifierPersistence.Executions`
[🔗](https://github.com/riddler/statifier_persistence/blob/v0.18.0/lib/statifier_persistence/executions.ex#L1)

The execution lifecycle: create and step durable executions with no live Session
process, the loop this package exists to package.

A step runs in ADR-0004 decision 3's order, and the order is the
contract: liveness check on the execution record -> load (guarded) -> re-stamp
`routes`/`invoke_types`/`send_types` unconditionally (with the nil tripwire
from st-ADR-0064: the fields are pattern-matched `nil` before stamping, so an
upstream regression fails loudly here, not silently downstream) -> step
via `Interpreter.handle_event/2` -> execute effects via the executor
seam -> consume `:done` and `:budget_exhausted` into execution status -> assert
`MachineState.internal_queue_empty?/1` -> persist.

Effect execution is at-least-once: a crash between step and persist
re-drives the same event and re-emits the same effects with identical
deterministic keys (st-ADR-0054 decision 3, st-ADR-0059), and this loop
never dedupes - idempotency is the consumer's. `:done` is the only path
to `:completed` (ADR-0004 decision 6); an event delivered to a terminal
execution is discarded with a typed `{:discarded, execution}` result, never an
exception and never a silent step.

## A parked execution takes no event

`:needs_migration` is the status a migration leaves an execution in when
it parks it on the chart it was already pinned to (ADR-0014 decision 1).
It is not terminal, and it takes no event: a delivery through any event
door answers `{:error, {:needs_migration, execution}}` from the execution
record alone, before any position is loaded, and nothing is appended,
consumed, executed or written (decision 2). It is an error rather than a
discard because the execution will take events again: holding the
delivery and retrying it after the execution leaves the arm is the
host's. `fail/4` and `cancel/3` proceed on a parked execution as they do
on an `:active` one, and `unpark/3` puts it back to `:active` on its own
chart, as a corrected `migrate/4` puts it back on the plan's `to` chart
(decision 3).

## A chart says its own execution failed

Two routes reach `:failed`, and both are the chart's own word rather than
the host's - `fail/4` is the host-driven one (ADR-0004 decision 6).

The first is macrostep-budget exhaustion, which also returns
`{:error, {:budget_exhausted, payload}}` after the record is durable.

The second (ADR-0008's 2026-09-06 amendment) is a **failure-classed
final**: a top-level `<final>` whose `<donedata>` carries the reserved
key `statifier_persistence:execution_status` with the value `"failed"`.

    <final id="ended_badly">
      <donedata>
        <param name="statifier_persistence:execution_status" expr="'failed'"/>
      </donedata>
    </final>

Settling there is an ordinary successful step - it returns
`{:ok, %StatifierPersistence.Execution{status: :failed}, machine_state}`, not
the budget route's error tuple, because a chart that says it failed has
not malfunctioned, it has finished. The execution's `failure` string is
`"failed_final"`, the same string the
`[:statifier_persistence, :execution, :terminated]` event reports as `reason`
and `StatifierPersistence.Driver` sends a durable parent as
`{:failed, reason: ...}`, so a `:first_error` fan-out cancels the failed
child's siblings through the cascade ADR-0008 decision 5 already built.
The resolved `<donedata>` reaches the parent verbatim, tag included -
nothing is stripped.

The value set is closed at `"failed"`: any other value is ignored and the
execution takes the status it would have taken with no key at all, so a chart
cannot claim a `:completed` it did not reach or a `:cancelled` that is
the parent's word. An unhandled `error.communication` or
`error.execution` is **not** a route: a chart that raises an error it
does not catch stays `:active`, which is a chart bug its author fixes
with a transition to a failure-classed final, not a status this package
infers on the author's behalf (amendment decision 4).

Before 0.12.0 the reserved key was spelled
`statifier_persistence:run_status`. That spelling is still **read** in
0.12.0 and is dropped in 0.13.0 (ADR-0011 decision 4): where both keys are
present the new one wins, and reading the old one logs one deprecation line
at `:debug` naming the new key.

Executor failures on actionable effects re-enter the chart as
`error.communication` events through `Statifier.Interpreter.deliver_internal/5`
(st-ADR-0039's seam), per st-ADR-0051's failed-communication row: the core
alone mints the planning-time execution-error events, before any effect is
emitted, so every failure an executor can report re-enters uniformly as
`error.communication` (ADR-0004
decision 4). Failures on observational effects are discarded. Re-entry is
single-wave per step: effects the re-entries emit are executed too, but
their failures are not re-entered again, so a deterministically failing
executor cannot loop this library.
Each re-entry delivered is reported, inside the step span, as
`[:statifier_persistence, :execution, :step, :reentered]` with the name,
origin and options it was delivered with, so a host folding the events it
delivered can replay it (`docs/telemetry.md`).

Concurrent deliveries to one execution are ordered by a pluggable per-execution
serialization strategy (ADR-0004 decision 5): every entry point runs its
fetch-to-persist tail inside the strategy's
`c:StatifierPersistence.Serialization.with_execution/3`, selected per call with
`serialization: {module, config}` and defaulting to
`{StatifierPersistence.Serialization.AdapterLock, store}` - the adapter's
own optional `lock_execution/3`. A strategy refusal surfaces unchanged as
`{:error, {:serialization, reason}}`.

# `create_opt`

```elixir
@type create_opt() ::
  {:executor, StatifierPersistence.Executor.t()}
  | {:initialize, keyword()}
  | {:metadata, StatifierPersistence.Storage.Adapter.metadata()}
  | {:linkage, StatifierPersistence.Execution.Linkage.t()}
  | {:serialization, {module(), term()}}
  | {:step_reporter, ([Statifier.Effect.t()] -&gt; any())}
```

Options `create/4` accepts, and only those: an option `create/4` does
not read is not in this type, so Dialyzer reports it rather than the
create silently ignoring it. `t:step_opt/0`'s `invoke_id:` and
`child_count:` are left out too, although the create's telemetry would
carry them: they name the invocation a step answers, and a create
answers none.

- `executor:` (required) - the `t:StatifierPersistence.Executor.t/0`
  every non-lifecycle effect is handed to, in list order.
- `initialize:` - passed to `Statifier.Interpreter.initialize/2`
  unchanged. A create has no stored position to stamp, so the host's
  snapshots reach a new execution here and nowhere else: `routes:`,
  `invoke_types:` and `send_types:` (`t:step_opt/0` says what each
  one is) go inside this list, not beside it. For `send_types:` that
  is also the only way to get it right at all, because
  `Statifier.MachineState.new/2` is the one writer of the
  `_ioprocessors` entry each registered type gets and
  `Statifier.MachineState.put_send_types/2` does not rewrite it: an
  execution created without them there lacks the host's own types
  for its whole life.
- `metadata:` - the optional opaque map of host identities stored
  beside the execution record (ADR-0006 decision 1), defaulting to
  `%{}`. Identities only, never personal data (decision 2); an adapter
  that cannot store a non-empty map refuses the create with
  `{:error, :metadata_unsupported}` (decision 3).
- `linkage:` - this package's own, never a host's. Set by the durable
  subchart `start_child` clause (Phase 3) to record a child's parent
  under the reserved metadata namespace
  (`StatifierPersistence.Execution.Linkage`, ADR-0008 decision 2). A
  host supplies `metadata:` for its own identities; supplying
  `linkage:` from outside this package is a caller bug the same way a
  malformed `metadata:` is.
- `serialization:` - the `{module, config}` per-execution
  serialization strategy the persist tail runs inside (ADR-0004
  decision 5), as on `t:step_opt/0`.
- `step_reporter:` - this package's own, never a host's; the same
  reporter `t:step_opt/0` describes, handed the create's effect list.

# `entry`

```elixir
@type entry() ::
  :create
  | :step
  | :done_invocation
  | :failed_invocation
  | :answer_parent
  | :fail
  | :cancel
```

The fixed vocabulary of public doors `entry` names on this package's own
telemetry (`docs/telemetry.md`). It is the dimension an operator slices
step latency by first, because a `:done_invocation` step and a `:step`
step have different expected shapes.

It is also ADR-0010's door vocabulary: the same seven atoms, stored as
strings on an input log entry, and the record adds no second one. Of the
seven, only `:step`, `:done_invocation` and `:failed_invocation` -
`:answer_parent` among them, since it re-enters the parent through one
of the two invocation doors - ever carry an event into an interpreter,
so those are the doors that append (decision 5's table).

# `error`

```elixir
@type error() ::
  StatifierPersistence.Storage.error()
  | {:needs_migration, StatifierPersistence.Execution.t()}
  | {:budget_exhausted, Statifier.Effect.BudgetExhausted.t()}
  | {:serialization, term()}
  | {:pin_source_failed, {module(), StatifierPersistence.PinSource.reason()}}
```

This module's error vocabulary: the facade's arms, unflattened, plus the
`{:budget_exhausted, payload}` arm returned after a budget-exhausted step
or create has persisted its `:failed` execution record, plus the serialization
strategy's own refusal, surfaced unchanged
(`{:serialization, :not_supported}` from the default strategy over an
adapter with no `lock_execution/3`).

# `event_builder`

```elixir
@type event_builder() :: (Statifier.MachineState.t() -&gt;
                      {:ok, Statifier.Event.t()} | :discard)
```

An event `step/5` can only build once the execution's position is loaded.

Called with the loaded, re-stamped `t:Statifier.MachineState.t/0`, inside
the serialization strategy's `with_execution/3` and before
`Statifier.Interpreter.handle_event/2` - so what it reads and what the
step acts on are the same position under the same exclusion. `{:ok,
event}` steps that event; `:discard` steps nothing and returns
`{:discarded, execution}`.

It exists for events whose *right to be delivered at all* is a property
of the position: an invocation's late answer, which spec 6.4.3 discards
when the invocation is no longer live (`StatifierPersistence.Driver`'s
`done_invocation/5`). This adds no step to ADR-0004 decision 3's order -
the event argument is late-bound, the loop is not re-ordered.

# `execution_id`

```elixir
@type execution_id() :: StatifierPersistence.Storage.Adapter.execution_id()
```

An execution's caller-supplied opaque key (ADR-0004 decision 2).

# `migrate_error`

```elixir
@type migrate_error() ::
  {:invalid_plan, [StatifierPersistence.Migration.Plan.finding()]}
  | {:no_pin_source,
     [StatifierPersistence.Migration.Plan.state_id() | non_neg_integer()]}
  | {:linked, StatifierPersistence.Execution.t()}
  | {:terminal_execution, StatifierPersistence.Execution.t()}
  | {:not_on_from_chart, StatifierPersistence.Storage.Adapter.content_hash(),
     StatifierPersistence.Storage.Adapter.content_hash()}
  | {:migration_refused, [migration_finding()]}
  | error()
```

Why `migrate/4` refused.

- `{:invalid_plan, findings}` - the static validation against the two
  machines (`StatifierPersistence.Migration.Plan.validate/3`), every
  finding at once.
- `{:chart_retired, info}` - the plan's `to` hash is tombstoned
  (ADR-0012 decision 6).
- `{:no_pin_source, states}` - the plan leaves unmapped or drops `states`,
  each a state of the from chart that could own a timer, and `opts`
  supplied no pin source (ADR-0013 decision 6, fail closed). A state the
  chart gives no id is named by its index.
- `{:pin_source_failed, {module, reason}}` - a pin source did not answer
  (`t:StatifierPersistence.PinSource.reason/0`), the same arm
  `retire_chart/4` answers.
- `{:linked, execution}` - the execution carries a linkage: it is a
  durable child of another execution, and its linkage pins the chart it
  walks. `migrate/4` moves no child; `migrate_tree/4` with the child as
  the root moves it and its pin together (ADR-0015's 2026-09-24
  Amendment). Only `migrate/4` answers it.
- `{:terminal_execution, execution}` - the execution is `:completed`,
  `:failed` or `:cancelled`.
- `{:not_on_from_chart, stored_content_hash, plan_from}` - the execution
  is stored on another chart than the plan's `from`.
- `{:migration_refused, findings}` - the validation against the
  execution, every finding at once (`t:migration_finding/0`).
- anything `t:error/0` names - a lock that could not be taken, an
  execution that does not exist, a position that could not be loaded.

# `migrate_tree_error`

```elixir
@type migrate_tree_error() ::
  {:tree_refused, %{required(execution_id()) =&gt; tree_refusal()}}
  | :tree_migration_unsupported
  | {:not_in_tree, [execution_id()]}
  | error()
```

Why `migrate_tree/4` refused.

- `{:tree_refused, refusals}` - one or more nodes refused, `refusals`
  every node's refusal at once, keyed by execution id
  (`t:tree_refusal/0`).
- `:tree_migration_unsupported` - the store's adapter does not declare
  `c:StatifierPersistence.Storage.Adapter.write_tree_migration/2`;
  nothing was read.
- `{:not_in_tree, execution_ids}` - ids in `plans` that are not nodes of
  the tree rooted at the root, sorted.
- `:child_listing_unsupported`, and anything else `t:error/0` names - a
  tree that could not be listed, a root that does not exist, a lock that
  could not be taken, a unit write the adapter refused.

# `migrated`

```elixir
@type migrated() :: %{
  from_content_hash: StatifierPersistence.Storage.Adapter.content_hash(),
  to_content_hash: StatifierPersistence.Storage.Adapter.content_hash(),
  dropped: [StatifierPersistence.Migration.Plan.state_id()]
}
```

What a successful migration reports beside the migrated execution
(ADR-0013 decision 5): the two content hashes, and the dropped states that
were in the execution's configuration. The
`[:statifier_persistence, :execution, :migrated]` event carries the same
facts.

# `migration_finding`

```elixir
@type migration_finding() ::
  {:not_exportable,
   :internal_queue_not_empty | {:unnameable_states, [non_neg_integer()]}}
  | {:unmapped_state,
     :configuration | :entered_states | :states_to_invoke | :history_values,
     StatifierPersistence.Migration.Plan.state_id()}
  | {:invocation_dropped,
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()}}
  | {:invocation_unmapped,
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()}}
  | {:invocation_out_of_range,
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()},
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()},
     non_neg_integer()}
  | {:invocation_element_changed,
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()},
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()}}
  | {:invocations_coincide,
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()},
     [{StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()}]}
  | {:invocation_outside_configuration,
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()},
     {StatifierPersistence.Migration.Plan.state_id(), non_neg_integer()}}
  | {:datamodel_refused, non_neg_integer(),
     StatifierPersistence.Migration.Plan.datamodel_op(),
     :key_present | :key_absent}
  | {:pending_timers,
     [StatifierPersistence.Migration.Plan.state_id() | non_neg_integer()],
     %{required(module()) =&gt; StatifierPersistence.PinSource.counts()}}
  | {:illegal_configuration, [StatifierPersistence.Migration.Plan.state_id()]}
  | {:import_refused, term()}
```

One finding of `migrate/4`'s validation against the execution (ADR-0013
decision 3, its second half). Each names what it is about.

- `{:not_exportable, reason}` - `Statifier.Position.export/1` refused the
  position: `:internal_queue_not_empty` for a position that is not
  quiescent, or `{:unnameable_states, indexes}`. It is the only finding
  when it occurs, because there is no export to check further.
- `{:unmapped_state, field, state_id}` - a state in the exported
  `configuration`, `entered_states`, `states_to_invoke` or
  `history_values` that the plan neither maps (by name or by the same-id
  default) nor drops.
- `{:invocation_dropped, {state_id, ordinal}}` and
  `{:invocation_unmapped, {state_id, ordinal}}` - an active invocation the
  plan does not move whose state the plan drops, or leaves unmapped.
- `{:invocation_out_of_range, {state_id, ordinal}, {to_state_id,
  ordinal}, invoke_count}` - an active invocation kept by the
  same-ordinal default whose ordinal is not one of the to state's
  `<invoke>` children.
- `{:invocation_element_changed, {state_id, ordinal}, {to_state_id,
  to_ordinal}}` - an active invocation kept by the same-ordinal default,
  or moved through the plan's `invocations`, onto an `<invoke>` element
  that is not the one it was started from (ADR-0013's 2026-09-23
  Amendment, finding 1). When the source element authors an `id`, the
  target must author the same `id`; when it authors none, the target
  must author none and the two elements' source text must be
  byte-equal. Its position is never the rule. Name the invocation's move
  onto its own element, or give an unnamed element an `id` before
  editing it. A stored invocation whose from ordinal names no `<invoke>`
  element of its from state has no element to keep, and is refused this
  way too (ADR-0013's 2026-09-23 Amendment on a missing source element).
- `{:invocations_coincide, {to_state_id, ordinal}, sources}` - two or more
  active invocations would land on one key.
- `{:invocation_outside_configuration, {state_id, ordinal}, {to_state_id,
  to_ordinal}}` - an active invocation kept by the same-ordinal default,
  or moved through the plan's `invocations`, onto a state that is not in
  the transformed configuration (ADR-0013's 2026-09-23 Amendment,
  finding 2). Nothing would reach its child there: the engine cancels an
  invocation only when its state exits, so the child would outlive the
  parent. Move it onto a state the migrated configuration holds.
- `{:datamodel_refused, index, operation, :key_present | :key_absent}` -
  a datamodel operation that does not apply at its place in the order.
- `{:pending_timers, states, source_counts}` - the plan leaves unmapped
  `states`, each a state of the from chart that could own a timer, while
  a pin source counted a pending timer for the execution (ADR-0013
  decision 6). `source_counts` is every source's answer under its module.
  A count names no state, so any non-zero count refuses; a plan that
  drops those states instead is not refused. A state the chart gives no
  id is named by its index.
- `{:illegal_configuration, state_ids}` - the transformed configuration
  is not a legal configuration of the to chart (SCXML 3.11, with the
  root added): a compound state in it without exactly one child state in
  it, a parallel state without every child state, an atomic state
  without every proper ancestor, or a history pseudo-state in it
  (ADR-0013's 2026-09-23 Amendment, finding 3). `state_ids` is the
  transformed configuration, sorted. A plan that drops an active leaf
  and not its whole region is refused this way; a plan that leaves a
  state unmapped may answer this beside `:unmapped_state`.
- `{:import_refused, reason}` - `Statifier.Position.import/2` on the to
  machine refused the transformed export.

# `opt`

```elixir
@type opt() :: create_opt() | step_opt()
```

The union of `t:create_opt/0` and `t:step_opt/0`. Neither function's
spec names it: each names its own type, so Dialyzer reports an option
one of them ignores where it is passed.

# `retired_chart`

```elixir
@type retired_chart() :: %{
  content_hash: StatifierPersistence.Storage.Adapter.content_hash(),
  retired_at: DateTime.t(),
  retired_by: String.t()
}
```

What a completed retirement answers (ADR-0012 decision 6): the hash
that was retired, which the row keeps, and the tombstone written on
it.

# `step_opt`

```elixir
@type step_opt() ::
  {:executor, StatifierPersistence.Executor.t()}
  | {:routes, Statifier.MachineState.routes()}
  | {:invoke_types, Statifier.MachineState.invoke_types()}
  | {:send_types, Statifier.MachineState.send_types()}
  | {:serialization, {module(), term()}}
  | {:entry, entry()}
  | {:invoke_id, String.t()}
  | {:child_count, pos_integer()}
  | {:step_reporter, ([Statifier.Effect.t()] -&gt; any())}
```

Options `step/5` accepts, and only those: an option `step/5` does not
read is not in this type.

- `executor:` (required) - the `t:StatifierPersistence.Executor.t/0`
  every non-lifecycle effect is handed to, in list order.
- `routes:` - the `t:Statifier.Send.Routes.t/0` snapshot stamped onto the
  loaded position before the step; host-supplied per call, never read
  back from storage (st-ADR-0048). Defaults to `nil`, "no determination
  made".
- `invoke_types:` - the `t:Statifier.Invoke.Types.t/0` snapshot, stamped
  the same way (st-ADR-0051). Defaults to `nil`, "the built-in set only".
- `send_types:` - the `t:Statifier.Send.Types.t/0` snapshot of the Event
  I/O Processor types the host registers, stamped the same way
  (st-ADR-0069). Defaults to `nil`, under which every non-built-in
  `type` on a `<send>` classifies as unsupported and the element is
  rejected with `error.execution`. None of these three is a
  `t:create_opt/0`: a create takes them inside `initialize:`.
- `serialization:` - the `{module, config}` per-execution serialization
  strategy the fetch-to-persist tail runs inside (ADR-0004 decision 5;
  `create/4` and `fail/4` accept it too). Defaults to
  `{StatifierPersistence.Serialization.AdapterLock, store}`.
- `entry:` - this package's own, never a host's: the public door this
  drive came through, carried on
  `[:statifier_persistence, :execution, :step, :start | :stop]` and
  `[:statifier_persistence, :execution, :discarded]` as `entry` (ADR-0009,
  `docs/telemetry.md`). `StatifierPersistence.Driver` sets it to
  `:done_invocation`, `:failed_invocation` or `:answer_parent` on the
  doors that reach `step/5` rather than being one of its own; every
  other entry point derives its own (`:create`, `:step`, `:fail`,
  `:cancel`). It stopped being telemetry-only with ADR-0010: on an
  adapter that keeps an input log, `entry:` also stamps the stored
  entry's `door` (decision 5). It changes nothing else.
- `invoke_id:` and `child_count:` - this package's own, never a host's,
  and telemetry only. Set by `StatifierPersistence.Driver` beside
  `entry: :answer_parent`, they name the invocation the step is
  answering and its width on
  `[:statifier_persistence, :execution, :step, :stop]` (the ADR-0009 sp-8wv
  amendment). `child_count` is `nil` for a single-child subchart. They
  change nothing else about the step.
- `step_reporter:` - this package's own, never a host's, and the seam
  ADR-0008's `after_step:` amendment (2026-09-08) needed: a 1-arity fun
  this call hands the step's WHOLE effect list to - the list this
  module's persist tail is handed, before it splits the lifecycle
  effects off - once the persist has landed and before the entry point
  returns. Set by `StatifierPersistence.Driver` when, and only when,
  its own `after_step:` is set, and set to a fun that *records* the
  list rather than acting on it: the driver fires the host's callback
  itself, after this function has returned and outside the execution's own
  exclusion (the amendment's clause 3). It is a reporter and not the
  callback because the amendment rules out widening this module's
  public returns to carry the list, and because nothing a host wrote
  should run inside a serialized section this package opened. Its
  return value is discarded and it changes nothing about the step.

# `tree_refusal`

```elixir
@type tree_refusal() ::
  migrate_error()
  | {:machine_missing, StatifierPersistence.Storage.Adapter.content_hash()}
  | {:child_unresolved, execution_id(), String.t()}
```

One node's refusal inside a refused tree (ADR-0015 decisions 3 and 5):
the refusal `migrate/4` would answer for that node, or one of the two
arms only a tree has.

- `{:machine_missing, content_hash}` - the node's plan names a `from` or
  `to` hash with no compiled machine under it in `machines:`. It is a
  static refusal and parks nothing.
- `{:child_unresolved, parent_execution_id, invoke_id}` - a live node
  absent from `plans` whose parent is moved by its plan, and whose
  invocation id the parent's migrated position would no longer name
  among its active invocations (ADR-0015 decision 1's resolve rule).
  Under `on_failure: :park` it parks the named nodes, as ADR-0013
  decision 7's child rule parks the parent.

# `cancel`

```elixir
@spec cancel(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id(),
  opts :: keyword()
) ::
  {:ok, StatifierPersistence.Execution.t()}
  | {:discarded, StatifierPersistence.Execution.t()}
  | {:error, error()}
```

Cancels an execution: the second host-driven terminal transition (ADR-0004
decision 6 as extended by ADR-0008 decision 5), and the one a cascading
cancel writes through.

Cancellation **retains**: no record and no position is deleted, no
interpreter is involved, and the stored position is left untouched - only
the record's status changes, to `:cancelled`. An execution that is already
terminal - cancelled by an earlier, interrupted cascade included - is
discarded with `{:discarded, execution}`, which is what makes re-running a
cascade over an already-cancelled subtree a no-op. A `:needs_migration`
execution is cancelled exactly as an `:active` one is, so a cascading
cancel reaches a parked child (ADR-0014 decision 2).

`opts` accepts `serialization:` only - `fail/4`'s `serialization:`,
without its `driver:`: no chart is stepped by a cancel, on either side of
a linkage.

# `cascade_cancel`

```elixir
@spec cascade_cancel(
  store :: StatifierPersistence.Storage.t(),
  metadata_match :: StatifierPersistence.Storage.Adapter.metadata(),
  opts :: keyword()
) :: {:ok, non_neg_integer()} | {:error, error()}
```

Cancels every execution linked to `parent_execution_id` - for one invocation, or for
all of them - and every execution linked to those, recursively (ADR-0008
decision 5).

Retains: nothing is deleted and every position is left byte-identical;
each execution simply takes the `:cancelled` terminal status through `cancel/3`.

Idempotent, and idempotent in the strong sense a crash needs. The walk
descends into every child it finds, whatever that child's own status, and
`cancel/3` discards an execution that is already terminal - so a cascade
interrupted halfway through a deep tree is completed correctly by
re-running it, and a cascade over a subtree that is already fully
cancelled writes nothing at all.

There is no global transaction and there deliberately is none: each
execution's cancel is its own serialized write under its own execution's exclusion
(ADR-0004 decision 5), so a deep tree is O(subtree) writes. Cross-execution
locking is the only way to make it atomic, and this package does not have
it and does not want it.

Termination rests on the execution tree being acyclic, which it is by
construction: a child's execution id strictly extends its parent's
(`StatifierPersistence.Execution.Linkage.child_execution_id/3`), so no execution can be its
own descendant. This is why no depth ceiling is needed (ADR-0008
decision 6).

That same fact is what makes the lock order safe, which is worth stating
because this walk is the one place a cycle would be conceivable. It runs
from inside the caller's own exclusion on every path that has one - the
`{:cancel_invoke, _}` effect fires inside the exiting execution's, and
`first_error`'s settlement fires it inside the PARENT's - and it only
ever takes an exclusion on an execution further down that same subtree. Nothing
here holds a descendant's exclusion and then asks for an ancestor's: a
child releases its own before answering its parent
(`Driver.maybe_answer_parent/3` runs after the drive returns), and the
parent's door is stepped after the settlement's exclusion closes rather
than inside it. So the wait-for relation between two connections embeds
in the execution tree, and an acyclic tree has no cycle to deadlock on.
`test/statifier_persistence/driver_fanout_test.exs` pins the direction;
its Ecto variant runs it against real Postgres advisory locks.

`metadata_match` is a `StatifierPersistence.Execution.Linkage` containment map -
`Linkage.invocation_match/2` to cancel one invocation's subtree,
`Linkage.parent_match/1` for every child a parent has ever started. `opts`
accepts `serialization:` only, threaded to every `cancel/3` call the walk
makes, exactly as `cancel/3` itself accepts it.

# `create`

```elixir
@spec create(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id(),
  machine :: Statifier.Machine.t(),
  opts :: [create_opt()]
) ::
  {:ok, StatifierPersistence.Execution.t(), Statifier.MachineState.t()}
  | {:error, error()}
```

Creates an execution: `Statifier.Interpreter.initialize/2` (which cannot fail),
then the shared persist tail - effects through the executor seam,
`:done`/`:budget_exhausted` consumed into execution status, quiescence
asserted, the record inserted with its encoded position.

Create-exactly-once rests on the adapter's atomic `:execution_exists` refusal
(ADR-0004 decision 2), not on a pre-check here: creating an existing
`execution_id` returns `{:error, :execution_exists}`.

A create whose `initialize/2` exhausts its macrostep budget persists a
`:failed` execution with no position blob (there is no quiescent position to
store - ADR-0004 decision 1) and then returns
`{:error, {:budget_exhausted, payload}}`, so the caller sees both the
durable state and the reason.

`metadata:` rides through to the inserted execution record unchanged (ADR-0006
decision 1). Create is the only place it is set - `step/5` and `fail/4`
carry the stored map forward and take no `metadata:` of their own - and
an adapter that cannot store a non-empty map refuses here, before any
effect is executed: `{:error, :metadata_unsupported}`.

A chart a retirement has tombstoned refuses here too, in the same
place and for the same reason: `{:error, {:chart_retired, info}}`
(ADR-0012 decision 6). An execution created on a retired hash would
persist and then be unresumable, because the rebuild reads the chart
back through `StatifierPersistence.Storage.fetch_chart/2` and gets
the retired arm. A retirement refuses for as long as anything pins
the hash, so a create after one is the single way an execution comes
to stand on a tombstone.

# `ended?`

```elixir
@spec ended?(execution :: StatifierPersistence.Execution.t()) :: boolean()
```

Whether `execution` has ended: `true` when it carries an `ended_at`
stamp, `false` when it does not.

The answer is read off the stamp, not the status. Every write that takes
an execution into `:completed`, `:failed` or `:cancelled` stamps
`ended_at` unless the row already holds a stamp, and nothing clears or
moves a stamp once written. So for an execution this package took into
its terminal status, and whose row nothing has since written back to
another status, the two agree. They disagree in three cases:

- A stamped row written back to a status that is not terminal - through
  `StatifierPersistence.Storage.update_execution/5` or
  `update_execution_status/4` with `:active`, say - keeps its stamp, so
  that execution is not terminal and answers `true`.
- A row that was already terminal before V08 of the migrations helper
  added the column has no stamp and answers `false` - until a later
  terminal write reaches it (a re-delivered settlement recording a
  child's answer is one) and stamps it with that write's time, not the
  time it ended.
- A record from an adapter that does not store the field carries no
  stamp, so a terminal execution from it answers `false`.

Pure: `execution` is the struct any function in this module handed back,
or `StatifierPersistence.Execution.from_record/1` built from a fetched
record, and no store is read.

# `executions_on`

```elixir
@spec executions_on(
  store :: StatifierPersistence.Storage.t(),
  content_hash :: StatifierPersistence.Storage.Adapter.content_hash()
) ::
  {:ok, StatifierPersistence.Storage.Adapter.execution_counts()}
  | {:error, error()}
```

Counts the executions on `content_hash`, per stored arm (ADR-0012
decision 3).

Answers `%{active: n, needs_migration: n, completed: n, failed: n,
cancelled: n, children: n}` - every key present for every hash, and
zeros for a hash this store has never seen. The five arm keys are the
stored statuses and nothing else: `terminal` is a fold this record's
decision 2 names in prose and never stores, so a caller that wants it
adds `completed`, `failed` and `cancelled` itself. `needs_migration`
counts the parked executions on the hash (ADR-0014 decision 4): they
are not terminal, and they pin the chart as `active` ones do, so this
key is how a host finds the executions a migration left behind.

`children` counts the durable-child linkage pins on the hash whose
parent execution is `:active` or `:needs_migration`, whatever arm the
child itself is in (ADR-0012 decision 1, as ADR-0014 decision 4 reads
it). It counts pins and not rows, so it is a different population from
the five arm keys and can be non-zero for a hash carrying no execution
row of its own.

`{:error, :content_hash_query_unsupported}` for a store whose adapter
does not answer the query, without calling the adapter at all.

This is a read of a chart's traffic and **not a retirability test**
(ADR-0012's consequences): the three terminal arms it reports never
block a retirement, and the position rows and host pin sources that do
are not in it. A host sweeping for retirable charts asks this to find
candidates and asks the retirement itself whether a candidate can go.

Read-only, and outside any execution's exclusion by design: it takes no
lock and counts what is committed at the moment it runs.

# `fail`

```elixir
@spec fail(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id(),
  reason :: String.t(),
  opts :: keyword()
) ::
  {:ok, StatifierPersistence.Execution.t()}
  | {:discarded, StatifierPersistence.Execution.t()}
  | {:error, error()}
```

Abandons an execution: the only host-driven terminal transition (ADR-0004
decision 6). No interpreter is involved - abandonment is a host decision
about the execution, not a chart transition - so the stored position is left
untouched and only the record's status and failure reason change.

A terminal execution is discarded, same as `step/5`: `{:discarded, execution}`.
A `:needs_migration` execution is failed exactly as an `:active` one is:
giving up on a parked execution is a host decision about it, not an event
for its chart (ADR-0014 decision 2).
`reason` is the short string stored as the execution's `failure` - keep it a
prefixed, console-readable reason, not an inspect dump.

`opts` accepts `serialization:` - the same `{module, config}` strategy
`create/4` and `step/5` take, with the same default - and `driver:`.

## `driver:` and a linked child (ADR-0008's outside-fail note)

An execution failed here is failed from *outside* the interpreter, so nothing in
this call steps a chart and nothing in it reaches the parent of a durable
subchart child (ADR-0008 decision 2's linkage). Left there, a child a
host abandons this way holds its parent's `<invoke>` `:pending` forever:
every path that answers a parent hangs off a drive of the child, and an
outside fail is the one terminal transition that has no drive.

`driver:` is that answer. Given a `t:StatifierPersistence.Driver.t/0`,
an execution that carried linkage and actually reached `:failed` here answers
its parent with `{:failed, reason: reason}` - the ADR-0008 spelling, the
same payload the automatic path builds from an execution's stored `failure` -
through `StatifierPersistence.Driver.resolve_and_answer_parent/3`, the
same write site the stepped path uses. A fan-out child settles rather
than answering, because that routing lives in
`StatifierPersistence.Driver.answer_parent/3` and both paths reach it.

The driver must be able to answer the parent: either its
`chart_resolver:` resolves the parent's chart, or its `machine` already
*is* the parent's chart. Its `store` is what the answer reads and writes
through, so it is normally a driver over this same store.

Two boundaries this option does not cross. The answer happens **after**
this execution's own serialization section commits, not inside it - the same
order `create/3` and `send_event/4` answer in, and the reason a nested
exclusion is never taken here. And the answer's own outcome does not
change this function's: a parent that has already cancelled the
invocation, or that cannot be resolved, leaves `{:ok, execution}` exactly as it
is. What that window costs, and what closes it, is
`docs/adr/0008-durable-subchart-child-runs.md`'s note.

Without `driver:` nothing about this call changes, for a linked execution or an
unlinked one: no linkage is read and no parent is answered.

# `inputs`

```elixir
@spec inputs(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id()
) ::
  {:ok, [StatifierPersistence.Storage.input()]}
  | :not_supported
  | {:error, error()}
```

Lists `execution_id`'s input log, in the order the execution's interpreter saw it
(ADR-0010 decision 2).

Each entry carries its ordinal (`seq`, dense from zero), the public
door it entered by, and the `%Statifier.Event{}` itself - equal to the
one that was delivered, `caller_context` and all. An entry whose
`event` is `nil` is the closed marker a host-declared cap wrote
(decision 6); a reader mapping this log onto a replay refuses on it
rather than replaying an execution that never happened.

`:not_supported` for a store whose adapter keeps no log - which is not
a failure, since nothing in this package refuses an execution over it
(decision 1). `{:error, :execution_not_found}` for an execution that does not exist,
and `{:ok, []}` for one that has taken no input yet.

Read-only and outside the execution's exclusion by design: this is a
diagnostic read, and nothing in this package consumes it. The replay
itself is `StatifierUI.Trace.Replay.from_events/4`'s, under the mapping
ADR-0010 decision 8 names and no code here builds.

# `migrate`

```elixir
@spec migrate(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id(),
  plan :: StatifierPersistence.Migration.Plan.t(),
  opts :: keyword()
) ::
  {:ok, StatifierPersistence.Execution.t(), migrated()}
  | {:parked, {:migration_refused, [migration_finding()]}}
  | {:error, migrate_error()}
```

Moves one execution from the chart it is pinned to onto another, whole or
not at all (ADR-0013; the park is ADR-0014's).

`plan` is a `StatifierPersistence.Migration.Plan`. The two compiled
machines arrive in `opts`, because a stored chart is opaque to this
package (ADR-0013 decision 9):

- `from_machine:` (required) - the machine whose content hash is the
  plan's `from`; the position is loaded with it, through the identity
  guard, exactly as a step loads it.
- `to_machine:` (required) - the machine whose content hash is the plan's
  `to`. The host saves this chart with
  `StatifierPersistence.Storage.save_chart/3` before it migrates, as it
  does before `create/4` (decision 4).
- `pin_sources:` - the host's list of `StatifierPersistence.PinSource`
  modules, default `[]`, asked for pending timers (decision 6). They
  arrive here in `opts`, where `retire_chart/4` takes the same list as a
  positional argument.
- `on_failure:` - `:refuse` (the default) or `:park` (decision 4).
- `serialization:` - the `{module, config}` strategy every entry point
  takes, with the same default. Its `with_execution/3` is called directly;
  a migration is not a step and takes no step span (decisions 4 and 5).

## Timers

This package stores no timer and changes none: a pending delayed send
stays in the host's queue with its deadline, and a plan that maps the
state around it keeps it (`keep_mapped`). The counters cross verbatim, so
a send id or a timer ordinal the migrated execution mints cannot collide
with one a surviving timer holds (decisions 2 and 6).

A state *could own a timer* when, in the from chart, a `<send>` with a
`delay` or a `delayexpr` appears in its `onentry`, its `onexit`, a
transition it owns (its `<initial>` and a history default included) or
the `<finalize>` of one of its `<invoke>`s, nested `<if>` and `<foreach>`
bodies included. The rule is static because a pin source answers counts,
which name no state:

- a plan that maps every such state needs no pin source, and none is
  asked;
- otherwise, with no `pin_sources:`, the migration is refused with
  `{:no_pin_source, states}` before the execution is read - it fails
  closed, never blind to timers;
- otherwise the sources are asked, through
  `StatifierPersistence.PinSource.collect/3`, with the plan's `from`
  hash and the one execution's id in `:execution_ids`. A source that does
  not answer refuses with `{:pin_source_failed, {module, reason}}`; a
  non-zero count refuses with a `{:pending_timers, states, source_counts}`
  finding when the plan leaves any such state unmapped, and never when it
  drops them all.

## Children

A migration moves one execution and rewrites nothing in any durable
child of it: not the child's row, its linkage or its position (decision
7). A child's linkage pins the child's own chart, and execution metadata
is write-once. A live child reaches its parent by its invocation id
alone, and the invocation ids in the parent's active invocations cross
unchanged: every active invocation maps to a key of the to chart, through
the plan's `invocations` or by the same-ordinal default, and that key
names the `<invoke>` element the invocation was started from, or the
migration is refused with an invocation finding (decision 3; ADR-0013's
2026-09-23 Amendment, finding 1). So the parent's own
position answers whether its children still resolve; no child is read
and no child's lock is taken. Under `on_failure: :park` a refusal parks
the parent only.

A durable child is not this function's to move. An execution that
carries a linkage is refused with `{:linked, execution}` and nothing is
written, under either `on_failure:`: its row and its linkage pin name
one chart, and this function would re-pin the row alone. Migrating a
child together with its linkage pin, or a tree of executions together,
is `migrate_tree/4`'s, with the child as the root for a child moved on
its own (ADR-0015 and its 2026-09-24 Amendment).

## What it does

In this order, and every check and the whole transform come before the
one write (decision 4): the plan is validated against the two machines
(decision 3, static); a tombstoned `to` hash is refused; a plan that
leaves unmapped or drops a state that could own a timer is refused when
no pin source is supplied; then, under the execution's serialization,
the execution is read and refused if it carries a linkage, is terminal,
or is stored on another chart than the plan's `from`; the pin sources
are asked when the plan puts a state that could own a timer at risk; its
position is loaded
with the from machine through `StatifierPersistence.Storage.load_execution_position/3`;
`Statifier.Position.export/1` translates it; the export is checked
against the plan and transformed (decisions 2 and 3); and
`Statifier.Position.import/2` rebuilds it on the to machine. Then one
`StatifierPersistence.Storage.update_execution/5` writes the imported
position back at `:active`, which replaces the identity, the content hash
and the position blob together, and nothing that can fail follows it
(decisions 4 and 9). The counters cross verbatim; the metadata, the input
log and any durable child are not touched (decisions 2 and 7).

Afterwards a load with the to machine passes the identity guard and a
load with the from machine is refused with its `identity_mismatch` arm.
One `[:statifier_persistence, :execution, :migrated]` event is emitted
after the serialization section returns, and nothing is stored as a
trace (decision 5).

## What it answers

- `{:ok, execution, migrated}` - the execution, now `:active` on the `to`
  hash, and `t:migrated/0`.
- `{:error, reason}` - `t:migrate_error/0`. Nothing is written.
- `{:parked, {:migration_refused, findings}}` - under `on_failure: :park`
  only, when the validation against the execution refused. The one write
  is the execution's status, `:needs_migration`, with a `nil` failure;
  its position, content hash, identity, metadata and input log stay as
  they were, on the from chart (ADR-0014 decision 1). Every other refusal
  writes nothing under either value: a static one, a tombstoned `to`
  hash, a missing pin source, a lock that could not be taken, an
  execution that carries a linkage, a terminal execution, one stored on
  another chart, and a pin source that did not answer. The missing pin
  source and the source that did not answer are ADR-0013's 2026-09-23
  Amendment to decisions 3 and 4.

A `:needs_migration` execution is migrated as an `:active` one is, and a
successful migration writes it back at `:active` (ADR-0014 decision 3).

Nothing in this package calls this function: saving a chart, creating an
execution and stepping one never migrate anything (decision 9).

# `migrate_tree`

```elixir
@spec migrate_tree(
  store :: StatifierPersistence.Storage.t(),
  root_execution_id :: execution_id(),
  plans :: %{
    required(execution_id()) =&gt; StatifierPersistence.Migration.Plan.t()
  },
  opts :: keyword()
) ::
  {:ok, [{StatifierPersistence.Execution.t(), migrated()}]}
  | {:parked, {:tree_refused, %{required(execution_id()) =&gt; tree_refusal()}}}
  | {:error, migrate_tree_error()}
```

Moves a tree of executions - a parent and the durable children it
invoked - onto newer charts, whole or not at all (ADR-0015; the write of
a child's linkage pin is ADR-0008's 2026-09-23 Amendment).

`plans` maps an execution id to one `StatifierPersistence.Migration.Plan`,
one per node the host moves; the same plan may serve several nodes. A
node absent from `plans` is left untouched - its row, its linkage and
its position - and, when it is live, must still resolve against its
parent as that parent will stand after the move (decision 1). A child
moved on its own is moved with the child as the root.

## Options

- `machines:` (required) - a map from content hash to compiled machine,
  holding the `from` and `to` machine of every plan. A plan whose
  machine is missing is refused with `{:machine_missing, hash}`.
- `pin_sources:`, `on_failure:` and `serialization:` - `migrate/4`'s,
  with its meanings and defaults.

## What it does

Nothing is read or written for an adapter that does not declare the
unit: that is `{:error, :tree_migration_unsupported}` (decision 3).
Then every node's plan is checked against its two machines exactly as
`migrate/4` checks its one plan - the static validation, a tombstoned
`to` hash, a missing pin source - before any execution is read.

The tree is read through the linkage, as `cascade_cancel/3` reads it,
through every child whatever its status (decision 2), and an id in
`plans` outside it is refused. The exclusion of every named node is
taken through the serialization strategy's `with_execution/3`, each
ancestor before its descendants and siblings in ascending id, and held
until the write has returned; a node absent from `plans` is read and
never locked. The tree is read again under the exclusions, and the
named nodes are checked against that second read.

Every named node is then validated as `migrate/4` validates its one
execution - the same functions, children first and the root last:
refused when terminal or stored on another chart than its plan's
`from`, its pin sources asked with its plan's `from` hash and its own
id, its position loaded through the identity guard, checked and
transformed. Every live node absent from `plans` whose parent is named
is checked against the parent's transformed position. Every refusal
from every node is collected before anything is written.

The write is one call to
`c:StatifierPersistence.Storage.Adapter.write_tree_migration/2`
through `StatifierPersistence.Storage.write_tree_migration/2`: one
transaction on a database, one atomic state transition on an adapter
without one, so every write in it lands or none does (decision 3). It
carries one re-pin per named node, children first: the record
`migrate/4`'s one write would carry, and for a node with a linkage its
pin's new `content_hash`. Nothing that can fail follows it.

## What it answers

- `{:ok, moved}` - `moved` is one `{execution, migrated}` pair per
  named node, children first and the root last, each as `migrate/4`
  answers for one node. One `[:statifier_persistence, :execution,
  :migrated]` event per pair follows, in that order, once every
  exclusion is released (decision 5).
- `{:error, reason}` - `t:migrate_tree_error/0`. No node was written.
- `{:parked, {:tree_refused, refusals}}` - under `on_failure: :park`
  only, when every refusal is one `migrate/4` parks for its own node
  (`{:migration_refused, findings}`) or the resolve rule's
  `{:child_unresolved, _, _}`. Then every named node - including one
  whose own plan would have applied - is written `:needs_migration`
  with a `nil` failure, in the same one unit, and nothing else of it
  changes; no node absent from `plans` is parked (decision 4). Any
  other refusal parks nothing.

A refused or parked tree emits no event. Nothing in this package calls
this function: saving, publishing and stepping never migrate anything.

# `retire_chart`

```elixir
@spec retire_chart(
  store :: StatifierPersistence.Storage.t(),
  content_hash :: StatifierPersistence.Storage.Adapter.content_hash(),
  pin_sources :: [module()],
  opts :: keyword()
) :: {:ok, retired_chart()} | {:error, error()}
```

Retires the chart on `content_hash`, or refuses with every count
(ADR-0012 decisions 5 and 6).

The host-facing door of the two decision 5 names. This one owns
everything that reaches outside the package - the pin sources a host
registered, and the refusal for a source that cannot answer - and
`StatifierPersistence.Storage.retire_chart/3` beneath it owns this
package's own tables, the counts taken inside the transaction, and
the tombstone write.

`pin_sources` is the host's list of
`StatifierPersistence.PinSource` modules, `[]` for a host with none.
Each is asked through `StatifierPersistence.PinSource.collect/3`, with
a context carrying the ids of the `:active` executions on the hash,
because a source such as a timer queue knows executions and never
knows content hashes (decision 4). A `:needs_migration` execution is
not in that list (ADR-0014 decision 4): it already refuses the
retirement through its own count.

## What refuses

A non-zero count anywhere in decision 1's blocking set, as ADR-0014
decision 4 reads it - an `:active` or `:needs_migration` execution row
on the hash, a durable-child linkage pin naming it whose parent is
`:active` or `:needs_migration`, a position row on it, or any source's
non-zero count - answers `{:error, {:pinned, counts}}` and writes
nothing. A parked execution pins its chart as an `:active` one does:
it is not terminal, and `unpark/3` puts it back to `:active` on the
chart it was already pinned to. The
refusal carries every count it knows, this package's own under its own
name and each source's under that source's module name, so a host
learns everything holding the chart in one answer rather than one
refusal per retry. The three terminal execution arms are in that map
and never cause it: a terminal row never goes away, and counting one
as a pin would make a chart permanently unretirable the first time
anything on it finished.

A store that cannot answer the drained query refuses at open with
`{:error, :content_hash_query_unsupported}`, and a store that cannot
carry a tombstone at all with `{:error, :chart_retirement_unsupported}`
- both before any source is asked and before anything is counted.
`{:error, :chart_not_found}` is a hash never stored, and
`{:error, {:chart_retired, info}}` a hash already retired: a second
retirement is the retired arm, never a second tombstone.

## A source that could not answer refuses on its own, and carries no counts

`{:error, {:pin_source_failed, {module, reason}}}`, where `reason` is
`t:StatifierPersistence.PinSource.reason/0`: `{:raised, exception}`,
`{:thrown, value}`, `{:exited, reason}` or `{:invalid_return, value}`.
It is a different arm from
`{:pinned, counts}` and it carries no count map at all, deliberately:
the walk stops at the first source that could not answer, so no
complete count exists to report, and a refusal shaped like a count
would let a host read a partial one as the whole. "The source could
not answer" and "the source answered zero" are different facts
(decision 4), and so are "here is everything holding this chart" and
"here is some of it".

## Options

- `retired_by:` (required) - the opaque host string recorded on the
  row as who asked. This package does not interpret it.
- `now:` - the `t:DateTime.t/0` written as `retired_at`. Defaults to
  `DateTime.utc_now/0`. There is no clock here: deciding *when* a
  chart should be retired is host policy, and nothing in this package
  retires on its own or on a schedule (decision 7).

## Afterwards

The row and its content hash are kept and both blobs are `nil`.
`StatifierPersistence.Storage.fetch_chart/2` on that hash answers the
retired arm rather than `:chart_not_found`, and
`StatifierPersistence.Storage.save_chart/3` refuses it rather than
reviving the row. Retirement is irreversible through the public
surface: a host that retires a hash it still wanted re-authors the
document and saves the result under its new hash.

# `step`

```elixir
@spec step(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id(),
  machine :: Statifier.Machine.t(),
  event :: Statifier.Event.t() | event_builder(),
  opts :: [step_opt()]
) ::
  {:ok, StatifierPersistence.Execution.t(), Statifier.MachineState.t()}
  | {:discarded, StatifierPersistence.Execution.t()}
  | {:error, error()}
```

Delivers one external event to an execution, in ADR-0004 decision 3's order (the
moduledoc quotes it).

An event delivered to a terminal execution returns `{:discarded, execution}` from the
execution record alone, before any position decode. `handle_event/2`'s
`{:error, :not_running}` arm is the structural backstop for an execution record
whose `:active` status lies about a terminal stored position: it discards
too, and repairs the record's status to `:completed` on the way out.

An event delivered to a `:needs_migration` execution is refused whole with
`{:error, {:needs_migration, execution}}`, also from the execution record
alone and before any position decode: nothing is appended to the input
log, no effect is executed and nothing is written (ADR-0014 decision 2).

`event` may also be a `t:event_builder/0` - a fun the loaded position is
handed, for an event only the position can build or decline. A builder
that declines discards the delivery through the same `{:discarded, execution}`
arm.

# `unpark`

```elixir
@spec unpark(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id(),
  opts :: keyword()
) ::
  {:ok, StatifierPersistence.Execution.t()}
  | {:discarded, StatifierPersistence.Execution.t()}
  | {:error, error()}
```

Puts a `:needs_migration` execution back to `:active` on the chart it was
already pinned to (ADR-0014 decision 3): the way out of the arm for a host
that decides the execution should go on unmigrated.

The status is the only thing written. The execution goes on at the
position it was parked at, under the content hash it already carried,
with its blobs, metadata and input log as they were; the write is
`StatifierPersistence.Storage.update_execution_status/4`'s, which carries
every other stored field forward. Nothing is replayed: a delivery that was
refused while the execution was parked is delivered again by the host or
not at all.

An `:active` execution answers `{:ok, execution}` and nothing is written,
so re-running an interrupted unpark changes nothing. A terminal execution
answers `{:discarded, execution}`, as `fail/4` and `cancel/3` do.

`opts` accepts `serialization:` only, with `cancel/3`'s default. The call
runs inside the execution's serialization strategy and opens no step span.
It emits `[:statifier_persistence, :execution, :lock]` for the wait on
that exclusion, as the step seam does, and
`[:statifier_persistence, :execution, :unparked]` once the section has
returned, only when it wrote `:active`; an `:active`, terminal or refused
execution emits no `:unparked` (ADR-0014's telemetry amendment).

---

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