StatifierPersistence.Storage (StatifierPersistence v0.9.0)

Copy Markdown View Source

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 by StatifierPersistence.Telemetry; see docs/telemetry.md for 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.

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.

Options the run writers (insert_run/5, update_run/5) accept

t()

Functions

Appends event to run_id's input log at door, returning the ordinal the adapter assigned (ADR-0010 decisions 2 and 3).

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.

Whether store's adapter keeps a run's input log (ADR-0010 decision 1).

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.

Lists run_id's whole input log in ascending seq, decoded (ADR-0010 decision 2).

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

error()

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

input()

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.Runs.entry/0's seven atoms, as its string - the key decision 8's replay mapping is written against.

run_write_opt()

@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 :failed run; defaults to nil.
  • position: - :persist (the default) encodes the given machine state with Position.to_binary/1 and stores it as the run's position_blob; :skip stores nil on insert and carries the currently stored blob forward verbatim on update.
  • metadata: - insert_run/5 only: the optional opaque map of host identities stored beside the run (ADR-0006 decision 1), defaulting to %{}. It is write-once - update_run/5 and update_run_status/4 carry the stored map forward and accept no metadata: of their own.
  • outcome_blob: - update_run_status/4 only: the run's own opaque answer, written once when it reaches a terminal status. Defaults to nil, which carries the stored value forward rather than clearing it, so every other writer leaves an answer already recorded alone.

t()

@type t() :: %StatifierPersistence.Storage{
  adapter: module(),
  opts: StatifierPersistence.Storage.Adapter.opts()
}

Functions

append_input(store, run_id, door, event)

@spec append_input(
  store :: t(),
  run_id :: StatifierPersistence.Storage.Adapter.run_id(),
  door :: atom(),
  event :: Statifier.Event.t()
) ::
  {:ok, StatifierPersistence.Storage.Adapter.seq()}
  | :not_supported
  | {:error, error()}

Appends event to run_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 run'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.Runs.entry/0 atom - the fixed vocabulary of public doors, stored as its string.

check_metadata(store, opts)

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

child_listing_supported?(store)

@spec child_listing_supported?(store :: t()) :: boolean()

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

fetch_chart(store, content_hash)

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

fetch_run(store, run_id)

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

input_log_supported?(store)

@spec input_log_supported?(store :: t()) :: boolean()

Whether store's adapter keeps a run'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 run can find out whether it will get one before it drives one.

insert_run(store, run_id, machine_state, status, opts \\ [])

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

list_inputs(store, run_id)

@spec list_inputs(
  store :: t(),
  run_id :: StatifierPersistence.Storage.Adapter.run_id()
) ::
  {:ok, [input()]} | :not_supported | {:error, error()}

Lists run_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 a run that never happened.

:not_supported for an adapter that keeps no log; {:error, :run_not_found} for a run that does not exist; {:ok, []} for a run with no inputs.

list_run_states_by_metadata(store, metadata)

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

list_runs_by_metadata(store, metadata)

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

load_position(store, session_id, machine)

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

  1. fetch_position/2 on the adapter. :position_not_found and {:adapter, term()} pass straight through.
  2. The cheap pre-check: decodes the stored identity_blob and compares it against Machine.identity(machine) with Identity.matches?/2 - never ==/2 (st-ADR-0052 decision 1). A mismatch returns {:error, {:identity_mismatch, stored, supplied}} without paying the position decode. machine carrying no identity returns {:error, :unidentified_chart}. A stored identity_blob that does not decode returns {:error, :not_a_statifier_blob} (or {:error, {:unsupported_format_version, version}}).
  3. 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.

load_run_position(store, run_id, machine)

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

metadata_supported?(store)

@spec metadata_supported?(store :: t()) :: boolean()

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.

new(adapter, opts)

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

run_outcome_supported?(store)

@spec run_outcome_supported?(store :: t()) :: boolean()

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.

run_states_supported?(store)

@spec run_states_supported?(store :: t()) :: boolean()

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.

save_chart(store, machine, chart_blob)

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

save_position(store, session_id, machine_state)

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

update_run(store, run_id, machine_state, status, opts \\ [])

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

update_run_status(store, run_id, status, opts \\ [])

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