All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Entries for unreleased work are not written here directly. Each issue drops a fragment in changelog.d/; the fragments are assembled into a version section at release. See that README for the format and for when a change warrants an entry at all.

[0.21.0] 2026-09-27

Feature release: a door of StatifierPersistence.Executions called from inside the executor of the execution being stepped now refuses with {:error, {:reentrant_step, execution_id}} instead of having its write overwritten by the outer step with nothing reported, and StatifierPersistence.Retention.prune/3 takes single_batch: true to prune one batch per call, so a host can hold one short transaction of its own per batch.

Upgrading: no schema migration. The statifier floor stays ~> 2.9. A host that never calls back into the execution it is stepping sees no change; code that matches StatifierPersistence.Executions.error/0 exhaustively adds the {:reentrant_step, execution_id} arm. A prune that does not pass single_batch: is unchanged.

Added

  • Every public door of StatifierPersistence.Executions that takes an execution id (create/4, step/5, fail/4, cancel/3, unpark/3, migrate/4, migrate_tree/4, inputs/2) answers {:error, {:reentrant_step, execution_id}} when called for an execution whose executor is running in the calling process, instead of letting the outer step overwrite the nested call's write with nothing reported; a host that never calls back into the execution it is stepping sees no change. Code that matches StatifierPersistence.Executions.error/0 exhaustively gains one arm.
  • StatifierPersistence.Retention.prune/3 takes single_batch: true to prune one batch and answer its counts plus more?, so a host can hold one short transaction of its own per batch; its docs now say that a call inside a caller's transaction prunes every batch in that one transaction.

[0.20.0] 2026-09-26

Feature release: a host can move every execution on a chart hash in one call. StatifierPersistence.Executions.migrate_batch/3 applies one migration plan to each :active and :needs_migration execution on the plan's from hash and answers a per-execution report, with a dry run that writes nothing and a telemetry span around the call. StatifierPersistence.Storage.list_execution_ids_by_content_hash/3 and its optional adapter callback list the executions it covers.

Upgrading: no schema migration, and nothing an existing caller does changes. The statifier floor stays ~> 2.9. A handler that matches StatifierPersistence.Telemetry.events/0 exhaustively adds the three [:statifier_persistence, :execution, :migrate_batch, _] events, and one that matches the callback of [:statifier_persistence, :adapter, :call] exhaustively adds :list_execution_ids_by_content_hash.

Added

  • StatifierPersistence.Executions.migrate_batch/3 applies one migration plan to every :active and :needs_migration execution on the plan's from hash and answers a report with one result per execution and a count per outcome. It refuses an execution the plan cannot take and leaves it where it was unless you pass on_failure: :park, and it moves a durable child through migrate_tree/4 rooted at that child.
  • dry_run: true on migrate_batch/3 previews the batch without writing anything: each execution answers :would_migrate (with the states the plan would drop and the Statifier.Position.compatible_at?/3 answer at its position), :would_refuse (with the refusal) or :skipped.
  • StatifierPersistence.Storage.list_execution_ids_by_content_hash/3 lists the ids of the executions on a content hash in a given set of statuses, and StatifierPersistence.Storage.Adapter.list_execution_ids_by_content_hash/3 is the optional callback behind it, implemented by both shipped adapters. An adapter without the capability or without the callback answers {:error, :content_hash_query_unsupported}, and so does a batch against it.
  • StatifierPersistence.Executions.migrate_batch/3 emits one telemetry span per call: [:statifier_persistence, :execution, :migrate_batch, :start], then [..., :stop] carrying the report's count per outcome as measurements, or [..., :exception] when the batch raises. Every half carries the plan's from and to hashes, dry_run and span_ref; the stop adds outcome (:ok, or :error with the refusal as reason when the whole batch was refused). The dry run and a whole-batch refusal open it too, and StatifierPersistence.Telemetry.events/0 lists the three new names. The per-execution [:statifier_persistence, :execution, :migrated] event is unchanged.
  • prune_scope: on use StatifierPersistence.Testing.StorageConformance, for authors of a storage adapter of their own: given inside: and outside: scopes and a place: function that writes the scope's columns onto an execution's rows, the suite generates one more pruning case proving that a scoped StatifierPersistence.Storage.Adapter.prune_executions/4 batch clears the execution inside the scope and leaves the one outside it whole. The option shipped in 0.19.0, whose section did not name it; nothing changes in this release.

[0.19.0] 2026-09-25

Feature release: a host can confine a prune to one partition of its tables. scope: on StatifierPersistence.Retention.prune/3 takes column equalities over the host's :leading_columns and carries them on every statement each batch runs, and StatifierPersistence.Executions.fail/4 called with driver: no longer draws a Dialyzer warning in the caller.

Breaking for a storage adapter of your own that prunes, and for a host that matches StatifierPersistence.Storage.Adapter.error/0 exhaustively: the optional pruning callback moves from prune_executions/3 to prune_executions/4, and the error type gains :unscoped_adapter, as Changed below lists.

Upgrading: no schema migration. The statifier floor stays ~> 2.9. The "0.18 to 0.19" section of docs/upgrading.md lists each step.

Added

  • scope: on StatifierPersistence.Retention.prune/3: a keyword list of column equalities over columns you placed with :leading_columns, such as scope: [tenant_id: tenant_id], that confines the prune to the rows holding every one of them; each batch's selection, input log check, input log delete and position blob update carries the equalities, so a prune run inside one partition's transaction touches no other partition. Without scope: the prune is unchanged. The in-memory adapter answers {:error, :unscoped_adapter} for a scope, and StatifierPersistence.Storage.prune_executions/4 takes the scope as its fourth argument, defaulting to [].

Changed

  • Breaking for a storage adapter of your own that implements c:StatifierPersistence.Storage.Adapter.prune_executions/3: the optional callback is now prune_executions/4, its fourth argument the scope, [] when the host gave none, and an adapter still exporting prune_executions/3 is no longer counted as declaring pruning, so prune/3 answers {:error, :execution_pruning_unsupported} for it. Add the fourth argument; an adapter that cannot confine a batch to a scope answers {:error, :unscoped_adapter} for any scope that is not [], and a host matching StatifierPersistence.Storage.Adapter.error/0 exhaustively adds a clause for that arm.

Fixed

  • StatifierPersistence.Executions.fail/4 called with driver: no longer draws a Dialyzer "no local return" warning in the calling module: an internal spec on the fail path typed the option list narrower than fail/4's own keyword().

[0.18.0] 2026-09-25

Feature release: the step stop event says whether the delivered event selected a transition, a host can fold the error.communication events a step re-entered after an executor failure, and a durable subchart's single child records its answer so one a parent refused can be delivered again. selection on [:statifier_persistence, :execution, :step, :stop] is :selected or :none whether or not tracing is on, the new [:statifier_persistence, :execution, :step, :reentered] reports each re-entry inside the step span, and StatifierPersistence.Execution.from_record/1 reads a recorded answer's donedata back.

Breaking for a host that calls StatifierPersistence.Executions.migrate/4 on a durable child, or matches StatifierPersistence.Executions.migrate_error/0 exhaustively: migrate/4 refuses an execution that carries a linkage with {:error, {:linked, execution}} and writes nothing, as Changed below lists; move a child with migrate_tree/4. A fetched completed child of a durable subchart no longer reads donedata: nil when its answer was recorded.

Upgrading: no schema migration. The statifier floor moves to ~> 2.9. A handler that matches StatifierPersistence.Telemetry.events/0 exhaustively adds the :reentered event; the "0.17 to 0.18" section of docs/upgrading.md lists each step.

Added

  • [:statifier_persistence, :execution, :step, :reentered], emitted inside the step span once per error.communication the persist tail re-entered after an executor failure, carrying its name, origin and opts so a host folding its delivered events can reach the persisted position.
  • selection on [:statifier_persistence, :execution, :step, :stop]: :selected when the event the step delivered selected a transition, :none when it selected none, whether or not tracing is on, and nil on a stop that delivered no event (:create, :fail, :cancel) or returned no position.

Changed

  • StatifierPersistence.Execution.from_record/1 no longer returns donedata: nil for every record: it reads a recorded {:done, donedata} answer back as donedata, so a fetched completed child of a durable subchart, a fan-out child's included, carries the donedata it answered its parent with. A host that matches donedata: nil on such a record sees the donedata instead.
  • A durable subchart's single child records its answer on its own execution record before its parent's door is tried, so an answer a parked or unreachable parent refused can be delivered again through StatifierPersistence.Driver.answer_parent/3 from the child's fetched record; a single child that ended before this release has no recorded answer and still reads donedata: nil.
  • Breaking for a host that calls StatifierPersistence.Executions.migrate/4 on a durable child, or matches StatifierPersistence.Executions.migrate_error/0 exhaustively: migrate/4 now refuses an execution that carries a linkage with {:error, {:linked, execution}} and writes nothing, under either on_failure: value. Move a child with StatifierPersistence.Executions.migrate_tree/4, the child as the root, and add a clause for {:linked, execution}, or a catch-all, to every case over migrate/4's refusals.
  • Requires statifier ~> 2.9, whose MachineState.last_selection the new selection key is read from.

[0.17.0] 2026-09-24

Feature release: an execution records when it ended, and a host can clear what a finished execution no longer needs. ended_at is stamped by the first write that takes an execution to :completed, :failed or :cancelled, StatifierPersistence.Executions.ended?/1 answers whether it is set, migration V08 adds the column and an index on it, and StatifierPersistence.Retention.prune/3 clears the position blob and the input log of every execution that ended before a cutoff you pass, keeping the execution row.

Breaking for a host that deploys without running V08, and for a storage adapter outside this package: the generated execution schema reads ended_at on every query, so an executions table without the column fails every read; and an adapter of your own must store ended_at and, in update_execution/2, keep a stored stamp over the one a later record carries, as Added below lists and the conformance suite checks.

Upgrading: run V08 before deploying this version. An install already at V07 writes up(for: MyApp.Persistence, from: 8) - from: is inclusive, so that call runs V08 and nothing before it - and V08 backfills nothing, so an execution already terminal reads ended_at as nil until a later terminal write stamps it. The two pruning callbacks are optional: an adapter that exports neither stays conformant, and prune/3 answers {:error, :execution_pruning_unsupported} on it. The statifier floor stays ~> 2.6.

Added

  • An execution records when it ended: ended_at on the stored record and on %StatifierPersistence.Execution{} is stamped by the first write that takes the execution to :completed, :failed or :cancelled, and no later write moves or clears it, even one that puts the row back to :active. It is nil for an execution that has not ended, and for a row that was already terminal before V08 until a later terminal write stamps it with that write's time.
  • StatifierPersistence.Executions.ended?/1 answers whether an execution carries that stamp.
  • V08 of the migrations helper adds the nullable ended_at column to the executions table and an index on it. Run it before deploying this version: the generated execution schema reads the column on every query, so an executions table without it fails every read.
  • StatifierPersistence.Storage.Adapter.execution_record/0 carries ended_at, and update_execution/2 keeps a stored stamp over the one a later record carries. The shared conformance suite checks both halves, so an adapter of your own must store the field and honour the rule.
  • StatifierPersistence.Retention.prune/3 clears the position blob and the input log of every :completed, :failed or :cancelled execution whose ended_at is before a DateTime you pass, in batches, and keeps the execution row with its status, answer and ended_at. It takes no duration and has no default window.
  • StatifierPersistence.Storage.prune_executions/3 and execution_pruning_supported?/1, the one-batch facade beneath it, which answers {:error, :execution_pruning_unsupported} for an adapter that does not declare the capability.
  • Two optional adapter callbacks, supports_execution_pruning?/1 and prune_executions/3, implemented by the in-memory and Ecto adapters and checked by the conformance suite. An adapter of your own that exports neither stays conformant.
  • docs/retention.md: which rows you may delete for a finished execution, and which you must not.

[0.16.0] 2026-09-24

Feature release: use StatifierPersistence.Ecto takes two new options, timestamps_position: and column_collations:, so the tables V01 and V05 create can place inserted_at and updated_at right after the leading columns and declare a collation on a package text column. The release also reports a durable child's automatic answer to a parent whose record does not fetch, or whose chart does not resolve, on [:statifier_persistence, :child, :answered] instead of dropping it.

Breaking for a host whose telemetry handler matches delivery on [:statifier_persistence, :child, :answered] exhaustively: delivery has two new values, :parent_unfetched and :parent_chart_unresolved, as Changed below lists.

Upgrading: no schema migration. Both new options default to the layout earlier releases create, and they apply only to the tables V01 and V05 create. The statifier floor stays ~> 2.6.

Added

  • use StatifierPersistence.Ecto takes timestamps_position: :leading, which places inserted_at and updated_at right after the leading columns in every table V01 and V05 create; the default, :trailing, keeps them last as before.
  • use StatifierPersistence.Ecto takes column_collations: [name: collation], which declares a package text column with that collation in every V01 or V05 CREATE TABLE that declares it - execution_id: "C", for example.

Changed

  • Breaking for a host whose telemetry handler matches delivery on [:statifier_persistence, :child, :answered] exhaustively: delivery has two new values, :parent_unfetched and :parent_chart_unresolved, and on them outcome is the child's own and failed_count is nil, even for a fan-out. Add clauses for the two values or a catch-all.

Fixed

  • A durable child's automatic answer to a parent whose record does not fetch, or whose chart the chart_resolver: does not return, reports [:statifier_persistence, :child, :answered] with delivery: :parent_unfetched or :parent_chart_unresolved instead of being dropped with nothing emitted.

[0.15.1] 2026-09-23

Patch release: fixes and telemetry gaps found after 0.15.0. A drive that raises now closes its step span with a new [:statifier_persistence, :execution, :step, :exception] event, StatifierPersistence.Executions.unpark/3 reports itself on a new [:statifier_persistence, :execution, :unparked] event and on the lock event, and StatifierPersistence.Telemetry.events/0 returns nineteen names. migrate/4 and migrate_tree/4 refuse a kept invocation whose stored ordinal names no <invoke> element, and migrate_tree/4 answers a missing execution the same way on both shipped adapters.

Upgrading: no schema migration, and no error shape a host matches is added or removed. The statifier floor stays ~> 2.6.

Added

  • StatifierPersistence.Executions.unpark/3 emits [:statifier_persistence, :execution, :unparked] (execution_id, content_hash) when it puts a :needs_migration execution back to :active, and [:statifier_persistence, :execution, :lock] for its wait on the execution's exclusion; StatifierPersistence.Telemetry.events/0 returns nineteen names.
  • A telemetry event, [:statifier_persistence, :execution, :step, :exception], closes the step span in place of :stop when a drive raises, throws or exits (a host executor or event builder included), carrying execution_id, entry, span_ref, kind, and a reason and stacktrace narrowed so no raised value or call argument travels; the raise still reaches the caller unchanged. StatifierPersistence.Telemetry.events/0 returns eighteen names.
  • StatifierPersistence.Telemetry.execution_step_exception/2, the emitter of [:statifier_persistence, :execution, :step, :exception].

Fixed

  • StatifierPersistence.Executions.migrate_tree/4 on the Ecto adapter answers {:error, :execution_not_found} for a unit naming an execution that is not stored, as the in-memory adapter does, instead of {:error, {:adapter, :rollback}}. That old answer came under the default serialization, whose per-execution lock is a transaction; under a serialization strategy that opens no transaction, 0.15.0 already answered {:error, :execution_not_found}. The refusal writes nothing and no longer aborts a caller's own enclosing transaction.
  • A raise from a host executor or event builder during a drive through StatifierPersistence.Executions or StatifierPersistence.Driver no longer leaves the [:statifier_persistence, :execution, :step, :start] span open with no closing event.
  • StatifierPersistence.Executions.migrate/4 and migrate_tree/4 refuse, with {:invocation_element_changed, key, target}, a stored active invocation whose ordinal names no <invoke> element of its state when the plan keeps it at that ordinal, instead of carrying it onto whatever element the new chart holds there. Repair the stored position.

[0.15.0] 2026-09-23

Feature release: a parent execution and the durable children it invoked can now be moved onto newer charts together, whole or not at all. StatifierPersistence.Executions.migrate_tree/4 takes one plan per node, checks every node before any is written, and re-pins every named node in one store unit or writes nothing; under on_failure: :park every named node parks instead. A moved child's linkage pin follows it to its new chart. The release also reports, on [:statifier_persistence, :child, :answered], what a parent's door answered a durable child's automatic answer, so an answer a parked parent refused no longer passes unnoticed.

Breaking for a host whose migration plans migrate/4 accepted with an invocation left off the migrated configuration or moved onto another <invoke> element, or with an illegal configuration, and for a host that matches StatifierPersistence.Executions.migration_finding/0 exhaustively: StatifierPersistence.Executions.migrate/4 now refuses those three kinds of plan under three new findings, as Changed below lists; migrate_tree/4 applies the same checks to every node.

Upgrading: no schema migration. The two tree migration callbacks are optional, so a storage adapter outside this package stays conformant without them, and migrate_tree/4 answers {:error, :tree_migration_unsupported} on it. The statifier floor stays ~> 2.6.

Added

  • [:statifier_persistence, :child, :answered] carries delivery (:delivered, :discarded, :needs_migration or :error), so a durable child's automatic answer that a parked parent refused reaches the host instead of passing unnoticed.
  • StatifierPersistence.Executions.migrate_tree/4 emits [:statifier_persistence, :execution, :migrated] once per node it re-pins, the children's before the root's, after its one store unit has returned, with the same keys migrate/4 emits; a refused or parked tree emits it for no node.
  • StatifierPersistence.Testing.StorageConformance gains two tree migration cases for an adapter that exports write_tree_migration/2: every re-pin and park in the list lands with only a moved child's linkage pin rewritten in its metadata, and a list that cannot land one write lands none.
  • StatifierPersistence.Executions.migrate_tree/4 moves a parent execution and its durable children onto newer charts together: one plan per node, every node validated before any is written, children first, all in one store unit or nothing, and under on_failure: :park every named node parks.
  • A moved child's linkage pin is rewritten to the chart it now walks, so the old chart is no longer counted as pinned by it.
  • The storage adapter behaviour gains the optional supports_tree_migration?/1 and write_tree_migration/2 callbacks, implemented by the in-memory and Ecto adapters; an adapter without them makes migrate_tree/4 answer {:error, :tree_migration_unsupported}.
  • StatifierPersistence.Storage.tree_migration_supported?/1 and StatifierPersistence.Storage.write_tree_migration/2, and StatifierPersistence.Storage.error/0 gains :tree_migration_unsupported.

Changed

  • Breaking for a host whose migration plans move a live invocation onto a state the migrated execution is not in, or that matches StatifierPersistence.Executions.migration_finding/0 exhaustively: StatifierPersistence.Executions.migrate/4 now refuses a plan that keeps or moves an active invocation onto a state outside the transformed configuration, answering {:invocation_outside_configuration, {state_id, ordinal}, {to_state_id, to_ordinal}} inside {:migration_refused, findings} (or parking the execution under on_failure: :park) with nothing else written. Before, such a plan answered :ok, and the invoked child outlived the parent's completion because nothing cancels an invocation on a state the parent never exits. Move the invocation onto a state the migrated configuration holds instead, and add a clause for the new finding, or a catch-all, to every case over the findings.
  • Breaking for a host whose migration plans drop an active state without its whole region, or that matches StatifierPersistence.Executions.migration_finding/0 exhaustively: StatifierPersistence.Executions.migrate/4 now refuses a plan whose transformed configuration is not a legal configuration of the to chart, answering {:illegal_configuration, state_ids} inside {:migration_refused, findings} (or parking the execution under on_failure: :park) with nothing else written. Before, a plan that dropped the state an execution waits in answered :ok and left its parent compound state with no active child. Plan a configuration the to chart can hold instead - map the dropped state, or drop its whole region - and add a clause for the new finding, or a catch-all, to every case over the findings.
  • Breaking for a host whose migration plans keep or move a live invocation onto an <invoke> element other than its own, or that matches StatifierPersistence.Executions.migration_finding/0 exhaustively: StatifierPersistence.Executions.migrate/4 now refuses a plan that keeps an active invocation at its ordinal, or moves it through invocations, onto a different <invoke> element of the to chart, answering {:invocation_element_changed, {state_id, ordinal}, {to_state_id, to_ordinal}} inside {:migration_refused, findings} (or parking the execution under on_failure: :park) with nothing else written. The element is the same when the source element's authored id is the target's, or, when the source element authors none, when the target authors none and the two elements' source text is byte-equal; position alone never is. Before, a revision that reordered or replaced a state's <invoke> children migrated with no finding, and the live invocation took another element's finalize and autoforward. Name each invocation's move onto its own element in the plan's invocations, give an unnamed <invoke> an id before editing it, and add a clause for the new finding, or a catch-all, to every case over the findings.

[0.14.0] 2026-09-23

Feature release: an execution can now be moved onto another chart, on purpose and whole. StatifierPersistence.Migration.Plan is the plan as data, checked against the two machines; StatifierPersistence.Executions.migrate/4 applies one to a single execution and either re-pins it to the new chart or writes nothing, refusing a plan that could strand a pending timer unless the host supplies a pin source. A refusal can instead park the execution in a fifth status, :needs_migration, until a corrected plan or StatifierPersistence.Executions.unpark/3 puts it back. Nothing migrates an execution because a chart was saved or published. The release also adds a leading_columns: option for host-owned columns at a fixed position, and a create's retired-chart check that no longer reads the chart's bytes.

Breaking for a host that matches the execution status exhaustively: :needs_migration is a fifth value of StatifierPersistence.Storage.Adapter.execution_status/0, and the drained query's answer gains a sixth key; a storage adapter outside this package must store, count and refuse on the new status, as Changed below lists. Breaking for a host that runs Dialyzer: create/4 and step/5 each name their own option type, so an option the called function does not act on is reported. Breaking for a telemetry handler that matches the adapter-call callback exhaustively: a create's retired-chart check reports two new callback names in place of :fetch_chart.

Upgrading: no schema migration - the status column has no constraint through V07, so :needs_migration is a new stored string and nothing more. Move every node to 0.14.0 before any execution is parked, since an older node does not read the new status. leading_columns: defaults to [] and reaches a table only as V01 or V05 creates it, so an existing install is unchanged. The statifier floor stays ~> 2.6.

Added

  • A fifth execution status, :needs_migration: an execution parked on the chart it was already pinned to. It is not terminal, it takes no event, fail/4 and cancel/3 end it as they end an :active one, and it pins its chart against a retirement as an :active one does. StatifierPersistence.Executions.migrate/4 under on_failure: :park is the one thing that parks an execution.
  • StatifierPersistence.Executions.unpark/3 puts a :needs_migration execution back to :active at the position it was parked at, on its own chart, writing its status and nothing else; an :active execution answers {:ok, execution} unchanged and a terminal one is discarded.
  • StatifierPersistence.Testing.StorageConformance gains a retirement case for a durable child's pin: an adapter that exports retire_chart/3 and supports_metadata?/1 must refuse to retire a chart named by a terminal child's linkage pin while that child's parent is :active, and keep the chart's bytes.
  • StatifierPersistence.Executions.migrate/4 moves one execution onto another chart by a StatifierPersistence.Migration.Plan, whole or not at all (ADR-0013): it takes the two machines in from_machine: and to_machine:, re-pins the position, content hash and identity in one write at :active and answers {:ok, execution, migrated}, or refuses with {:error, reason} and writes nothing. Under on_failure: :park a refusal of the check against the execution instead writes :needs_migration and answers {:parked, reason}. A plan that leaves unmapped or drops a state that could own a timer is refused with {:error, {:no_pin_source, states}} unless the call supplies pin_sources:, and writes nothing under either on_failure:.
  • A telemetry event, [:statifier_persistence, :execution, :migrated], once per successful migration, carrying execution_id, from_content_hash, to_content_hash and dropped; StatifierPersistence.Telemetry.events/0 returns seventeen names.
  • StatifierPersistence.Migration, a documentation module for the namespace: what moving an execution onto another chart is, which module holds the plan and which function applies it, and that it is not StatifierPersistence.Ecto.Migrations, the schema-migration helper.
  • StatifierPersistence.Telemetry.execution_migrated/1, the emitter of [:statifier_persistence, :execution, :migrated], beside the package's other documented emitters.
  • StatifierPersistence.Migration.Plan: the plan that moves an execution from one chart to another, as data (ADR-0013). new/1 builds one and refuses a malformed plan naming the field; to_map/1 and from_map/1 are its one JSON-safe encoding, string keys only; validate/3 checks a plan against the from and to machines and answers every finding at once. StatifierPersistence.Executions.migrate/4 applies one to an execution.
  • StatifierPersistence.Executions.migrate/4 reads pending timers through a pin_sources: option, a list of StatifierPersistence.PinSource modules asked for the one execution (ADR-0013 decision 6): a state could own a timer when a <send> with delay or delayexpr sits in its onentry, onexit, a transition it owns or an <invoke>'s <finalize>, and a plan that maps every such state needs no source. With no source, a plan that leaves one unmapped or drops one is refused with {:error, {:no_pin_source, states}}; a source that cannot answer refuses with {:error, {:pin_source_failed, {module, reason}}}, as retire_chart/4 does; neither writes anything under on_failure: :park. A non-zero count while the plan leaves such a state unmapped is the {:pending_timers, states, source_counts} finding of {:migration_refused, findings}, which parks under :park; a plan that drops the state instead migrates. The send and timer counters are carried, so an id the migrated execution mints cannot collide with a surviving timer's.
  • use StatifierPersistence.Ecto accepts leading_columns: [name: {type, opts}]: the migrations helper places those host-owned columns immediately after id, in the order given, in every table V01 and V05 create; it only places them, so a default or a NOT NULL belongs to a later migration of the host's own.
  • Two optional StatifierPersistence.Storage.Adapter callbacks, supports_retired_info?/1 and fetch_retired_info/2: an adapter that exports both answers whether a content hash is retired without reading the chart's bytes. StatifierPersistence.Storage.Ecto and StatifierPersistence.Storage.InMemory implement them; an adapter that does not export them stays conformant and is read through fetch_chart/2 as before, and the conformance suite checks whichever path the adapter declares.

Changed

  • Breaking for a host that runs Dialyzer. StatifierPersistence.Executions.create/4 and step/5 now each name their own option type, StatifierPersistence.Executions.create_opt/0 and StatifierPersistence.Executions.step_opt/0, instead of sharing StatifierPersistence.Executions.opt/0, so Dialyzer reports an option the called function does not act on: routes:, invoke_types:, send_types:, entry:, invoke_id: or child_count: on create/4, and initialize:, metadata: or linkage: on step/5. Nothing changes at runtime: none of those options changed what the call did, invoke_id: and child_count: on a create reaching only its step telemetry's metadata, as they still do, and a top-level send_types: on create/4 left the execution without the host's own types for its whole life. Pass routes:, invoke_types: and send_types: to create/4 inside initialize: instead. StatifierPersistence.Executions.opt/0 remains, as the union of the two.
  • Breaking for a host that matches StatifierPersistence.Storage.Adapter.execution_status/0 exhaustively: add a clause for :needs_migration, or a catch-all, to every case over an execution's status.
  • Breaking for a host that matches the drained query's answer as a closed map: StatifierPersistence.Executions.executions_on/2 and StatifierPersistence.Storage.count_executions_by_content_hash/2 answer a sixth key, needs_migration, and a {:pinned, counts} refusal carries it under executions.
  • Breaking for a storage adapter outside this package that stores the status or implements count_executions_by_content_hash/2 or retire_chart/3: store and read back :needs_migration, count it under its own key, count a durable child's pin while its parent is :active or :needs_migration, and refuse to retire a chart a :needs_migration execution is on, or one a durable child's pin names while its parent is :needs_migration, as for an :active one. The conformance suite checks each.
  • A delivery to a :needs_migration execution through StatifierPersistence.Executions.step/5 or any StatifierPersistence.Driver door answers {:error, {:needs_migration, execution}}: nothing is appended, executed or written, and retrying the delivery after the execution leaves the arm is the host's. Only StatifierPersistence.Executions.migrate/4 under on_failure: :park parks an execution, so a host that never parks never sees this arm.
  • A durable child's linkage pin counts toward children, and refuses a retirement, while its parent is :needs_migration as well as :active.
  • Breaking for a host whose telemetry handler matches the callback of [:statifier_persistence, :adapter, :call] exhaustively: StatifierPersistence.Storage.check_chart_retired/2, and StatifierPersistence.Executions.create/4 through it, report :supports_retired_info? and :fetch_retired_info in place of :fetch_chart on an adapter that declares the narrow read, as both bundled adapters do. A handler with no clause for the two new names raises, and :telemetry detaches it; add them, or a catch-all.
  • StatifierPersistence.Executions.create/4's check for a retired chart no longer transfers the chart's stored bytes on an adapter that declares the narrow read, so its cost stays flat as charts grow; the [:statifier_persistence, :adapter, :call] event for that check names :supports_retired_info? and :fetch_retired_info instead of :fetch_chart on such an adapter.

Fixed

[0.13.0] 2026-09-20

Feature release: a chart can now be retired. StatifierPersistence.Executions.retire_chart/4 tombstones a chart nothing still uses and refuses with every pin count when something does, executions_on/2 and the new StatifierPersistence.PinSource behaviour answer what is still using it, both chart doors answer a retired arm in place of a retired chart's bytes, and migration V07 adds the tombstone columns and the content-hash index those queries need. The release also carries the host's registered Event I/O Processor types through the execution paths: send_types: on StatifierPersistence.Executions.step/5 and on StatifierPersistence.Driver.new/3, stamped onto the loaded position beside invoke_types:.

Upgrading: run V07 against an existing database. An install already at V06 writes up(for: MyApp.Persistence, from: 7) - from: is inclusive, so that call runs V07 and nothing before it - and V07 copies no data. A host that reads charts gains one arm to handle: StatifierPersistence.Storage.fetch_chart/2 answers {:error, {:chart_retired, info}} for a hash a retirement has tombstoned, in place of :chart_not_found. A host that never retires a chart never sees it. The statifier floor moves to ~> 2.6, the first release carrying host-registered send types.

Added

  • StatifierPersistence.PinSource, a behaviour a host implements so state this package cannot see - a pending timer, an address row - can report named counts against a content hash, with collect/3 gathering each source's counts under its module name and turning a source that raises or answers malformed into {:error, {module, reason}} rather than a zero.
  • StatifierPersistence.Storage.check_chart_retired/2, which answers the retired arm for a machine's own content hash without writing anything.
  • StatifierPersistence.Executions.executions_on/2 counts the executions on one content hash, per stored status.
  • StatifierPersistence.Storage.count_executions_by_content_hash/2 and content_hash_query_supported?/1 over two new optional adapter callbacks, count_executions_by_content_hash/2 and supports_content_hash_query?/1.
  • Migration V07: an index on executions(content_hash), the nullable retired_at and retired_by columns on charts, and - on Postgres - nullable identity_blob and chart_blob on charts.
  • StatifierPersistence.Executions.retire_chart/4 retires a chart, or refuses with every pin count when anything still uses it.
  • StatifierPersistence.Storage.retire_chart/3, chart_retirement_supported?/1 and list_active_execution_ids_by_content_hash/2 over three new optional adapter callbacks, retire_chart/3, supports_chart_retirement?/1 and list_active_execution_ids_by_content_hash/2.
  • A position row on a content hash is a pin: it refuses a retirement of that chart even when no execution runs on it.
  • The generated chart schema carries the retired_at and retired_by columns migration V07 adds.
  • StatifierPersistence.Storage.Adapter.pin_counts/3, pinned?/1 and sources_pinned?/1, with the pin_counts/0, execution_counts/0 and source_counts/0 types: the one shape a retire_chart/3 refusal carries and the predicate for whether what an adapter counted is a pin, so a third-party adapter builds its refusal through them instead of inventing a second shape for one answer.
  • send_types: on StatifierPersistence.Executions.step/5, the Statifier.Send.Types.t/0 snapshot of the host's registered Event I/O Processor types, stamped onto the loaded position the way invoke_types: is; on create/4 it travels inside initialize:.
  • send_types: on StatifierPersistence.Driver.new/3, a driver-level default carried onto every step and, through initialize:, onto the create.

Changed

  • StatifierPersistence.Executions.create/4 refuses a chart a retirement has tombstoned with {:error, {:chart_retired, info}}, before it writes an execution row or executes an effect.
  • The adapter error vocabulary gains :content_hash_query_unsupported, the refusal for an adapter that cannot answer the content-hash count.
  • StatifierPersistence.Storage.fetch_chart/2 gains a {:chart_retired, info} error arm for a retired hash, carrying who retired it and when, instead of :chart_not_found.
  • StatifierPersistence.Storage.save_chart/3 refuses that same arm for a retired hash rather than reviving the row.
  • The adapter error vocabulary gains :chart_retirement_unsupported, the refusal for a store whose chart blob columns are not nullable, and {:pinned, counts}, the refusal carrying every count.
  • StatifierPersistence.Executions.executions_on/2 answers a real children count: the durable-child linkage pins naming the hash whose parent execution is :active, in place of the zero both bundled adapters returned for that key.
  • The statifier floor is ~> 2.6, the first release carrying host-registered send types.

[0.12.0] 2026-09-13

Breaking release: run is retired as the noun for the durable record, and the package speaks execution throughout - modules, functions, callbacks, types, error atoms, telemetry events and metadata, the durable table and its identity column (ADR-0011). No behaviour, arity or return shape changed; only names did, and no compatibility shim, deprecated delegate or alias module ships.

Breaking for storage adapters: all seven StatifierPersistence.Storage.Adapter callbacks rename, and the table under Changed below lists every one of them - the compiler names each in turn. Hosts also rename the run_id: context key, this package's :run_* error atoms, the :tables key (:runs is now rejected), and any raw query naming statifier_runs; telemetry handlers must move to the :execution prefix, which does not dual-emit. Existing databases are migrated in place by the new V06, which copies no data; drain in-flight durable subchart children before upgrading.

Changed

  • Breaking for storage adapters. run is retired as the noun for the durable record; it is execution everywhere (ADR-0011). Nothing about the behaviour, the arities, the return shapes or the identity guard changed - only names. This entry is the complete list of what moved. Rename the seven StatifierPersistence.Storage.Adapter callbacks below in your adapter; the compiler names every one of them, and no compatibility shim, deprecated delegate or alias module ships.

    Callback beforeAfter
    insert_run/2insert_execution/2
    fetch_run/2fetch_execution/2
    update_run/2update_execution/2
    lock_run/3lock_execution/3
    list_runs_by_metadata/2list_executions_by_metadata/2
    supports_run_outcome?/1supports_execution_outcome?/1
    list_run_states_by_metadata/2list_execution_states_by_metadata/2

    append_input/3 and list_inputs/2 keep their names; their run_id() parameter is now execution_id().

  • Breaking for a host-supplied serialization strategy. The second behaviour a host may implement renames its one callback: StatifierPersistence.Serialization.with_run/3 is now with_execution/3, and the shipped Serialization.AdapterLock.with_run/3 is now AdapterLock.with_execution/3. A host that left the strategy at its default implements nothing and is unaffected. The two module names are unchanged - they name a strategy, not the durable record.

  • Breaking, silently, for a host effect executor and a :dispatch function. The run_id: key of StatifierPersistence.Executor.context/0 and of StatifierPersistence.Driver.dispatch_context/0 is now execution_id:. An implementation that pattern-matches %{run_id: id} raises at the first effect; one that reads the key by name gets nil. Rename the key in both.

  • Breaking for a host that pattern-matches this package's error atoms. :run_exists, :run_not_found, :run_outcome_unsupported, :run_states_unsupported, :run_position_missing are now :execution_exists, :execution_not_found, :execution_outcome_unsupported, :execution_states_unsupported, :execution_position_missing. A case with no catch-all raises; one with a catch-all quietly reclassifies a known refusal.

  • Breaking for anything subscribed to this package's telemetry. The event family is now [:statifier_persistence, :execution, :step, :start | :stop], [:statifier_persistence, :execution, :lock] and [:statifier_persistence, :execution, :created | :terminated | :discarded]. There is no dual emit: a handler attached to the old :run names goes silent with no error, so grep your handlers for the old prefix as part of the upgrade. opentelemetry_statifier 0.6.0 moves in lockstep.

  • Telemetry metadata keys run_id, parent_run_id and child_run_id are now execution_id, parent_execution_id and child_execution_id on every event that carries them, including the events whose own names did not change ([:statifier_persistence, :adapter, :call], :identity, :refused, :effect, :failed, :drive, :turns_exhausted and the six [:statifier_persistence, :child, ...] events).

  • Two documented telemetry metadata values rename with them: stage: :run becomes stage: :execution on [:statifier_persistence, :identity, :refused], and reason: :terminal_run becomes reason: :terminal_execution on [:statifier_persistence, :execution, :discarded].

  • The six documented StatifierPersistence.Telemetry emitters rename with their events: run_step_start/3, run_step_stop/2, run_lock/2, run_created/1, run_terminated/1 and run_discarded/1 are now execution_step_start/3, execution_step_stop/2, execution_lock/2, execution_created/1, execution_terminated/1 and execution_discarded/1. The other ten emitters keep their names.

  • Modules: StatifierPersistence.Run is now StatifierPersistence.Execution (struct field :run_id is now :execution_id), StatifierPersistence.Runs is now StatifierPersistence.Executions, and StatifierPersistence.Run.Linkage is now StatifierPersistence.Execution.Linkage, whose child_run_id/3 is now child_execution_id/3. The lifecycle doors keep their own names and arities: Executions.create/4, step/5, fail/4, cancel/3, cascade_cancel/3 and inputs/2.

  • StatifierPersistence.Storage renames nine functions, arities unchanged: insert_run/5, update_run/5, update_run_status/4, fetch_run/2, run_outcome_supported?/1, run_states_supported?/1, list_run_states_by_metadata/2, list_runs_by_metadata/2 and load_run_position/3 become insert_execution/5, update_execution/5, update_execution_status/4, fetch_execution/2, execution_outcome_supported?/1, execution_states_supported?/1, list_execution_states_by_metadata/2, list_executions_by_metadata/2 and load_execution_position/3.

  • Types: Storage.Adapter.run_id/0, run_status/0, run_record/0 and run_state/0 are now execution_id/0, execution_status/0, execution_record/0 and execution_state/0, and the run_id: key inside execution_record/0, execution_state/0, input_record/0 and Storage.input/0 is now execution_id:. Runs.run_id/0 is now Executions.execution_id/0, and Storage.run_write_opt/0 is now Storage.execution_write_opt/0.

  • The reserved child-linkage metadata key written into a child's metadata map is now "parent_execution_id", and Execution.Linkage's struct field is :parent_execution_id. A child written by 0.11.x carries the old key; re-key it or let those executions finish under 0.11.x.

  • use StatifierPersistence.Ecto now generates MyApp.Persistence.Execution instead of MyApp.Persistence.Run, and the executions and inputs schemas expose the field as execution_id instead of run_id. A host naming the module or the field in its own queries renames both.

  • New surrogate ids for that table carry the exec_ prefix instead of run_. Existing rows keep the ids they have - nothing rewrites them - so both spellings coexist permanently on an upgraded install. Nothing in this package parses a prefix and no host should; one that does must accept both.

  • The reserved <donedata> key a chart writes to fail itself is now statifier_persistence:execution_status.

  • Breaking for hosts. The durable table is now statifier_executions, and its identity column - in that table and in statifier_inputs - is now execution_id (ADR-0011 decision 3). A new migration, V06, makes the move on an existing database: it renames the table, both columns, both unique indexes and the metadata GIN index in place, copying no data, and it is a no-op on a database created at 0.12.0 or later. Run StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence, from: 6) from an ordinary host migration; a fresh install gets the new names from V01 and needs nothing extra. That one-line upgrade is for an install already at V05: one capped below V04, which still owes V02, V03 or V04, runs up(for: MyApp.Persistence, from: 6, version: 6) first and then up(for: MyApp.Persistence, from: <its cap + 1>, version: 5) - from: 2 for a host capped at V01, from: 3 at V02, from: 4 at V03 - because V02-V04 alter the executions table, which on such a database carries that name only once V06 has renamed it; the migration's down mirrors the two calls in reverse, down(for: MyApp.Persistence, from: 5, version: <its cap + 1>) and then down(for: MyApp.Persistence, from: 6, version: 6). Rename the table in any raw query, view, materialized view, hand-written Ecto schema or dashboard of your own that names it - it is statifier_executions on both paths. "In place" is exact for the table and the columns on every backend; off Postgres, which has no ALTER INDEX ... RENAME TO, the two unique indexes are dropped and declared again under their new names instead, which still copies no data.

  • Breaking for a host that overrides table names. The :tables key for this table is now :executions; :runs is rejected with ArgumentError, and no alias ships for a release. Rename the key in your use StatifierPersistence.Ecto options. A :tables override's value is untouched: V06 renames the columns and indexes under whatever name you gave, and the table keeps that name.

  • Rolling back drops the tables under their new names. V01-V05 are rewritten to the new noun and drop the tables under those names, so StatifierPersistence.Ecto.Migrations.down(for: MyApp.Persistence) still removes everything this package owns, on a fresh install and on an upgraded one alike. What it is not is a downgrade: to run 0.11.x again, restore a backup or migrate up with 0.11.x's own migrations.

  • An in-flight durable subchart child does not survive the upgrade. A child created under 0.11.x carries its parent link in metadata under the pre-0.12.0 key, and V06 renames no stored value - it is a catalog operation and copies no data. Execution.Linkage.from_metadata/1 therefore answers :no_linkage for such a child, and its completion no longer settles its parent's fan-out. Drain your in-flight children before upgrading: let every durable subchart child reach a terminal status under 0.11.x, then upgrade. Children created at 0.12.0 or later are unaffected.

  • The migration helper now knows six versions: StatifierPersistence.Ecto.Migrations.expected_version/0 answers 6.

  • The surrogate-key table map renames with the table key: StatifierPersistence.Ecto.KeyGenerator.table/0 is now :charts | :positions | :executions | :inputs, and the shipped UXID generator's prefix for that table is "exec" (it was already "exec" in 0.11.x under the old key). A host with its own Ecto.KeyGenerator implementation renames the atom it matches on.

  • V06's down/1 is a no-op, so a rollback never renames the durable table back to its pre-0.12.0 name. V01-V05 drop the tables under the execution names on every install this package can reach at 0.12.0, and returning to the retired names would only be meaningful under a downgrade to pre-0.12.0 code, which is unsupported - restore from a backup instead. This is what makes mix ecto.rollback --all work for a host that writes one migration per package version: a conditional rename would run in its own rollback step and the steps behind it would then name objects that are no longer there.

Deprecated

  • The pre-0.12.0 <donedata> key, statifier_persistence:run_status, is still read in this release and is dropped in 0.13.0: where both are present the new key wins, and reading the old one logs one deprecation line at :debug naming the new key. Update your charts' <param name="..."> before 0.13.0. It is the only transitional reader this rename ships.

[0.11.0] 2026-09-08

Feature release: a durably stepped run can now report itself as it goes. StatifierPersistence.Driver.new/3 takes an after_step: callback, fired after every step the driver takes on a caller's behalf - a durable subchart child's own steps and the parent's step on the answer path alike - so a host can trace, project or checkpoint from one seam instead of wrapping each entry point. Alongside it, StatifierPersistence.Testing.StorageConformance no longer writes from a setup, so an adapter's own setup runs first.

Added

  • StatifierPersistence.Driver.new/3 takes after_step:, a (run_id, machine_state, effects -> any) callback fired after every step the driver takes on a caller's behalf - a durable subchart child's own steps and the parent's step on the answer path included, each under the id of the run that was stepped, with the whole effect list that step produced. It defaults to nil and may be overridden per call on create/3, send_event/4, done_invocation/5 and failed_invocation/5.

Fixed

  • StatifierPersistence.Testing.StorageConformance no longer registers a setup that writes: the input-log cases build their fixture run inside the case body, so a host's own setup - even one written below the use - is no longer preceded by a write. The moduledoc states the ordering contract a host binds against.

[0.10.0] 2026-09-06

Feature release: a durable subchart child failed from outside the interpreter now settles its parent's pending <invoke> instead of leaving it hanging forever. StatifierPersistence.Runs.fail/4 takes a driver: option and answers the parent itself, and StatifierPersistence.Driver.resolve_and_answer_parent/3 is the public form of that answer for a caller with no drive of the child to hang it off. Alongside it, StatifierPersistence.Ecto.Migrations.expected_version/0 names the newest migration version this package knows, for a host whose schema is hand-written DDL and which therefore has to check for itself that its tables are current.

Added

  • StatifierPersistence.Runs.fail/4 takes a driver: option: a durable subchart child failed from outside the interpreter now answers its parent's <invoke> with the failure instead of leaving it pending forever (ADR-0008's note on the outside-fail seam).
  • Adds StatifierPersistence.Driver.resolve_and_answer_parent/3, the public form of the automatic answer - resolve the parent's chart through chart_resolver:, then answer through answer_parent/3 - for a caller that has no drive of the child to hang it off.
  • StatifierPersistence.Ecto.Migrations.expected_version/0 returns the newest migration version this package knows, for a host whose schema is hand-written DDL rather than a delegated migration and which therefore has to check for itself that its tables are current. There is no assert_version!/1 to go with it: the package records no version marker in a repo's schema, so the comparison stays the host's - the function's docs say why.

Documentation

  • ADR-0010 takes a note answering whether a host needs the V05 input log table at all. An adapter that does not export the optional input-log callbacks never touches it - a host on one caps its migration at V04 in both directions rather than carrying an empty table - while a host storing through StatifierPersistence.Storage.Ecto needs it unconditionally, because that adapter declares input-log support without probing.

[0.9.0] 2026-09-06

Feature release: a durably stepped run can now keep a verbatim log of every input its interpreter saw, and a fan-out's settlement is visible in telemetry. The storage-adapter behaviour gains three optional input-log callbacks (supports_input_log?/1, append_input/3, list_inputs/2) and StatifierPersistence.Runs.inputs/2 reads the log back; Storage.Ecto implements them on Postgres and SQLite alike through migration V05, with an input_log_cap: bound and no log at all for an adapter that does not export the callbacks (ADR-0010, accepted). Two new events, [:statifier_persistence, :child, :recorded] and :settled, surface the per-child answers and the settlement decision that reach no door, and child.answered's outcome is now the invocation's rather than the door's, so a fan-out that failed no longer reports :done. And Storage.Ecto.list_runs_by_metadata/2 and list_run_states_by_metadata/2 refuse cleanly with {:error, :metadata_unsupported} off Postgres instead of raising from the driver.

Added

  • Adds an optional per-run input log to the storage-adapter behaviour (supports_input_log?/1, append_input/3, list_inputs/2): an adapter that exports them records every input a run's interpreter saw - the verbatim %Statifier.Event{}, the public door it entered by, and a dense zero-based ordinal - which is what an offline replay of a durably stepped run needs (ADR-0010).
  • Adds StatifierPersistence.Runs.inputs/2 and StatifierPersistence.Storage.input_log_supported?/1, append_input/4 and list_inputs/2 for reading and writing that log.
  • Adds migration V05, the input log table, on Postgres and SQLite alike; StatifierPersistence.Storage.Ecto implements all three callbacks and takes an input_log_cap: option that bounds a run's log and closes it with a marker entry rather than truncating it silently. The default is :infinity.
  • :blob_type now reaches the new input_blob column. An event's data is host payload, so turning the log on is a data-retention decision: an adapter that does not export supports_input_log?/1 keeps no log and behaves exactly as it did before.
  • Adds [:statifier_persistence, :child, :recorded], once per fan-out child answer written under the parent's settlement exclusion. Every index but the settling one records an answer that reaches no door, so this is the only surface those answers appear on (ADR-0009's sp-8wv amendment).
  • Adds [:statifier_persistence, :child, :settled], once per settlement decision, carrying the invocation's policy, the :answer / :not_yet decision, and the completed / failed / cancelled / unstarted tallies it was decided from.
  • [:statifier_persistence, :run, :step, :stop] now carries invoke_id and child_count, nil on an ordinary drive and set on the entry: :answer_parent step, so the step span that delivers a whole fan-out's assembled answer is recognisable as that one.

Changed

  • [:statifier_persistence, :child, :answered]'s outcome is now the invocation's for a fan-out, not the door's: :failed when any index failed. A fan-out always answers its parent through done_invocation/5
    • the failure shape is inside each entry - so the event previously said outcome: :done for a settlement that had failed. It also gains child_count and failed_count, both nil for a single-child subchart, which is not an invocation with a width. A consumer counting outcome across fan-outs will see failures it did not see before.
  • StatifierPersistence.Storage.Ecto.list_runs_by_metadata/2 and list_run_states_by_metadata/2 now return {:error, :metadata_unsupported} on a backend that is not Postgres, where they previously raised from the driver on jsonb containment SQL it cannot parse. Both consult supports_metadata?/1 before issuing anything, so a host calling the raw adapter callback gets the same clean refusal StatifierPersistence.Storage already gave through the facade. A behaviour change on two adapter callbacks: code rescuing the raise sees a tagged tuple instead. Nothing changes on Postgres, and the facade is untouched.

[0.8.0] 2026-09-06

Feature release: a chart can now report that its own run failed, and the Ecto adapter gains room to move on large and on non-Postgres hosts. A top-level <final> whose <donedata> sets run_status to "failed" persists the run as :failed so :first_error settlement fires (the ADR-0008 amendment, accepted). V04 of the DDL rebuilds V03's metadata GIN index with CREATE INDEX CONCURRENTLY - @current_version is now 4, and a host runs V04 as a migration of its own carrying @disable_ddl_transaction true and @disable_migration_lock true. A host that is not on Postgres has a documented opt-out: decline lock_run/3 with its own serialization: strategy and run the conformance suite with --exclude postgres. And Migrations.down/1 takes from:, the ceiling a capped migration needs to roll all the way back.

Added

  • A chart can now fail its own run: settling in a top-level <final> whose <donedata> carries statifier_persistence:run_status set to "failed" persists the run as :failed with the failure string "failed_final", so a :first_error fan-out cancels the failed child's siblings with no host-side translation.
  • docs/non-postgres-backends.md: the supported way to run the Ecto adapter on a backend that is not Postgres - decline lock_run/3 with your own serialization: strategy, what declining costs, and how to verify it.
  • StatifierPersistence.Ecto.Migrations.down/1 takes from:, the version it starts rolling back from (default: the newest this package knows), so a migration capped with up(version: 2) caps its rollback with down(from: 2).
  • V04 of the Ecto DDL rebuilds V03's metadata GIN index with CREATE INDEX CONCURRENTLY, so a host with a large runs table gets the index without the SHARE lock a plain build holds. Give it a migration of its own carrying @disable_ddl_transaction true and @disable_migration_lock true - Ecto reads those from your module, not from the helper - and call StatifierPersistence.Ecto.Migrations.up(for: MyApp.Persistence, from: 4). Called from an ordinary transactional migration it leaves V03's index in place instead of raising, warning when the runs table already holds rows. It is a no-op off Ecto.Adapters.Postgres, where V03 creates no index.

Changed

  • The conformance suite tags its four Postgres-only cases @tag :postgres - the two lock_run/3 cases and the two metadata-listing cases - so a host running Storage.Ecto on another Ecto backend runs it green with mix test --exclude postgres instead of forking the suite.

Fixed

  • mix ecto.rollback --all no longer fails for a host that caps one migration and takes a later version in another: without a ceiling every down/1 started at the newest version, so the capped migration rolled the later one's versions back a second time and failed on DDL that was already gone. Cap the rollback with from: as above.

[0.7.2] 2026-09-06

Patch release: a fan-out whose children settle at the same time assembles with every child's donedata present. A settlement used to read a sibling's status as terminal while that sibling's answer was still in flight, and assembled a completed child with a nil donedata; a settlement now records its own answer under the parent's exclusion and waits for every child's recorded answer, not only for every child's terminal status.

Fixed

  • A fan-out invocation whose children settle concurrently no longer answers with a nil donedata for a child that completed: a settlement waits for every child's answer to be recorded, not only for every child's status to be terminal, and records its own answer under the parent's exclusion.

[0.7.1] 2026-09-05

Patch release: hosts that are not on Postgres can apply the package DDL. V03 creates its metadata GIN index only on Postgres, and the Ecto adapter now answers supports_metadata?/1 by adapter, so a store that could not settle a durable subchart or a fan-out is refused at open rather than crashing partway through.

Fixed

  • V03 of the Ecto migration helper is adapter-aware: the metadata GIN jsonb_path_ops index is created (and dropped) only when the migration's repo runs on Ecto.Adapters.Postgres. On any other adapter it is skipped and the migration runs to completion, so a host on another backend can apply the package DDL at all - in 0.7.0 the index raised, the whole migration rolled back, and the outcome_blob column went with it. The column itself is still created on every adapter, and StatifierPersistence.Storage.Ecto.supports_run_outcome?/1 is still true everywhere.
  • StatifierPersistence.Storage.Ecto.supports_metadata?/1 now answers false off Postgres, because both metadata queries the adapter issues are jsonb containment SQL. StatifierPersistence.Storage's child_listing_supported?/1 and run_states_supported?/1 consult it, so a durable subchart or a fan-out over such a store is refused at open (:child_listing_unsupported) rather than started and left with children nothing could settle. Behavior on Postgres is unchanged.

[0.7.0] 2026-09-05

Feature release: a scheduler can start a Tier A fan-out through a public door, and the N children settle once as a dense, index-ordered list instead of each answering the parent. Hosts on the Ecto adapter run a new V03 migration.

Added

  • StatifierPersistence.Driver.start_child_at/6: the public start-with-index door a scheduler drives a Tier A fan-out through. Starts child i of N for a parent's <invoke>, records the count and the aggregation policy (:all or :first_error) on the child's linkage, and is idempotent on the derived child run id, so a re-delivered start adopts rather than duplicating. Refuses at open on a store that could not settle the invocation afterwards.
  • StatifierPersistence.Run.Linkage.new/6 and fan_out?/1: a child's linkage can now carry its invocation's child_count and aggregation policy (:all or :first_error), which is what marks it as one of a fan-out's N rather than an ordinary durable subchart.

Changed

  • The storage-adapter run_record gains a nullable outcome_blob: a run's own answer, written once when it reaches a terminal status through StatifierPersistence.Storage.update_run_status/4's new outcome_blob: option. update_run/2 carries a stored payload forward when the record it is given carries none, so an ordinary step never erases one. Adapters gain two optional callbacks alongside it - supports_run_outcome?/1 and list_run_states_by_metadata/2, the indexed status projection - and an adapter that exports neither is conformant unchanged.
  • The Ecto adapter's V03 migration adds the outcome_blob column and a GIN jsonb_path_ops index on metadata. A host already on V02 picks it up with StatifierPersistence.Ecto.Migrations.up(for: MyApp, from: 3). Run it before deploying 0.7.0, and read the README's "Upgrading to V03 before deploying 0.7.0" first: the index build is a plain CREATE INDEX that blocks writes to the runs table for the length of the build.
  • StatifierPersistence.Driver takes a child_canceller: option: how a :first_error settlement asks the scheduler to cancel the start jobs of a fan-out's not-yet-started children, which have no run record for the cascade to reach.
  • A fan-out child's completion now settles instead of answering its parent's door: its answer is stored on its own run record, and only the settlement that finds all N indices terminal assembles the dense, index-ordered list and answers the invocation once. StatifierPersistence.Driver.answer_parent/3 routes a fan-out child the same way and returns :ok for it. A child with no child_count on its linkage - every child created before this release - is unaffected.

[0.6.0] 2026-09-02

Feature release: a durably-stepped run is observable through statifier's own session telemetry, so the OpenTelemetry bridge produces the same spans and effect events for a durable run as for a session-hosted one.

Added

  • Durably-stepped runs now emit statifier's own [:statifier, :session, ...] telemetry with driver: :persistence, so opentelemetry_statifier produces the same macrostep spans and effect events for a durable run as for a session-hosted one, with no bridge change.

[0.5.0] 2026-09-01

Feature release: the durable step is observable, and a :dispatch fun can see the whole <invoke> it is being handed.

Added

  • StatifierPersistence.Driver.dispatch_context/0 carries :invoke, the whole Statifier.Effect.Invoke being dispatched, so a :dispatch fun can read the element's src - the document id a subchart handler resolves its child chart by - along with content, autoforward, and the step counters.
  • StatifierPersistence.Telemetry emits the fourteen [:statifier_persistence, ...] events ADR-0009 specifies - the durable step as a :start/:stop pair, the per-run lock wait, every storage-adapter call, identity refusals, the run lifecycle, executor failures, and the durable-subchart seam - and events/0 returns every name for a bridge to attach to.
  • Adds a direct :telemetry dependency (already present transitively through statifier, so no lock file grows).

[0.4.0] 2026-09-01

Feature release: durable subcharts (ADR-0008) - an <invoke> may start a child chart as an ordinary durable run, the parent rests holding no process while the child runs, and leaving the invoking state cancels the child subtree.

Breaking for storage adapters: run_status/0 gains a fourth terminal value, :cancelled. An adapter that encodes run statuses by an exhaustive match must add a clause for it before upgrading, or cancelled runs will fail to persist. The two adapters in this package already handle it.

Added

  • Durable subcharts (ADR-0008): a <invoke> whose :dispatch fun answers {:start_child, invoke, {:invoke, invoke}} now starts the subchart as an ordinary durable run instead of being refused, and the parent rests holding no process for as long as the child takes.
  • StatifierPersistence.Run.Linkage records a child's parent run id, invocation id, and a mandatory pin of the child's chart identity under a reserved key in the child's run metadata.
  • StatifierPersistence.Runs.create/4 takes linkage:, and raises ArgumentError when a host's own metadata: writes into the reserved key.
  • StatifierPersistence.Driver.new/3 takes chart_resolver:, which lets a finished child answer its parent through the existing done_invocation/5 and failed_invocation/5 doors.
  • StatifierPersistence.Runs.cancel/3 and cascade_cancel/3 cancel a parent's child subtree when it leaves the invoking state, retaining every record and position.
  • Storage adapters may export the optional list_runs_by_metadata/2, reached through StatifierPersistence.Storage.list_runs_by_metadata/2 and child_listing_supported?/1; a store whose adapter does not export it refuses a durable subchart before any write.
  • StatifierPersistence.Storage.InMemory implements list_runs_by_metadata/2, which StatifierPersistence.Storage.Ecto already supported.

Changed

  • StatifierPersistence.Storage.Adapter.run_status/0 gains a fourth terminal value, :cancelled. An adapter that encodes statuses by an exhaustive match must add a clause for it, or cancelled runs will fail to persist.
  • StatifierPersistence.Run gains donedata, set only on the step that completes a run and nil everywhere else.

[0.3.0] 2026-09-01

Feature release: an asynchronous invocation seam on the durable driver - a dispatch may answer :pending and the run rests holding no process, with public doors that answer the invocation later from any process or node.

Added

  • StatifierPersistence.Driver's :dispatch fun may answer :pending: the call was started asynchronously and the run rests durably with the invocation live, holding no process (ADR-0007).
  • StatifierPersistence.Driver.done_invocation/5 and StatifierPersistence.Driver.failed_invocation/5 answer a pending invocation later, from any process or node, building the same done.invoke / error.communication.invoke events a live Statifier.Session builds. An answer for an invocation the chart has cancelled is {:discarded, run}, decided from the persisted position inside the run's serialization strategy.
  • StatifierPersistence.Runs.step/5 accepts an event builder - a fun over the loaded position returning {:ok, event} or :discard - anywhere it accepts a Statifier.Event.

Changed

  • The context handed to a :dispatch fun carries invoke_id, the invocation id an asynchronous host keys its work by and hands back to the re-entry doors.

[0.2.0] 2026-08-31

Feature release: a durable run-to-quiescence driver, opaque run metadata, and a custom blob type for encryption at rest.

Added

  • StatifierPersistence.Driver drives a durable run to quiescence over StatifierPersistence.Runs: it performs each <invoke> through a host-supplied dispatch fun inside the step that emitted it, then steps every answer back in until the chart rests. Hosts that hand-rolled this loop can delete it.
  • StatifierPersistence.Driver builds an invocation's answer events - done.invoke.<id> and error.communication.invoke.<id>, origin and origintype included - field for field from Statifier.Session's own done_invocation/3 and failed_invocation/3, so a chart sees the same event in a session and out of storage. A conformance test answers one document both ways and compares what each chart saw.
  • StatifierPersistence.Runs.create/4 and StatifierPersistence.Storage.insert_run/5 accept an optional metadata: map of string keys, stored opaquely beside the run record and returned by fetch_run/2 unchanged (ADR-0006). Host identities only, never personal data: blob encryption does not reach this column.
  • StatifierPersistence.Storage.Adapter gains the optional supports_metadata?/1 callback and a metadata field on run_record. An adapter that does not export it refuses a non-empty map at create with {:error, :metadata_unsupported}; an empty or absent map is never refused, so every existing adapter stays conformant unchanged.
  • StatifierPersistence.Storage.metadata_supported?/1 and check_metadata/2 report whether a store's adapter can hold metadata, without writing anything.
  • StatifierPersistence.Storage.Ecto.list_runs_by_metadata/2 lists the runs whose metadata contains every given key/value pair.
  • Migration V02 adds a nullable jsonb metadata column to the runs table, and StatifierPersistence.Ecto.Migrations.up/1 accepts from: so a host already on V01 applies later versions in its own second migration.
  • StatifierPersistence.Testing.StorageConformance gains metadata cases: a conformant adapter either round-trips the map or refuses it at open, and never silently drops it.
  • use StatifierPersistence.Ecto accepts a :blob_type option to put a custom Ecto type on the three blob columns (identity_blob, chart_blob, position_blob), enabling encryption at rest with no wrapping adapter.

Changed

  • Requires statifier ~> 2.2 and >= 2.2.1 rather than ~> 2.0: 2.2.1 is the first release carrying the queue-discard-on-exit fix the completion conformance cases need. (An interim git-ref pin served between 2.2.0 and that release.)

Fixed

  • A run whose top-level <final> is reached while sibling done.state.* events are still queued now persists as completed, instead of raising "loop bug: non-quiescent MachineState reached the persist tail". The same holds for a top-level <final> whose <donedata> expression fails.
  • StatifierPersistence.Runs.create/4 passes only its metadata: pair to StatifierPersistence.Storage.check_metadata/2, whose contract is the narrower [Storage.run_write_opt()]. Handing the whole option list over made dialyzer derive a success typing for create/4 that accepted no executor: at all, so an embedder had to suppress "will never return" on every correct call; that suppression can now be deleted.

[0.1.3] 2026-08-27

Docs release: README and guide refresh onto the family's canonical example domains. No library code changes.

Changed

  • The README now walks a full worked run in the card-processing domain - load, step, execute effects, persist - and continues it across a restart, with a new module map; the examples are executed by a test so they cannot drift from the real API.
  • Example domains follow the family rule: card processing and the signup wizard with A/B testing only.
  • Agent tooling: gate attestation points at mix quality.verify (shipped by ex_quality 0.14) instead of a retired local task.

[0.1.2] 2026-08-24

Docs release: the hexdocs/README overhaul from PR #20. No library code changes.

Changed

  • Hexdocs no longer publishes the ADRs: the ADR extras and their groups_for_extras entry are removed, so the published docs are the README, this changelog, and the restart-demo guide.
  • ex_doc is pinned to ~> 0.40, and CHANGELOG.md is listed in skip_undefined_reference_warnings_on; mix docs now completes with zero warnings.
  • The README gains the standard badge row (CI, hex.pm version/downloads, hexdocs, license) and a documentation index line linking the published restart-demo guide.

[0.1.1] 2026-08-24

Patch release: the key-generator compile-race fix from PR #18.

Fixed

  • Custom key-generator validation in use StatifierPersistence.Ecto no longer fails spuriously when the generator module is still being compiled by the host's parallel compiler; validation now waits for in-flight compilation (Code.ensure_compiled/1) instead of checking Code.ensure_loaded?/1.

[0.1.0] 2026-08-22

First release: the persistence-first execution loop for the statifier statechart engine - load a persisted position, step it, execute the effects, persist - packaged as a storage-adapter behaviour with an identity guard, an in-memory reference adapter, a run lifecycle, and an Ecto/Postgres layer, all covered by one conformance suite downstream adapters inherit.

Added

  • StatifierPersistence.Storage.Adapter, the storage contract, including run records: insert_run/2, fetch_run/2, and update_run/2 callbacks with run_record/run_status types and the :run_exists / :run_not_found error arms; StatifierPersistence.Storage.InMemory is the reference implementation.
  • Guarded run access on the facade: StatifierPersistence.Storage.insert_run/5, update_run/5, fetch_run/2, and load_run_position/3 (identity-guarded, with the :run_position_missing arm for a run persisted without a position).
  • Run-record conformance tests in StatifierPersistence.Testing.StorageConformance, so downstream adapters inherit the same contract checks.
  • The run lifecycle as a library: StatifierPersistence.Runs.create/4 and step/5 drive the load -> re-stamp -> step -> execute -> persist loop over durable run records, handing effects to a host-supplied StatifierPersistence.Executor (behaviour or arity-2 fun) and returning the host-facing StatifierPersistence.Run struct; events to a terminal run come back as {:discarded, run}.
  • Failure semantics on the loop: executor failures on actionable effects re-enter the chart as error.communication events (single wave per step, observational failures discarded); effect execution is at-least-once, with a failed persist re-driving the same event and re-emitting the same effects under identical deterministic keys; budget exhaustion persists a :failed run (position untouched) and returns {:error, {:budget_exhausted, payload}}.
  • StatifierPersistence.Runs.fail/4, the host-driven abandonment: marks an active run :failed with a reason, leaves the stored position untouched, and discards on a terminal run - backed by the status-only writer StatifierPersistence.Storage.update_run_status/4.
  • Pluggable per-run serialization: the StatifierPersistence.Serialization behaviour (with_run/3), selected per lifecycle call with serialization: {module, config} on Runs.create/4, step/5, and fail/4. The default strategy, StatifierPersistence.Serialization.AdapterLock, delegates to the optional adapter callback StatifierPersistence.Storage.Adapter.lock_run/3 (implemented by InMemory, conformance-tested when exported) and refuses with {:error, {:serialization, :not_supported}} when the adapter does not export it.
  • use StatifierPersistence.Ecto: compile-time configuration on the host's module (repo:, key:, table_prefix:, tables:, prefix:) that defines Chart, Position, and Run schema modules and exposes the resolved config via __statifier_persistence__/1. Requires the optional ecto_sql dependency.
  • StatifierPersistence.Ecto.KeyGenerator: the behaviour a surrogate-key scheme implements, with :uxid (default), :uuid (UUIDv7), :bigserial, and {module, opts} resolved through resolve/1.
  • StatifierPersistence.Ecto.Migrations: the versioned migrations helper (up/1, down/1, taking for: HostModule or the same literal options use takes) that creates the charts/positions/runs tables from the same resolved config the schemas use.
  • StatifierPersistence.Storage.Ecto: the Postgres storage adapter over the schemas a host generates with use StatifierPersistence.Ecto (Storage.new(Storage.Ecto, persistence: MyApp.Persistence)). Passes the same conformance suite as the in-memory reference adapter; engine identities stored verbatim; :run_exists enforced atomically by the unique index.
  • Storage.Ecto.isolate/1: with sandbox: true, wraps each test in its own Ecto.Adapters.SQL.Sandbox checkout - the hook host test suites (and this package's conformance suite) isolate through.
  • Storage.Ecto.lock_run/3: per-run mutual exclusion as a transaction-scoped pg_advisory_xact_lock plus a SELECT ... FOR UPDATE row lock (ADR-0004 as amended), consumed by Serialization.AdapterLock.
  • uxid is a required dependency (the default key scheme works out of the box); ecto_sql is optional and the package compiles without it.