The guarded entry point from a storage adapter to a
Statifier.MachineState.t().
Every load runs through load_position/3 or load_execution_position/3, and
every load is checked against the exact chart revision that produced the
stored position (ADR-0003 decision 2). No adapter callback ever holds
both the stored identity and a caller-supplied Statifier.Machine.t() at
the same time (StatifierPersistence.Storage.Adapter's moduledoc), so
the guard cannot be skipped or weakened per adapter - it lives here,
above every one of them, and nowhere else in this package decodes a
position blob.
Every writer taking a machine or machine state - save_chart/3,
save_position/3, insert_execution/5, update_execution/5 - derives a chart's
content_hash and identity_blob from Machine.identity/1 on the
machine it is given - never from a caller-supplied value - and refuses an
unidentified machine with {:error, :unidentified_chart} rather than
writing a row a later load has no way to check.
A chart is keyed by that content_hash and by nothing else. No tenant,
namespace, or host scope takes part in the key, so two tenants that store
byte-identical charts share one chart row: the hash is a content address,
saving the same one twice is :ok, and it does not duplicate the row
(StatifierPersistence.Storage.Adapter's save_chart/2 contract). The
hash answers which chart these bytes are, never who stored them.
A multi-tenant host therefore tenant-qualifies its own per-chart rows, in
its own tables, rather than expecting this package to do it. The package
stores nothing per tenant; an execution's opaque metadata map (ADR-0006) is
where a host tags an execution with the scope it already keys its own tables by,
and any narrower scoping stays the host's.
Folding a namespace into the hash would change what a chart's identity
is, and that is Statifier.Machine.identity/1's question - statifier-ex's
contract, not this package's. Nothing here proposes it, and this package
offers no option to do it.
Every execution writer - insert_execution/5, update_execution/5,
update_execution_status/4 - stamps ended_at on the record it writes
when the status it writes is terminal (:completed, :failed or
:cancelled), with DateTime.utc_now/0, and leaves it nil otherwise.
The adapter keeps a stamp already stored
(StatifierPersistence.Storage.Adapter.update_execution/2), so the
stamp an execution carries is the time of the first terminal write its
row took while it had none, and no later write moves it. A write that
is not terminal - write_tree_migration/2's re-pins and parks, and an
update_execution/5 or update_execution_status/4 putting a row back
to :active - never clears one, so a row can carry a stamp while its
status is not terminal.
Every function here returns an error tuple instead of throwing; nothing in this module ever downgrades a failure to a default value.
This module is also the storage seam's telemetry site (ADR-0009 decision
3): every adapter callback below is timed and reported as
[:statifier_persistence, :adapter, :call], and every identity refusal
- the guard's own and every writer's - as
[:statifier_persistence, :identity, :refused]. Both are built byStatifierPersistence.Telemetry; seedocs/telemetry.mdfor the contract.
Summary
Types
This facade's error vocabulary: an adapter's own arms plus Statifier's
own unflattened blob-decode and identity arms (ADR-0003 decision 4).
Every arm is returned, none is collapsed into a default.
Options the execution writers (insert_execution/5, update_execution/5) accept
One decoded input log entry (ADR-0010 decision 2), as list_inputs/2
returns it: the adapter's stored record with its opaque input_blob
decoded back into the %Statifier.Event{} the interpreter saw.
One write of a tree migration, as write_tree_migration/2 takes it
(ADR-0015 decision 3): a re-pin of an execution onto the machine state
it is handed, with its linkage pin's new content_hash or nil when it
carries no linkage, or a park.
Functions
Appends event to execution_id's input log at door, returning the ordinal
the adapter assigned (ADR-0010 decisions 2 and 3).
Whether a chart can be tombstoned in store (ADR-0012 decision 6).
Answers the retired arm for machine's own content hash without
writing anything: :ok, or {:error, {:chart_retired, info}}
(ADR-0012 decision 6).
Validates a writer's metadata: option against store's adapter without
writing anything: :ok, or {:error, :metadata_unsupported} for a
non-empty map an adapter cannot store (ADR-0006 decision 3).
Whether store's adapter can list executions by a metadata match (ADR-0008
decision 5).
Whether store's adapter can answer the drained query (ADR-0012
decision 3).
Counts the executions on content_hash, per stored arm (ADR-0012
decision 3).
Whether store's adapter can store an execution's own outcome_blob (sp-t57,
ruling C3).
Whether store's adapter can prune finished executions (ADR-0016).
Whether store's adapter can answer the indexed status projection
(sp-t57, ruling C5).
Fetches the chart stored under content_hash.
Fetches the execution record stored under execution_id.
Whether store's adapter keeps an execution's input log (ADR-0010 decision 1).
Inserts an execution record for execution_id, keyed by the content hash and identity
envelope of machine_state.machine's own Machine.identity/1 - never a
caller-supplied hash - and refusing an unidentified machine with
{:error, :unidentified_chart} before calling the adapter.
Lists the ids of the :active executions on content_hash (ADR-0012
decision 4).
Lists the ids of the executions on content_hash whose stored status
is one of statuses (ADR-0017 decision 8).
The indexed status projection over the same match
list_executions_by_metadata/2 takes (sp-t57, ruling C5).
Lists the executions whose stored metadata contains every key/value pair in
metadata (ADR-0008 decision 5).
Lists execution_id's whole input log in ascending seq, decoded (ADR-0010
decision 2).
Fetches the execution stored under execution_id and rebuilds its position into a
Statifier.MachineState.t() walking machine, refusing a chart-revision
mismatch instead of silently resuming the wrong configuration.
Fetches the position stored for session_id and rebuilds it into a
Statifier.MachineState.t() walking machine, refusing a chart-revision
mismatch instead of silently resuming the wrong configuration.
Whether store's adapter can store an execution's opaque metadata map
(ADR-0006 decision 3).
Initializes adapter with opts and returns the handle every other
function in this module takes as its first argument.
Prunes one batch of at most limit finished executions that ended
before cutoff (ADR-0016, the facade half of
StatifierPersistence.Storage.Adapter.prune_executions/4).
Retires the chart on content_hash over this package's own tables
(ADR-0012 decisions 5 and 6).
Stores chart_blob under the content hash and identity envelope of
machine's own Machine.identity/1 - never a caller-supplied hash.
Encodes machine_state with Position.to_binary/1 and stores it under
session_id, keyed by the content hash and identity envelope of
machine_state.machine's own Machine.identity/1 - never a
caller-supplied hash, so a caller cannot store a position under a hash
that disagrees with its blob.
Whether store's adapter can write a tree migration as one unit
(ADR-0015 decision 3).
Overwrites the execution stored under execution_id with a full record derived the
same way insert_execution/5 derives one: identity always from
machine_state.machine's own Machine.identity/1, refusal of an
unidentified machine, position_blob encoded under position: :persist
(the default).
Overwrites only the status and failure of the execution stored under execution_id,
carrying every other stored field - both blobs included - forward
verbatim.
Writes a tree migration's re-pins and parks as one unit: every write
lands or none does (ADR-0015 decision 3, the facade half of
StatifierPersistence.Storage.Adapter.write_tree_migration/2).
Types
@type error() :: StatifierPersistence.Storage.Adapter.error() | :not_a_statifier_blob | :unidentified_chart | :execution_position_missing | :metadata_unsupported | :child_listing_unsupported | :execution_outcome_unsupported | :execution_states_unsupported | :content_hash_query_unsupported | :chart_retirement_unsupported | :tree_migration_unsupported | :execution_pruning_unsupported | {:unsupported_format_version, term()} | {:identity_mismatch, Statifier.Machine.Identity.t(), Statifier.Machine.Identity.t() | nil}
This facade's error vocabulary: an adapter's own arms plus Statifier's
own unflattened blob-decode and identity arms (ADR-0003 decision 4).
Every arm is returned, none is collapsed into a default.
@type execution_write_opt() :: {:failure, String.t() | nil} | {:position, :persist | :skip} | {:metadata, StatifierPersistence.Storage.Adapter.metadata()} | {:outcome_blob, binary() | nil}
Options the execution writers (insert_execution/5, update_execution/5) accept:
failure:- the short reason stored on a:failedexecution; defaults tonil.position:-:persist(the default) encodes the given machine state withPosition.to_binary/1and stores it as the execution'sposition_blob;:skipstoresnilon insert and carries the currently stored blob forward verbatim on update.metadata:-insert_execution/5only: the optional opaque map of host identities stored beside the execution (ADR-0006 decision 1), defaulting to%{}. It is write-once -update_execution/5andupdate_execution_status/4carry the stored map forward and accept nometadata:of their own.outcome_blob:-update_execution_status/4only: the execution's own opaque answer, written once when it reaches a terminal status. Defaults tonil, which carries the stored value forward rather than clearing it, so every other writer leaves an answer already recorded alone.
@type input() :: %{ execution_id: StatifierPersistence.Storage.Adapter.execution_id(), seq: StatifierPersistence.Storage.Adapter.seq(), door: StatifierPersistence.Storage.Adapter.door(), event: Statifier.Event.t() | nil }
One decoded input log entry (ADR-0010 decision 2), as list_inputs/2
returns it: the adapter's stored record with its opaque input_blob
decoded back into the %Statifier.Event{} the interpreter saw.
A nil event is the closed marker the cap wrote (decision 6) and
nothing else. door is one of StatifierPersistence.Executions.entry/0's
seven atoms, as its string - the key decision 8's replay mapping is
written against.
@type t() :: %StatifierPersistence.Storage{ adapter: module(), opts: StatifierPersistence.Storage.Adapter.opts() }
@type tree_write() :: {:repin, StatifierPersistence.Storage.Adapter.execution_id(), Statifier.MachineState.t(), StatifierPersistence.Storage.Adapter.content_hash() | nil} | {:park, StatifierPersistence.Storage.Adapter.execution_id()}
One write of a tree migration, as write_tree_migration/2 takes it
(ADR-0015 decision 3): a re-pin of an execution onto the machine state
it is handed, with its linkage pin's new content_hash or nil when it
carries no linkage, or a park.
Functions
@spec append_input( store :: t(), execution_id :: StatifierPersistence.Storage.Adapter.execution_id(), door :: atom(), event :: Statifier.Event.t() ) :: {:ok, StatifierPersistence.Storage.Adapter.seq()} | :not_supported | {:error, error()}
Appends event to execution_id's input log at door, returning the ordinal
the adapter assigned (ADR-0010 decisions 2 and 3).
This is the encode site: the %Statifier.Event{} the interpreter was
handed crosses the adapter seam as an opaque input_blob, encoded here
with :erlang.term_to_binary/1 in exactly outcome_blob's established
shape. ADR-0003 decision 1 therefore stays true word for word - an
adapter sees binaries, strings and an ordinal, never a Statifier
struct - and this package mints no serialization format of its own.
:not_supported for an adapter that keeps no log, without calling the
adapter at all. {:error, :input_log_full} once the execution's log has
closed itself at the host's cap (decision 6): that arm refuses the
append and never the step, and the caller carries on.
door is a StatifierPersistence.Executions.entry/0 atom - the fixed
vocabulary of public doors, stored as its string.
Whether a chart can be tombstoned in store (ADR-0012 decision 6).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_chart_retirement?/1
and it answers true for these opts - the same shape
metadata_supported?/1 checks, asked of the store rather than of the
adapter module, because migration V07 makes the two chart blob columns
nullable only on Postgres.
Public for content_hash_query_supported?/1's reason: a host plans a
retirement before it asks for one, and learning that this store cannot
carry a tombstone is worth learning then rather than at the refusal.
@spec check_chart_retired(store :: t(), machine :: Statifier.Machine.t()) :: :ok | {:error, error()}
Answers the retired arm for machine's own content hash without
writing anything: :ok, or {:error, {:chart_retired, info}}
(ADR-0012 decision 6).
The hash is derived from Machine.identity/1, never supplied by the
caller, exactly as save_chart/3 derives it. A machine carrying no
identity is :ok: there is no hash to have retired, and the writers'
own :unidentified_chart refusal is the one that belongs to that
case.
Every answer other than the retired arm is :ok. A hash never stored
is not a retired hash - :chart_not_found keeps the meaning decision
6 gave it - and an adapter failure is the write's to report, not this
check's, which is why this function narrows to the one arm it exists
to see rather than forwarding whatever it read.
Public for the reason check_metadata/2 is: a caller with work to do
before the write must not do it for a create that will be refused.
StatifierPersistence.Executions.create/4 runs it ahead of
Statifier.Interpreter.initialize/2, so an execution on a retired
chart fires no effect on its way to the refusal.
The read does not transfer the chart's bytes when the adapter declares
the optional
StatifierPersistence.Storage.Adapter.supports_retired_info?/1: the
check then asks
StatifierPersistence.Storage.Adapter.fetch_retired_info/2, which
reads the tombstone alone, so its cost does not grow with the chart.
An adapter that does not declare it is answered through
fetch_chart/2 instead - the same answer, reading the whole row.
@spec check_metadata(store :: t(), opts :: [execution_write_opt()]) :: :ok | {:error, error()}
Validates a writer's metadata: option against store's adapter without
writing anything: :ok, or {:error, :metadata_unsupported} for a
non-empty map an adapter cannot store (ADR-0006 decision 3).
insert_execution/5 runs this check itself, so a caller writing through the
facade alone never needs it. It is public for the caller that has work to
do before the write and must not do it for a create that will be
refused: StatifierPersistence.Executions.create/4 runs it ahead of
Statifier.Interpreter.initialize/2 so no effect is executed for an execution
whose metadata cannot be stored - the same reason insert_execution/5's
identity refusal runs before the position encode. Raises ArgumentError
on a malformed option, exactly as the writers do.
Whether store's adapter can list executions by a metadata match (ADR-0008
decision 5).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.list_executions_by_metadata/2 and
metadata_supported?/1 holds for this store. This is the predicate
list_executions_by_metadata/2 consults for its own refusal-at-open, exposed
because a caller with work to do before the query - such as
StatifierPersistence.Driver refusing to start a child it could never
enumerate for cancellation - wants the answer before it acts.
The metadata conjunct is not belt and braces. A metadata match is a
query over stored metadata, so an adapter that declares it cannot
support metadata (ADR-0006 decision 3) cannot answer one either, and an
adapter whose declaration is conditional - the shipped Ecto adapter says
false off Postgres, where the containment SQL does not parse - is
otherwise reported as able to list purely because the function is
compiled into it. Exported and able are different questions and this is
the one callers ask (sp-11w).
Whether store's adapter can answer the drained query (ADR-0012
decision 3).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_content_hash_query?/1
and it answers true - the same shape metadata_supported?/1 checks.
Public because a host asks it before it plans a retirement: a chart is retired against the counts this query takes, so a store that cannot answer it cannot retire a chart, and that is worth learning before the sweep rather than at the refusal.
@spec count_executions_by_content_hash( store :: t(), content_hash :: StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, StatifierPersistence.Storage.Adapter.execution_counts()} | {:error, error()}
Counts the executions on content_hash, per stored arm (ADR-0012
decision 3).
Delegates to the adapter's optional
StatifierPersistence.Storage.Adapter.count_executions_by_content_hash/2
when content_hash_query_supported?/1 is true; returns
{:error, :content_hash_query_unsupported} otherwise, without calling
the adapter at all.
A hash this store has never seen answers zeros, not a not-found arm: the question is how much traffic a chart carries, and none is a number.
The five arm keys - active, needs_migration, completed, failed
and cancelled, the stored statuses of
StatifierPersistence.Storage.Adapter.execution_status/0 - count
execution rows on the hash; needs_migration is not terminal
(ADR-0014 decision 4). children counts the durable-child linkage pins
naming it whose parent execution is :active or :needs_migration
(ADR-0012 decision 1, as ADR-0014 decision 4 reads it), which is a
different population and can be non-zero for a hash with no execution
row of its own in any arm.
The answer is not a retirability test (ADR-0012's consequences say so in full): it reports the three terminal arms, which never block a retirement, and it leaves out the position rows and the host's own pin sources, which do.
Whether store's adapter can store an execution's own outcome_blob (sp-t57,
ruling C3).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_execution_outcome?/1 and it
answers true - the same shape metadata_supported?/1 checks. This is
half of StatifierPersistence.Driver.start_child_at/6's refusal at
open: a fan-out child whose answer could never be read back could never
settle its invocation.
Whether store's adapter can prune finished executions (ADR-0016).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_execution_pruning?/1
and StatifierPersistence.Storage.Adapter.prune_executions/4 and the
first answers true - the shape tree_migration_supported?/1 checks.
Whether store's adapter can answer the indexed status projection
(sp-t57, ruling C5).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.list_execution_states_by_metadata/2
and metadata_supported?/1 holds - the same pair
child_listing_supported?/1 checks, and for the same reason: the
projection is the same metadata match, narrowed to three columns. The
other half of start_child_at/6's refusal at open.
@spec fetch_chart( store :: t(), content_hash :: StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, StatifierPersistence.Storage.Adapter.chart_record()} | {:error, error()}
Fetches the chart stored under content_hash.
A tombstoned hash answers {:error, {:chart_retired, info}} carrying
who retired it and when, never :chart_not_found (ADR-0012 decision
6): a host that asked for the removal reads its own decision back
rather than a miss it would debug as data loss. :chart_not_found
keeps its meaning exactly - never stored, as against stored and
retired.
@spec fetch_execution( store :: t(), execution_id :: StatifierPersistence.Storage.Adapter.execution_id() ) :: {:ok, StatifierPersistence.Storage.Adapter.execution_record()} | {:error, error()}
Fetches the execution record stored under execution_id.
Whether store's adapter keeps an execution's input log (ADR-0010 decision 1).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_input_log?/1 and it
answers true - the same shape metadata_supported?/1 checks. Nothing
refuses on it: an adapter that keeps no log runs every chart exactly as
it did before ADR-0010, and this predicate is public so a host that
needs a replayable execution can find out whether it will get one before it
drives one.
@spec insert_execution( store :: t(), execution_id :: StatifierPersistence.Storage.Adapter.execution_id(), machine_state :: Statifier.MachineState.t(), status :: StatifierPersistence.Storage.Adapter.execution_status(), opts :: [execution_write_opt()] ) :: :ok | {:error, error()}
Inserts an execution record for execution_id, keyed by the content hash and identity
envelope of machine_state.machine's own Machine.identity/1 - never a
caller-supplied hash - and refusing an unidentified machine with
{:error, :unidentified_chart} before calling the adapter.
Writers always take a MachineState: even an execution failed at creation has
one, because Statifier.Interpreter.initialize/2 cannot fail (ADR-0004
decision 1). Under position: :persist (the default) the state is
encoded with Position.to_binary/1 and stored as the execution's
position_blob; under position: :skip the blob is stored nil - the
arm for an execution with no quiescent position to store. Uniqueness comes from
the adapter's insert_execution/2 :execution_exists refusal, not a pre-check here.
metadata: is the optional opaque map of host identities ADR-0006
decision 1 grants, defaulting to %{}. Create is the only place it is
set. This function is where the refusal-at-open lives: a non-empty map
for an adapter that does not export
StatifierPersistence.Storage.Adapter.supports_metadata?/1 (or whose
answer is false) returns {:error, :metadata_unsupported} before any
write, and an empty or absent map is never refused. A map that is not a
map of string keys raises ArgumentError: the shape is the one thing
ADR-0006 decision 1 does validate, and a malformed option is a caller
bug, not a storage event.
The map carries host identities only, never personal data (ADR-0006
decision 2). :blob_type encryption reaches the three blob columns and
not this map, so a name, an email address, or a card number filed here is
at rest in the clear. Nothing in this package can enforce that - the map
is opaque - so the contract states it and the host keeps it.
@spec list_active_execution_ids_by_content_hash( store :: t(), content_hash :: StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, [StatifierPersistence.Storage.Adapter.execution_id()]} | {:error, error()}
Lists the ids of the :active executions on content_hash (ADR-0012
decision 4).
Delegates to the adapter's optional
StatifierPersistence.Storage.Adapter.list_active_execution_ids_by_content_hash/2
under the same capability count_executions_by_content_hash/2 checks;
returns {:error, :content_hash_query_unsupported} otherwise, without
calling the adapter at all.
It is public because it is what a host building a pin source's context
by hand would otherwise have no way to ask for, and
StatifierPersistence.Executions.retire_chart/4 asks it on a host's
behalf on every retirement. A hash this store has never seen answers
{:ok, []}.
@spec list_execution_ids_by_content_hash( store :: t(), content_hash :: StatifierPersistence.Storage.Adapter.content_hash(), statuses :: [StatifierPersistence.Storage.Adapter.execution_status(), ...] ) :: {:ok, [StatifierPersistence.Storage.Adapter.execution_id()]} | {:error, error()}
Lists the ids of the executions on content_hash whose stored status
is one of statuses (ADR-0017 decision 8).
statuses is a non-empty list of the stored statuses of
StatifierPersistence.Storage.Adapter.execution_status/0; anything
else raises ArgumentError before the adapter is asked. The answer is
{:ok, ids} in ascending execution id, and {:ok, []} for a hash this
store has never seen.
Delegates to the adapter's optional
StatifierPersistence.Storage.Adapter.list_execution_ids_by_content_hash/3
under the capability count_executions_by_content_hash/2 checks. It
answers {:error, :content_hash_query_unsupported} without calling the
adapter at all when the adapter does not declare that capability, and
also when it declares the capability but does not export this callback
- an adapter written before the callback existed (ADR-0017 decision 9). An error the adapter answers is passed through as it is.
StatifierPersistence.Executions.migrate_batch/3 asks it for
[:active, :needs_migration]. list_active_execution_ids_by_content_hash/2
and its :active-only answer are unchanged.
@spec list_execution_states_by_metadata( store :: t(), metadata :: StatifierPersistence.Storage.Adapter.metadata() ) :: {:ok, [StatifierPersistence.Storage.Adapter.execution_state()]} | {:error, error()}
The indexed status projection over the same match
list_executions_by_metadata/2 takes (sp-t57, ruling C5).
Delegates to the adapter's optional
StatifierPersistence.Storage.Adapter.list_execution_states_by_metadata/2
when execution_states_supported?/1 is true; returns
{:error, :execution_states_unsupported} otherwise, without calling the
adapter at all.
This is the read a fan-out's settlement uses to ask whether every child of an invocation has reached a terminal status. It exists so that question does not cost N whole records - blobs included - once per child.
@spec list_executions_by_metadata( store :: t(), metadata :: StatifierPersistence.Storage.Adapter.metadata() ) :: {:ok, [StatifierPersistence.Storage.Adapter.execution_record()]} | {:error, error()}
Lists the executions whose stored metadata contains every key/value pair in
metadata (ADR-0008 decision 5).
Delegates to the adapter's optional
StatifierPersistence.Storage.Adapter.list_executions_by_metadata/2 when
child_listing_supported?/1 is true; returns
{:error, :child_listing_unsupported} for an adapter that does not
export it, without calling the adapter at all.
@spec list_inputs( store :: t(), execution_id :: StatifierPersistence.Storage.Adapter.execution_id() ) :: {:ok, [input()]} | :not_supported | {:error, error()}
Lists execution_id's whole input log in ascending seq, decoded (ADR-0010
decision 2).
The decode half of append_input/4: each stored input_blob comes back
as the %Statifier.Event{} that was delivered, equal to the one the
interpreter saw, caller_context and all. An entry whose event is
nil is the closed marker the cap wrote (decision 6) and nothing else -
a reader that maps this log onto a replay refuses on it rather than
replaying an execution that never happened.
:not_supported for an adapter that keeps no log;
{:error, :execution_not_found} for an execution that does not exist; {:ok, []}
for an execution with no inputs.
@spec load_execution_position( store :: t(), execution_id :: StatifierPersistence.Storage.Adapter.execution_id(), machine :: Statifier.Machine.t() ) :: {:ok, Statifier.MachineState.t()} | {:error, error()}
Fetches the execution stored under execution_id and rebuilds its position into a
Statifier.MachineState.t() walking machine, refusing a chart-revision
mismatch instead of silently resuming the wrong configuration.
Runs in load_position/3's order, with one extra arm: the same cheap
identity pre-check against the stored identity_blob, then
{:error, :execution_position_missing} for an execution whose position_blob is
nil (an execution that failed at creation stores none - ADR-0004 decision 1),
then Position.from_binary/2 as the authoritative check, its result
returned unchanged.
Like load_position/3, the returned state carries nil for routes,
invoke_types and send_types (st-ADR-0064); re-stamping them before
the next drive is the stepper's job, not this function's.
@spec load_position( store :: t(), session_id :: StatifierPersistence.Storage.Adapter.session_id(), machine :: Statifier.Machine.t() ) :: {:ok, Statifier.MachineState.t()} | {:error, error()}
Fetches the position stored for session_id and rebuilds it into a
Statifier.MachineState.t() walking machine, refusing a chart-revision
mismatch instead of silently resuming the wrong configuration.
Runs in this order, and the order is the contract:
fetch_position/2on the adapter.:position_not_foundand{:adapter, term()}pass straight through.- The cheap pre-check: decodes the stored
identity_bloband compares it againstMachine.identity(machine)withIdentity.matches?/2- never==/2(st-ADR-0052 decision 1). A mismatch returns{:error, {:identity_mismatch, stored, supplied}}without paying the position decode.machinecarrying no identity returns{:error, :unidentified_chart}. A storedidentity_blobthat does not decode returns{:error, :not_a_statifier_blob}(or{:error, {:unsupported_format_version, version}}). Position.from_binary/2- the authoritative check. Its result is returned unchanged, all four error arms included.
Step 2 is an optimization that must never disagree with step 3: both
reuse Identity.matches?/2 and produce the same
{:identity_mismatch, expected, actual} arm, whichever check fires.
The returned MachineState.t() carries nil for routes,
invoke_types and send_types. None survives the round trip:
Position.to_binary/1 drops all three alongside :machine when it
encodes the payload, and from_binary/2 drops all three from the
decoded payload before it rebuilds the struct - unconditionally, so a
blob written by an older encoder decodes to nil too (st-ADR-0064,
which amends st-ADR-0052 in part).
A caller must therefore stamp all three before the next drive, via
MachineState.put_routes/2, MachineState.put_invoke_types/2 and
MachineState.put_send_types/2: each is a per-drive or per-session
snapshot (st-ADR-0048, st-ADR-0051, st-ADR-0069), and a persisted
position is not the place they live. Stamping is the stepper's job
(sp-4an.2), not this function's; this function is documented here as
the place a reader learns the snapshot does not come back.
Whether store's adapter can store an execution's opaque metadata map
(ADR-0006 decision 3).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_metadata?/1 and it
answers true for this handle. This is the predicate insert_execution/5's
refusal-at-open consults, exposed because a host choosing between an
adapter's scope query and its own side table wants the answer before it
writes, and because the conformance suite branches on it.
@spec new(adapter :: module(), opts :: StatifierPersistence.Storage.Adapter.opts()) :: {:ok, t()} | {:error, error()}
Initializes adapter with opts and returns the handle every other
function in this module takes as its first argument.
@spec prune_executions( store :: t(), cutoff :: DateTime.t(), limit :: pos_integer(), scope :: StatifierPersistence.Storage.Adapter.prune_scope() ) :: {:ok, StatifierPersistence.Storage.Adapter.prune_counts()} | {:error, error()}
Prunes one batch of at most limit finished executions that ended
before cutoff (ADR-0016, the facade half of
StatifierPersistence.Storage.Adapter.prune_executions/4).
Each execution in the batch keeps its row and loses its position blob
and its input log. The callback's documentation says which executions
a batch takes. StatifierPersistence.Retention.prune/3 calls this
until nothing is left, or once with single_batch: true, and is the
door a host uses - including for one batch per host transaction.
scope is the callback's: [], the default, prunes from the whole
store, and column equalities confine the batch to the rows that hold
them (StatifierPersistence.Storage.Adapter.prune_scope/0). An
adapter that cannot confine a batch answers
{:error, :unscoped_adapter} for a scope that is not [].
{:error, :execution_pruning_unsupported} for a store whose adapter
does not declare the capability, without calling it.
@spec retire_chart( store :: t(), content_hash :: StatifierPersistence.Storage.Adapter.content_hash(), opts :: keyword() ) :: {:ok, StatifierPersistence.Storage.Adapter.retired_info()} | {:error, error()}
Retires the chart on content_hash over this package's own tables
(ADR-0012 decisions 5 and 6).
The facade half of the split decision 5 names: this function owns the
counts this package can take and the tombstone write, and
StatifierPersistence.Executions.retire_chart/4 above it owns
everything that reaches outside the package. A host calls that one;
this one is here for a host that keeps its own pin accounting and has
already done the outside half itself.
Two refusals happen at open, before anything is counted and before a transaction is opened:
{:error, :content_hash_query_unsupported}for a store that cannot answer the drained query, because a chart must not be retired against a count that was never taken (decision 3);{:error, :chart_retirement_unsupported}for a store that cannot carry a tombstone - the two chart blob columns are stillNOT NULL, or the two tombstone columns are absent. Migration V07 makes them nullable on Postgres only, so this is the answer on a store V07 could not finish arranging, and it names the backend limit instead of surfacing a constraint violation.
Everything after that is one atomic unit in the adapter: the counts,
the refusal, and the tombstone. A non-zero count anywhere in ADR-0012
decision 1's blocking set answers {:error, {:pinned, counts}} and
writes nothing, and the counts it carries include the three terminal
execution arms, which are reported and never block. A hash with no
chart is {:error, :chart_not_found}; a hash already retired is
{:error, {:chart_retired, info}} and never a second tombstone.
Options
retired_by:(required) - the opaque host string recorded on the row as who asked. This package does not interpret it.now:- theDateTime.t/0written asretired_at. Defaults toDateTime.utc_now/0; there is no clock in this package and nothing here decides when a chart should be retired (decision 7).source_counts:- the pin-source counts already collected, keyed by source module. Defaults to%{}. They are folded into the same atomic unit as this package's own counts, so a source count that blocks blocks inside the unit that would otherwise write.
@spec save_chart( store :: t(), machine :: Statifier.Machine.t(), chart_blob :: binary() ) :: :ok | {:error, error()}
Stores chart_blob under the content hash and identity envelope of
machine's own Machine.identity/1 - never a caller-supplied hash.
chart_blob is stored verbatim and is the one thing this function does
not derive or inspect (ADR-0003 decision 1). Returns
{:error, :unidentified_chart} when machine carries no identity,
without calling the adapter at all.
A hash a retirement has tombstoned is refused with
{:error, {:chart_retired, info}} and is not revived (ADR-0012
decision 6): a content-addressed save looks identical to an ordinary
idempotent re-save, so it is the one call with no way to say "yes, I
mean to undo that". A host that still wants the chart re-authors the
document and saves the result under its new hash.
@spec save_position( store :: t(), session_id :: StatifierPersistence.Storage.Adapter.session_id(), machine_state :: Statifier.MachineState.t() ) :: :ok | {:error, error()}
Encodes machine_state with Position.to_binary/1 and stores it under
session_id, keyed by the content hash and identity envelope of
machine_state.machine's own Machine.identity/1 - never a
caller-supplied hash, so a caller cannot store a position under a hash
that disagrees with its blob.
{:error, :unidentified_chart} from Position.to_binary/1 is returned
unchanged: st-ADR-0052 decision 4's structural guarantee that no
unverifiable blob can be written arrives intact at this layer too.
Whether store's adapter can write a tree migration as one unit
(ADR-0015 decision 3).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_tree_migration?/1 and
StatifierPersistence.Storage.Adapter.write_tree_migration/2 and the
first answers true - the shape chart_retirement_supported?/1 checks.
@spec update_execution( store :: t(), execution_id :: StatifierPersistence.Storage.Adapter.execution_id(), machine_state :: Statifier.MachineState.t(), status :: StatifierPersistence.Storage.Adapter.execution_status(), opts :: [execution_write_opt()] ) :: :ok | {:error, error()}
Overwrites the execution stored under execution_id with a full record derived the
same way insert_execution/5 derives one: identity always from
machine_state.machine's own Machine.identity/1, refusal of an
unidentified machine, position_blob encoded under position: :persist
(the default).
Under position: :skip the currently stored position_blob is carried
forward verbatim: because the adapter's update_execution/2 is a full-record
overwrite, this function fetches the current record and reuses its blob
bytes unchanged, so a status-only update (a failed step, an abandonment)
never touches the stored position. Returns {:error, :execution_not_found}
when no execution exists for the id.
An execution's metadata is write-once (ADR-0006 decision 1 grants a map at
create and no way to change it), so this function accepts no metadata:
option and passes %{} in the record; the adapter carries the stored map
forward verbatim, as
StatifierPersistence.Storage.Adapter.update_execution/2 records.
@spec update_execution_status( store :: t(), execution_id :: StatifierPersistence.Storage.Adapter.execution_id(), status :: StatifierPersistence.Storage.Adapter.execution_status(), opts :: [execution_write_opt()] ) :: :ok | {:error, error()}
Overwrites only the status and failure of the execution stored under execution_id,
carrying every other stored field - both blobs included - forward
verbatim.
This is the writer for a host-driven terminal transition that has no
MachineState in hand (StatifierPersistence.Executions.fail/4, ADR-0004
decision 6): nothing is derived, decoded, or re-encoded, so the identity
guard is preserved by construction - the stored identity_blob and
position_blob bytes never change. opts accepts failure: and
outcome_blob: (both default nil). Returns
{:error, :execution_not_found} when no execution exists for the id.
outcome_blob: is the one writer of an execution's own answer, and this is the
right writer for it: an answer is recorded exactly when an execution reaches a
terminal status, which is the transition this function exists for, and
nothing else about the record is touched. A nil (the default) carries
the stored blob forward, so a status-only update never erases an answer.
@spec write_tree_migration(store :: t(), writes :: [tree_write()]) :: :ok | {:error, error()}
Writes a tree migration's re-pins and parks as one unit: every write
lands or none does (ADR-0015 decision 3, the facade half of
StatifierPersistence.Storage.Adapter.write_tree_migration/2).
A re-pin's record is derived as update_execution/5 derives one: the
identity and content hash from the machine state's own machine, the
position blob encoded from it, the status :active and a nil failure;
the stored metadata and answer are carried forward, and the linkage
pin's content_hash is rewritten when the write names one (ADR-0008's
2026-09-23 Amendment). A park writes :needs_migration and a nil
failure and nothing else. Every record is derived before the adapter is
called, so an unidentified machine refuses with nothing written.
{:error, :tree_migration_unsupported} for a store whose adapter does
not declare the unit, without calling it.
StatifierPersistence.Executions.migrate_tree/4 is the caller; nothing
else in this package writes a linkage pin after create.