StatifierPersistence.Executions (StatifierPersistence v0.22.0)

Copy Markdown View Source

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).

A door called from inside its own executor refuses

The executor runs inside the step, after the position is loaded and before the new one is written (ADR-0004 decision 3). For the length of each executor call, and of the call to an event builder handed to step/5, which runs at the same point, this module marks the execution as in a step in the calling process, and every public door that takes an execution id - create/4, step/5, fail/4, cancel/3, unpark/3, migrate/4, migrate_tree/4 and inputs/2 - answers {:error, {:reentrant_step, execution_id}} for a marked id before it reads or writes anything. migrate_tree/4 checks its root and every id its plans name. migrate_batch/3 takes no execution id, but its dry run answers {:would_refuse, {:reentrant_step, execution_id}} for a marked id among the executions it lists, before it reads that one, as its apply answers {:refused, {:reentrant_step, execution_id}} for it through migrate/4 or migrate_tree/4. Without the refusal a nested door would read the position the outer step has not written yet, write its own, and have it overwritten when the outer step persists, with nothing reported; the Ecto adapter's lock would not stop it, because its advisory lock is re-entrant for the connection that already holds it. A door called for a different execution id, or from another process, is unaffected, and a host that never calls back into the execution being stepped sees no change (ADR-0004's 2026-09-26 Amendment).

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 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}}.

Summary

Types

What migrate_batch/3's apply answers for one execution (ADR-0017 decision 6).

What migrate_batch/3's dry run answers for one execution (ADR-0017 decision 2).

What migrate_batch/3 answers when it runs (ADR-0017 decision 6).

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. 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.

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.

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), plus {:reentrant_step, execution_id} from a door called for an execution whose executor is running in the calling process (the moduledoc's "A door called from inside its own executor refuses").

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

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

Why migrate/4 refused.

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.

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

The union of create_opt/0 and 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.

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

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

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.

Functions

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.

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).

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.

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

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

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.

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

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).

Applies one migration plan to every :active and every :needs_migration execution on the plan's from hash, or previews it (ADR-0017, docs/adr/0017-migrating-the-executions-on-a-chart.md).

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).

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

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

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.

Types

batch_outcome()

@type batch_outcome() ::
  {:migrated, migrated()}
  | {:refused, migrate_error() | migrate_tree_error()}
  | {:parked,
     {:migration_refused, [migration_finding()]}
     | {:tree_refused, %{required(execution_id()) => tree_refusal()}}}
  | {:skipped, :terminal}

What migrate_batch/3's apply answers for one execution (ADR-0017 decision 6).

  • {:migrated, migrated} - moved; migrated is what migrate/4 answers beside the execution, or, for a linked execution, what migrate_tree/4 answers for its root.
  • {:refused, reason} - refused and written nothing; reason is migrate/4's refusal, or migrate_tree/4's for a linked execution.
  • {:parked, reason} - parked under on_failure: :park, with the refusal that parked it: migrate/4's {:migration_refused, findings}, or migrate_tree/4's {:tree_refused, refusals}.
  • {:skipped, :terminal} - terminal when its turn came.

batch_preview()

@type batch_preview() ::
  {:would_migrate,
   %{
     dropped: [StatifierPersistence.Migration.Plan.state_id()],
     compatible_at: boolean()
   }}
  | {:would_refuse, migrate_error()}
  | {:skipped, :terminal | :linked}

What migrate_batch/3's dry run answers for one execution (ADR-0017 decision 2).

  • {:would_migrate, %{dropped: dropped, compatible_at: boolean}} - the plan would move it. dropped is what migrate/4 would report, and compatible_at is Statifier.Position.compatible_at?/3 over the from machine, the to machine and the execution's export at its position: advice for the host that gates nothing. It is false for an execution whose active state the plan renames, because the predicate takes no mapping.
  • {:would_refuse, reason} - migrate/4 would refuse it, and reason is that refusal (migrate_error/0), {:migration_refused, findings} for the validation against the execution, and {:reentrant_step, execution_id} for an execution whose executor is running in the calling process, answered before it is read.
  • {:skipped, :terminal} - the execution is terminal; there is nothing to move.
  • {:skipped, :linked} - the execution carries a linkage, so the apply moves it through migrate_tree/4 (decision 4), and the dry run does not preview it.

batch_report()

@type batch_report() :: %{
  from: StatifierPersistence.Storage.Adapter.content_hash(),
  to: StatifierPersistence.Storage.Adapter.content_hash(),
  dry_run: boolean(),
  results: [{execution_id(), batch_preview() | batch_outcome()}],
  counts: %{required(atom()) => non_neg_integer()}
}

What migrate_batch/3 answers when it runs (ADR-0017 decision 6).

  • from and to - the plan's two content hashes.
  • dry_run - whether this was the dry run.
  • results - one {execution_id, outcome} per execution the batch took, in the order it took them, ascending execution id: a batch_preview/0 under the dry run, a batch_outcome/0 under the apply.
  • counts - the number of executions that answered each outcome of the mode, every key present, zeros included: :would_migrate, :would_refuse and :skipped under the dry run; :migrated, :refused, :parked and :skipped under the apply.

create_opt()

@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()] -> 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. 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 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: (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 step_opt/0.
  • step_reporter: - this package's own, never a host's; the same reporter step_opt/0 describes, handed the create's effect list.

entry()

@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()

@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()}}
  | {:reentrant_step, execution_id()}

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), plus {:reentrant_step, execution_id} from a door called for an execution whose executor is running in the calling process (the moduledoc's "A door called from inside its own executor refuses").

event_builder()

@type event_builder() :: (Statifier.MachineState.t() ->
                      {: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 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()

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

migrate_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). A retirement refuses for as long as anything pins the hash, but a migration that passed this check can still leave its execution on a tombstone. The check answers for the to hash as it stood when it was read, and the execution row is re-pinned onto it later, in a separate write; nothing makes the two one step against a retirement. So one interleaving is not prevented: the migration reads the to hash as not retired, a retirement of that hash then writes its tombstone, and the migration re-pins its execution row onto the tombstoned hash. The retirement did not see that execution, because when it decided, no row put it on the hash. On StatifierPersistence.Storage.Ecto the retirement decides in one conditional UPDATE of the chart row, whose NOT EXISTS sees, under Postgres's default READ COMMITTED, only the rows committed when the statement runs; the re-pin and the UPDATE write different rows of different tables, so no unique index and no foreign key arbitrates between them, and the per-execution lock the migration takes is one a retirement never takes. Which object enforces what: this check turns a migration to an already-tombstoned hash into this arm; the retirement's own write keeps a tombstone off a hash that an execution it can see still pins; nothing in this package refuses the interleaving above, and this documentation claims nothing about a stricter isolation level a host sets.
  • {: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 (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 (migration_finding/0).
  • anything 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()

@type migrate_tree_error() ::
  {:tree_refused, %{required(execution_id()) => 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 (tree_refusal/0).
  • :tree_migration_unsupported - the store's adapter does not declare 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 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()

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()

@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()) => StatifierPersistence.PinSource.counts()}}
  | {:timer_event_removed, StatifierPersistence.Migration.Plan.state_id(),
     String.t()}
  | {: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.
  • {:timer_event_removed, state_id, event} - the plan keeps state_id, a state of the from chart that could own a timer, and one of its delayed <send>s to the execution itself (no target, a built-in type) names, as a literal event, a name some transition of the from chart listens for and no transition of the to chart does. A timer it scheduled would fire after the migration and its event would be ignored. "Listens for" is Statifier.Chart.check_accepts/2 over each chart's event vocabulary. The check is static over the two charts, because a pin source's count names no event, so it refuses whether or not a timer is pending. A send whose event is an eventexpr, or that writes a target, a targetexpr, a typeexpr or another event processor's type, is never refused this way. Handle the event in the to chart, or drop the state.
  • {: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()

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

The union of create_opt/0 and 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()

@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()

@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()] -> any())}

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

  • executor: (required) - the StatifierPersistence.Executor.t/0 every non-lifecycle effect is handed to, in list order.
  • routes: - the 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 Statifier.Invoke.Types.t/0 snapshot, stamped the same way (st-ADR-0051). Defaults to nil, "the built-in set only".
  • send_types: - the 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 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()

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

Functions

cancel(store, execution_id, opts \\ [])

@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(store, metadata_match, opts \\ [])

@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(store, execution_id, machine, opts)

@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, but a create that passed this check can still leave its execution on a tombstone. The check answers for the hash as it stood when it was read, and the execution row is inserted later, in a separate write; nothing makes the two one step against a retirement. So one interleaving is not prevented: the create reads the hash as not retired, a retirement of that hash then writes its tombstone, and the create inserts its execution row on the tombstoned hash. The retirement did not see that execution, because when it decided, no row put it on the hash. On StatifierPersistence.Storage.Ecto the retirement decides in one conditional UPDATE of the chart row, whose NOT EXISTS sees, under Postgres's default READ COMMITTED, only the rows committed when the statement runs; the insert and the UPDATE write different rows of different tables, so no unique index and no foreign key arbitrates between them, and the per-execution lock the create takes is one a retirement never takes. Which object enforces what: this check turns a create on an already-tombstoned hash into the retired arm; the retirement's own write keeps a tombstone off a hash that an execution it can see still pins; nothing in this package refuses the interleaving above, and this documentation claims nothing about a stricter isolation level a host sets.

ended?(execution)

@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(store, content_hash)

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(store, execution_id, reason, opts \\ [])

@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 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(store, execution_id)

@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(store, execution_id, plan, opts)

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

A timer the plan keeps must still be answered: a kept state's delayed <send> to the execution itself whose literal event the from chart listens for and the to chart no longer does is refused with a {:timer_event_removed, state_id, event} finding, with or without a pin source (migration_finding/0).

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 migrated/0.
  • {:error, reason} - 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).

In this package only migrate_batch/3 calls this function, once per unlinked execution on its plan's from hash, when a host asks it to (ADR-0017 decision 4): saving a chart, creating an execution and stepping one never migrate anything (ADR-0013 decision 9).

migrate_batch(store, plan, opts)

@spec migrate_batch(
  store :: StatifierPersistence.Storage.t(),
  plan :: StatifierPersistence.Migration.Plan.t(),
  opts :: keyword()
) :: {:ok, batch_report()} | {:error, migrate_error()}

Applies one migration plan to every :active and every :needs_migration execution on the plan's from hash, or previews it (ADR-0017, docs/adr/0017-migrating-the-executions-on-a-chart.md).

plan is one StatifierPersistence.Migration.Plan for the pair of chart hashes, applied to every execution on from; there are no per-execution overrides, and an execution the one plan cannot move is refused, never given a plan of its own (decision 1). The host builds the plan, from the blocks package's mapping or by hand; this function never builds one.

Options

  • from_machine: and to_machine: (required) - the two compiled machines, with migrate/4's meaning.
  • dry_run: - true or false, default false (decision 2).
  • on_failure: - :refuse (the default) or :park (decision 3).
  • pin_sources: - a list of StatifierPersistence.PinSource modules, default [], with migrate/4's meaning, handed to every execution's check.
  • serialization: - the {module, config} strategy every entry point takes, with the same default, applied per execution.

A malformed option raises ArgumentError before anything is read, as migrate/4's do.

What it does

First, once for the whole batch and before any execution is read, the plan meets migrate/4's own checks - the static validation against the two machines, a tombstoned to hash, a missing pin source - and the executions on from are listed through StatifierPersistence.Storage.list_execution_ids_by_content_hash/3 with [:active, :needs_migration], outside every exclusion (decision 8). That listing is the batch's work: an execution that lands on from after it is not in this batch. The executions are then taken one at a time, in ascending execution id.

Under the dry run, each execution is read under its own serialization and meets every check migrate/4 makes - the terminal status, the linkage, the from hash, the pin sources, the export, the transform and the import - and nothing is written, so on_failure: changes nothing (decision 2). Each answers a batch_preview/0. An execution whose executor, or whose event builder, is running in the calling process - a dry run started from inside its own step - answers {:would_refuse, {:reentrant_step, execution_id}} before its serialization is asked for or it is read, as the apply refuses it (see "A door called from inside its own executor refuses" above); the other executions are previewed as before. The dry run is advice, never a lock: it holds no exclusion past each execution's own check, and the apply checks everything again.

Under the apply, an execution that carries no linkage moves through migrate/4. One that carries a linkage - a durable child - moves through migrate_tree/4 rooted at it, with plans holding its own id mapped to the plan and machines: the two machines; a node of its subtree on another hash stays where it is, and one on from is moved on its own turn (decision 4). Under on_failure: :refuse a refused execution is written nothing: an :active one stays :active on from and keeps draining there, and a :needs_migration one stays parked. Under on_failure: :park a refusal that migrate/4 or migrate_tree/4 parks is parked, and every parked id is in the report (decision 3). Each execution answers a batch_outcome/0.

A batch is not one unit. Each execution is moved whole or not at all, under its own exclusion; the batch holds no exclusion and no transaction across executions. An interrupted apply leaves the executions it reached moved, refused or parked and the rest untouched on from, and calling it again with the same plan carries on.

Each moved execution emits the one [:statifier_persistence, :execution, :migrated] event migrate/4 or migrate_tree/4 emits for it; the dry run emits none.

One span brackets the call once its options are checked (decision 6): [:statifier_persistence, :execution, :migrate_batch, :start], then [..., :stop] - carrying the report's counts, or every count of the mode at zero beside a whole-batch refusal - or [..., :exception] when anything inside raises, throws or exits, re-raised unchanged. The dry run and a whole-batch refusal open it too; a malformed option raises before it opens. docs/telemetry.md is the contract.

Rollback is a reverse plan, to -> from, handed to this same function (decision 7). It moves every execution on to, including one created there after the forward batch; a host that wants only the moved ones back reads the forward report's results.

What it answers

migrate_tree(store, root_execution_id, plans, opts)

@spec migrate_tree(
  store :: StatifierPersistence.Storage.t(),
  root_execution_id :: execution_id(),
  plans :: %{
    required(execution_id()) => StatifierPersistence.Migration.Plan.t()
  },
  opts :: keyword()
) ::
  {:ok, [{StatifierPersistence.Execution.t(), migrated()}]}
  | {:parked, {:tree_refused, %{required(execution_id()) => 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.

A retirement refuses for as long as anything pins the hash, but a tree that passed this check can still leave a node on a tombstone. The check answers for each node's to hash as it stood when it was read, and the node's execution row is re-pinned onto it later, in the one write below; nothing makes the two one step against a retirement. So one interleaving is not prevented: the migration reads a node's to hash as not retired, a retirement of that hash then writes its tombstone, and the migration re-pins that node's execution row onto the tombstoned hash. The retirement did not see that execution, because when it decided, no row put it on the hash. On StatifierPersistence.Storage.Ecto the retirement decides in one conditional UPDATE of the chart row, whose NOT EXISTS sees, under Postgres's default READ COMMITTED, only the rows committed when the statement runs; the re-pin and the UPDATE write different rows of different tables, so no unique index and no foreign key arbitrates between them, and the per-execution locks the migration takes are ones a retirement never takes. Which object enforces what: this check turns a node whose plan moves to an already-tombstoned hash into that node's {:chart_retired, info} inside {:tree_refused, refusals}; the retirement's own write keeps a tombstone off a hash that an execution it can see still pins; nothing in this package refuses the interleaving above, and this documentation claims nothing about a stricter isolation level a host sets.

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 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} - 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. In this package only migrate_batch/3 calls this function, rooted at each linked execution on its plan's from hash, when a host asks it to (ADR-0017 decision 4): saving, publishing and stepping never migrate anything.

retire_chart(store, content_hash, pin_sources, opts \\ [])

@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 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 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(store, execution_id, machine, event, opts)

@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 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. The builder runs inside the step, so the execution is marked as in a step while it runs: a builder that calls a door of this module for the execution it was handed gets {:error, {:reentrant_step, execution_id}} from that door, as an executor does (see "A door called from inside its own executor refuses" above).

unpark(store, execution_id, opts \\ [])

@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).