The guarded entry point from a storage adapter to a
Statifier.MachineState.t().
Every load runs through load_position/3 or load_run_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_run/5, update_run/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.
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 run writers (insert_run/5, update_run/5) accept
Functions
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 runs by a metadata match (ADR-0008
decision 5).
Fetches the chart stored under content_hash.
Fetches the run record stored under run_id.
Inserts a run record for run_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.
The indexed status projection over the same match
list_runs_by_metadata/2 takes (sp-t57, ruling C5).
Lists the runs whose stored metadata contains every key/value pair in
metadata (ADR-0008 decision 5).
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.
Fetches the run stored under run_id and rebuilds its position 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 a run'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.
Whether store's adapter can store a run's own outcome_blob (sp-t57,
ruling C3).
Whether store's adapter can answer the indexed status projection
(sp-t57, ruling C5).
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.
Overwrites the run stored under run_id with a full record derived the
same way insert_run/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 run stored under run_id,
carrying every other stored field - both blobs included - forward
verbatim.
Types
@type error() :: StatifierPersistence.Storage.Adapter.error() | :not_a_statifier_blob | :unidentified_chart | :run_position_missing | :metadata_unsupported | :child_listing_unsupported | :run_outcome_unsupported | :run_states_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 run_write_opt() :: {:failure, String.t() | nil} | {:position, :persist | :skip} | {:metadata, StatifierPersistence.Storage.Adapter.metadata()} | {:outcome_blob, binary() | nil}
Options the run writers (insert_run/5, update_run/5) accept:
failure:- the short reason stored on a:failedrun; defaults tonil.position:-:persist(the default) encodes the given machine state withPosition.to_binary/1and stores it as the run'sposition_blob;:skipstoresnilon insert and carries the currently stored blob forward verbatim on update.metadata:-insert_run/5only: the optional opaque map of host identities stored beside the run (ADR-0006 decision 1), defaulting to%{}. It is write-once -update_run/5andupdate_run_status/4carry the stored map forward and accept nometadata:of their own.outcome_blob:-update_run_status/4only: the run'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 t() :: %StatifierPersistence.Storage{ adapter: module(), opts: StatifierPersistence.Storage.Adapter.opts() }
Functions
@spec check_metadata(store :: t(), opts :: [run_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_run/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.Runs.create/4 runs it ahead of
Statifier.Interpreter.initialize/2 so no effect is executed for a run
whose metadata cannot be stored - the same reason insert_run/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 runs by a metadata match (ADR-0008
decision 5).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.list_runs_by_metadata/2 and
metadata_supported?/1 holds for this store. This is the predicate
list_runs_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).
@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.
@spec fetch_run(store :: t(), run_id :: StatifierPersistence.Storage.Adapter.run_id()) :: {:ok, StatifierPersistence.Storage.Adapter.run_record()} | {:error, error()}
Fetches the run record stored under run_id.
@spec insert_run( store :: t(), run_id :: StatifierPersistence.Storage.Adapter.run_id(), machine_state :: Statifier.MachineState.t(), status :: StatifierPersistence.Storage.Adapter.run_status(), opts :: [run_write_opt()] ) :: :ok | {:error, error()}
Inserts a run record for run_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 a run 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 run's
position_blob; under position: :skip the blob is stored nil - the
arm for a run with no quiescent position to store. Uniqueness comes from
the adapter's insert_run/2 :run_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_run_states_by_metadata( store :: t(), metadata :: StatifierPersistence.Storage.Adapter.metadata() ) :: {:ok, [StatifierPersistence.Storage.Adapter.run_state()]} | {:error, error()}
The indexed status projection over the same match
list_runs_by_metadata/2 takes (sp-t57, ruling C5).
Delegates to the adapter's optional
StatifierPersistence.Storage.Adapter.list_run_states_by_metadata/2
when run_states_supported?/1 is true; returns
{:error, :run_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_runs_by_metadata( store :: t(), metadata :: StatifierPersistence.Storage.Adapter.metadata() ) :: {:ok, [StatifierPersistence.Storage.Adapter.run_record()]} | {:error, error()}
Lists the runs whose stored metadata contains every key/value pair in
metadata (ADR-0008 decision 5).
Delegates to the adapter's optional
StatifierPersistence.Storage.Adapter.list_runs_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 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 both routes and
invoke_types. Neither survives the round trip: Position.to_binary/1
drops both alongside :machine when it encodes the payload, and
from_binary/2 drops both 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 both before the next drive, via
MachineState.put_routes/2 and MachineState.put_invoke_types/2: both
are per-drive/per-session snapshots (st-ADR-0048, st-ADR-0051), 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.
@spec load_run_position( store :: t(), run_id :: StatifierPersistence.Storage.Adapter.run_id(), machine :: Statifier.Machine.t() ) :: {:ok, Statifier.MachineState.t()} | {:error, error()}
Fetches the run stored under run_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, :run_position_missing} for a run whose position_blob is
nil (a run 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 both
routes and invoke_types (st-ADR-0064); re-stamping them before the
next drive is the stepper's job, not this function's.
Whether store's adapter can store a run'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_run/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.
Whether store's adapter can store a run's own outcome_blob (sp-t57,
ruling C3).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.supports_run_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 answer the indexed status projection
(sp-t57, ruling C5).
True when the adapter exports the optional
StatifierPersistence.Storage.Adapter.list_run_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 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.
@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.
@spec update_run( store :: t(), run_id :: StatifierPersistence.Storage.Adapter.run_id(), machine_state :: Statifier.MachineState.t(), status :: StatifierPersistence.Storage.Adapter.run_status(), opts :: [run_write_opt()] ) :: :ok | {:error, error()}
Overwrites the run stored under run_id with a full record derived the
same way insert_run/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_run/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, :run_not_found}
when no run exists for the id.
A run'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_run/2 records.
@spec update_run_status( store :: t(), run_id :: StatifierPersistence.Storage.Adapter.run_id(), status :: StatifierPersistence.Storage.Adapter.run_status(), opts :: [run_write_opt()] ) :: :ok | {:error, error()}
Overwrites only the status and failure of the run stored under run_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.Runs.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, :run_not_found} when no run exists for the id.
outcome_blob: is the one writer of a run's own answer, and this is the
right writer for it: an answer is recorded exactly when a run 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.