# `StatifierPersistence.Storage.Adapter`
[🔗](https://github.com/riddler/statifier_persistence/blob/v0.12.0/lib/statifier_persistence/storage/adapter.ex#L1)

The storage contract: opaque blobs keyed by engine identities.

An adapter stores and returns binaries, engine identity strings, and one
optional opaque map of host identities on an execution record - and nothing else
(ADR-0003 decision 1 as amended by ADR-0006). No callback receives a compiled
`Statifier.Machine` and none returns a `Statifier.MachineState` value -
the identity guard is not a callback here, and cannot be, because no
callback ever holds both the stored identity and a caller-supplied machine
at the same time. The guard lives above every adapter, in
`StatifierPersistence.Storage.load_position/3` (ADR-0003 decision 2).

A chart is keyed by its content hash
(`Statifier.Machine.Identity.content_hash`, verbatim); a position is keyed
by the engine session id (st-ADR-0008's `sess_` UXID), also verbatim; a
execution is keyed by a caller-supplied opaque `execution_id`, also verbatim
(ADR-0004 decision 2). All are opaque strings to this layer: no callback
here accepts or returns a surrogate key, a table name, or a prefix
(ADR-0002 decision 1, ADR-0003 decision 3).

## The input log is a data-retention decision, not a debugging switch

An adapter that exports `supports_input_log?/1` and answers `true`
stores every input the interpreter saw, verbatim, for the life of the
execution (ADR-0010). Chart blobs, position blobs and identity blobs are
engine-shaped; an event's `data` is the host's own values - a signup's
email address, a capture's card number - and turning the log on puts
them at rest. `:blob_type` reaches `input_blob` for exactly that reason
(ADR-0010 decision 4), and `input_log_cap:` bounds how much of it
accumulates (decision 6), but a host that cannot encrypt at rest and
cannot accept plaintext payloads declines the whole facility by not
exporting the callback. Nothing in this package refuses an execution over it.

# `chart_record`

```elixir
@type chart_record() :: %{
  content_hash: content_hash(),
  identity_blob: binary(),
  chart_blob: binary()
}
```

A stored chart: its content hash, its identity envelope
(`Statifier.Machine.Identity.to_binary/1`), and an opaque `chart_blob`
this layer does not decode, inspect, or say what produced (ADR-0003
decision 1).

# `content_hash`

```elixir
@type content_hash() :: String.t()
```

A chart's content hash, verbatim from `Statifier.Machine.Identity` (ADR-0002 decision 1).

# `door`

```elixir
@type door() :: String.t()
```

The public door an input entered by (`t:StatifierPersistence.Executions.entry/0`),
as a string.

# `error`

```elixir
@type error() ::
  :chart_not_found
  | :position_not_found
  | :execution_exists
  | :execution_not_found
  | :metadata_unsupported
  | :execution_outcome_unsupported
  | :execution_states_unsupported
  | :input_log_full
  | {:adapter, term()}
```

This layer's own refusal arms. `:chart_not_found`, `:position_not_found`,
and `:execution_not_found` are the not-found arms every adapter must return
instead of `nil` or a raise; `:execution_exists` is `insert_execution/2`'s refusal of
a duplicate `execution_id`; `:metadata_unsupported` is the refusal-at-open arm
for a non-empty `metadata` map an adapter cannot store (ADR-0006
decision 3); `:execution_outcome_unsupported` and `:execution_states_unsupported`
are the refusal-at-open arms for an adapter that cannot store an execution's
`outcome_blob` or cannot answer the indexed status projection, which
together are what a fan-out's settlement needs; `:input_log_full` is
`append_input/3`'s refusal past the host-declared cap, after the log has
closed itself with a marker (ADR-0010 decision 6) - it refuses the
append and never the step; `{:adapter, term()}` carries a backend
failure (a database down, a timeout) that is not this layer's to
interpret further.

# `execution_id`

```elixir
@type execution_id() :: String.t()
```

An execution's key: a caller-supplied opaque string, stored verbatim (ADR-0004
decision 2). A host identity in ADR-0002 decision 1's category - never a
surrogate this layer generates.

# `execution_record`

```elixir
@type execution_record() :: %{
  execution_id: execution_id(),
  status: execution_status(),
  content_hash: content_hash(),
  identity_blob: binary(),
  position_blob: binary() | nil,
  failure: String.t() | nil,
  metadata: metadata(),
  outcome_blob: binary() | nil
}
```

A stored execution (ADR-0004 decision 1): its caller-supplied key, its status,
the content hash and identity envelope of the chart it runs, the opaque
`position_blob` holding its current position - nullable, because an execution
that fails at creation has no quiescent position to store - a short
`failure` reason for a `:failed` execution, `nil` otherwise, the opaque
`metadata` map of host identities (ADR-0006 decision 1), `%{}` when the
caller supplied none, and the opaque `outcome_blob`.

`outcome_blob` is the execution's own answer, written once when it reaches a
terminal status and `nil` for every execution that has none - which is almost
all of them, since an ordinary execution answers nobody. It is opaque in
`position_blob`'s strong sense: this layer does not decode it, does not
say what produced it, and never reads it to make a decision. It exists
because a stored record otherwise carries no trace of what a completed
execution answered with, and a fan-out's settlement has to assemble N answers
it did not witness.

# `execution_state`

```elixir
@type execution_state() :: %{
  execution_id: execution_id(),
  status: execution_status(),
  child_index: non_neg_integer() | nil
}
```

One row of the indexed status projection
(`c:list_execution_states_by_metadata/2`): a matched execution's id, its status, and
the `child_index` from this package's own reserved linkage namespace -
`nil` for a matched execution that carries no linkage.

Deliberately not a `execution_record`: the projection exists so that "have all
N of this invocation's children settled?" can be answered without moving
N identity and position blobs, and a row carrying them would defeat the
reason the callback exists.

# `execution_status`

```elixir
@type execution_status() :: :active | :completed | :failed | :cancelled
```

An execution's lifecycle status (ADR-0004 decision 2, extended by ADR-0008
decision 5). `:completed`, `:failed` and `:cancelled` are terminal. No
callback here validates a transition between them - the lifecycle above
the facade owns that.

# `input_record`

```elixir
@type input_record() :: %{
  execution_id: execution_id(),
  seq: seq(),
  door: door(),
  input_blob: binary() | nil
}
```

One stored input (ADR-0010 decision 2): the execution it belongs to, its
ordinal, the door it entered by, and the opaque `input_blob` the facade
encoded above this layer. A `nil` `input_blob` is the closed marker of
decision 6 and nothing else.

`append_input/3` is handed a record whose `seq` it must ignore - the
adapter assigns the ordinal, because denseness is a property only the
store can compute atomically - and returns the value it assigned.
`list_inputs/2` returns records whose `seq` is authoritative.

# `metadata`

```elixir
@type metadata() :: %{optional(String.t()) =&gt; term()}
```

An execution's optional opaque metadata (ADR-0006 decision 1): a map of string
keys to host-supplied values, opaque here in the strong sense
`chart_blob` is opaque. No callback reads a key or a value to make a
decision, validates it beyond the shape, or merges it into a blob.

The map carries **host identities only, never personal data** (ADR-0006
decision 2): a tenant id, a subject-entity id, a correlation id - never a
name, an email address, a postal address, a card number, or any other
personal or cardholder data. Blob encryption (`:blob_type`) covers the
three blob columns and does not reach this map, so anything filed here is
at rest in the clear. This package cannot enforce the rule - the map is
opaque by decision 1 - so the contract states it and the host keeps it.

The empty map means "no metadata" and is what an absent map resolves to.

# `opts`

```elixir
@type opts() :: keyword()
```

Adapter configuration, opaque to this package. `init/1` returns the value
a caller then threads through every other callback as this adapter's
handle - a pid, a name, a repo module, whatever the adapter needs.

# `position_record`

```elixir
@type position_record() :: %{
  session_id: session_id(),
  content_hash: content_hash(),
  identity_blob: binary(),
  position_blob: binary()
}
```

A stored position: the engine session id it belongs to, the content hash
and identity envelope of the chart it was saved against, and the opaque
`position_blob` (`Statifier.Position.to_binary/1`'s output) this layer
does not decode.

# `seq`

```elixir
@type seq() :: non_neg_integer()
```

An execution's input log ordinal: dense, zero-based, per execution, assigned by the
adapter. Not a position blob (ADR-0010 decision 3).

# `session_id`

```elixir
@type session_id() :: String.t()
```

An engine session id (st-ADR-0008), verbatim.

# `append_input`
*optional* 

```elixir
@callback append_input(opts(), execution_id(), input_record()) ::
  {:ok, seq()} | {:error, error()}
```

Optional append of one input to `execution_id`'s log (ADR-0010 decision 2).

The adapter assigns the ordinal and returns it: the `seq` on the given
record is ignored on the way in and authoritative on the way out,
because denseness from zero is a property only the store can compute
atomically. A lost race must fail the write rather than duplicate an
ordinal - a gap in a log is a defect, not a reading.

`input_blob` is opaque here in `position_blob`'s strong sense: the
facade above every adapter encodes the `%Statifier.Event{}` the
interpreter saw (ADR-0010 decision 3), and nothing in this layer decodes
it, inspects it, or says what produced it. `execution_id`, `seq` and `door`
are identity and lookup values and are stored beside it, not inside it.

Past the cap the host declared at `init/1` (`input_log_cap:`), the
append returns `{:error, :input_log_full}` and the log is **closed**:
the cap's last slot is written as a record with a `nil` `input_blob`,
and no later append to that execution ever succeeds (ADR-0010 decision 6). A
truncated log that looks complete is worse than no log, so the
truncation is a value in the log rather than an absence.

# `fetch_chart`

```elixir
@callback fetch_chart(opts(), content_hash()) :: {:ok, chart_record()} | {:error, error()}
```

Fetches the chart stored under `content_hash`.

Returns `{:error, :chart_not_found}` when no chart is stored under that
hash - never `{:ok, nil}` and never a raise. The returned `chart_blob` and
`identity_blob` must be byte-identical to what `save_chart/2` was given;
an adapter must not normalize, truncate, or re-encode them.

# `fetch_execution`

```elixir
@callback fetch_execution(opts(), execution_id()) ::
  {:ok, execution_record()} | {:error, error()}
```

Fetches the execution stored under `execution_id`.

Returns `{:error, :execution_not_found}` when no execution is stored under that id -
never `{:ok, nil}` and never a raise. The returned `identity_blob` and
`position_blob` must be byte-identical to what was stored (a stored `nil`
`position_blob` comes back as `nil`); an adapter must not normalize,
truncate, or re-encode them, and it does not decode them either (ADR-0003
decision 1). The returned `metadata` is the map `insert_execution/2` stored,
unchanged, and `%{}` when none was stored - never `nil`.

# `fetch_position`

```elixir
@callback fetch_position(opts(), session_id()) ::
  {:ok, position_record()} | {:error, error()}
```

Fetches the position stored for `session_id`.

Returns `{:error, :position_not_found}` when no position is stored for
that session id - never `{:ok, nil}` and never a raise. The returned
`position_blob` and `identity_blob` must be byte-identical to what
`save_position/2` was given.

# `init`

```elixir
@callback init(opts()) :: {:ok, opts()} | {:error, error()}
```

Prepares this adapter for use and returns the handle every other callback
is called with as its first argument.

For an adapter that needs setup - starting an Agent, checking a repo is
reachable - this is the declared place for it, and the return value is
the opts a caller threads through `save_chart/2`, `fetch_chart/2`,
`save_position/2`, and `fetch_position/2`. An adapter needing no setup
returns `{:ok, opts}` unchanged.

# `insert_execution`

```elixir
@callback insert_execution(opts(), execution_record()) :: :ok | {:error, error()}
```

Inserts `execution_record`, refusing a duplicate `execution_id` with
`{:error, :execution_exists}`.

The refusal must be atomic with the write: no interleaving of two
`insert_execution/2` calls for the same `execution_id` may let both return `:ok`.
Create-exactly-once rests on this callback alone, without a lock, so a
check-then-insert implemented as two separate operations does not satisfy
the contract - a SQL adapter reaches for a unique index (or equivalent
backend-native uniqueness) and maps its violation to
`{:error, :execution_exists}`.

This callback does not decode `position_blob` or `identity_blob`, does
not validate the status, and performs no identity check - the facade and
the lifecycle own those (ADR-0003 decisions 1 and 2, ADR-0004
decision 1).

`metadata` is stored as given and read back by `fetch_execution/2` unchanged
(ADR-0006 decision 1). Insert is the only write that sets it: the map is
not mutable after create, and `update_execution/2` says so. An adapter reaching
this callback has already declared `supports_metadata?/1` true for a
non-empty map - the facade refuses at open otherwise - but an adapter
whose backend cannot hold a particular value (a `jsonb` column and a
tuple, say) refuses that value here with
`{:error, :metadata_unsupported}` rather than storing something else.

# `isolate`
*optional* 

```elixir
@callback isolate(opts()) :: :ok | {:error, error()}
```

Optional per-test isolation hook (ADR-0003 amendment, 2026-08-21).

The conformance suite's case template (`StorageConformance`, in this
package's `Testing` namespace) calls this before each generated test,
when an adapter exports it, so an adapter backed by a
shared resource - a database connection, a sandbox checkout - can wrap
every test in its own isolated unit (an `Ecto.Adapters.SQL.Sandbox`
checkout, for one) instead of leaking state between conformance tests.
Optional and defaulted to a no-op by the template's own
`function_exported?/3` check: an adapter needing no isolation, like
`StatifierPersistence.Storage.InMemory`, simply does not implement it.

# `list_execution_states_by_metadata`
*optional* 

```elixir
@callback list_execution_states_by_metadata(opts(), metadata()) ::
  {:ok, [execution_state()]} | {:error, error()}
```

Optional indexed status projection over a metadata match (sp-t57,
ruling C5).

Answers the same match `list_executions_by_metadata/2` answers - equality on
every pair, recursively for a nested map - and returns
`t:execution_state/0` rows instead of whole records: the execution id, its status,
and its `child_index`.

It exists because the settlement of a fan-out asks "have all N of this
invocation's children reached a terminal status?" once per child, and
answering that with the listing would move N identity and position
blobs N times over one fan-out. An adapter implements this as a
projection its backend can serve from an index (the Ecto adapter selects
three columns under the `jsonb` containment predicate V03 indexes);
materialising records and mapping them down is a conformant but
pointless implementation, and defeats the callback's whole purpose.

Exporting it is how an adapter declares it can answer the question.
`StatifierPersistence.Driver.start_child_at/6` refuses at open with
`{:refused, :execution_states_unsupported}` for an adapter that does not.

# `list_executions_by_metadata`
*optional* 

```elixir
@callback list_executions_by_metadata(opts(), metadata()) ::
  {:ok, [execution_record()]} | {:error, error()}
```

Optional metadata-match listing (ADR-0006 decision 3's equality-match
helper, promoted to a callback by ADR-0008 decision 5).

Lists the executions whose stored `metadata` contains every key/value pair in
`metadata`, recursively for a nested map. Equality match on all pairs is
the whole query surface - no ranges, no partial matches, no ordering
guarantee - the same contract `StatifierPersistence.Storage.Ecto`'s
module-local function already documents. Exporting it is how an adapter
declares it can answer "which executions name me as their parent", which is
what a cascading cancel walks; an adapter that does not export it cannot
host a durable subchart, and `StatifierPersistence.Driver` refuses at
open rather than starting a child it could never cancel.

# `list_inputs`
*optional* 

```elixir
@callback list_inputs(opts(), execution_id()) ::
  {:ok, [input_record()]} | {:error, error()}
```

Optional listing of `execution_id`'s whole input log, in ascending `seq`
(ADR-0010 decision 2).

The whole log, always - no filter, no range, no limit, no reverse. The
whole log is what a replay consumes, and the cap is what bounds it. A
execution with no inputs is `{:ok, []}`; an execution that does not exist is
`{:error, :execution_not_found}`, the not-found arm this layer already
requires instead of `nil` or a raise.

# `lock_execution`
*optional* 

```elixir
@callback lock_execution(opts(), execution_id(), (-&gt; result)) ::
  {:ok, result} | {:error, error()}
when result: var
```

Optional per-execution lock (ADR-0003 amendment, 2026-08-22; ADR-0004
decision 5).

Provides mutual exclusion per `execution_id`: while one `lock_execution/3` call for a
given `execution_id` is running `fun`, no other `lock_execution/3` call for the same
`execution_id` may run its own. `fun` runs while the exclusion is held, and the
exclusion is released on ANY exit from `fun` - a normal return, a throw,
and a raise escaping `fun` alike. The lock must not leak: a raising `fun`
propagates to the caller, but the next `lock_execution/3` for that `execution_id`
must still acquire.

This is the callback the default serialization strategy
(`StatifierPersistence.Serialization.AdapterLock`) delegates to; an
adapter that does not export it makes that strategy refuse with
`{:error, {:serialization, :not_supported}}`. The Ecto adapter
implements it as a transaction-scoped advisory lock plus a
`SELECT ... FOR UPDATE` row lock inside a transaction that spans `fun`
(ADR-0004 decision 5 as amended 2026-08-22) - the advisory half exists
because a row lock alone excludes nothing for a `execution_id` whose execution has
not been inserted yet.

# `save_chart`

```elixir
@callback save_chart(opts(), chart_record()) :: :ok | {:error, error()}
```

Stores `chart_record`, idempotent on its `content_hash`.

A content hash is a content address: saving the same hash twice is `:ok`
and must not duplicate the row or change what a later `fetch_chart/2`
returns for it. This callback does not inspect, decode, or validate
`chart_blob`'s bytes and performs no identity check - both are outside
this layer's job (ADR-0003 decisions 1 and 2).

# `save_position`

```elixir
@callback save_position(opts(), position_record()) :: :ok | {:error, error()}
```

Stores `position_record`, overwriting any position already stored for its
`session_id`.

A session has exactly one current position; this layer keeps no history
of prior saves. This callback does not decode or validate `position_blob`
and performs no identity check, for the same reason `save_chart/2` does
not: the guard belongs to the facade, above every adapter (ADR-0003
decision 2).

# `supports_execution_outcome?`
*optional* 

```elixir
@callback supports_execution_outcome?(opts()) :: boolean()
```

Optional declaration that this adapter can store an execution's `outcome_blob`
(sp-t57, ruling C3).

The same opt-in-by-export shape `supports_metadata?/1` uses. An adapter
that does not export it, or answers `false`, stores no outcome payload,
and `StatifierPersistence.Driver.start_child_at/6` refuses to start a
fan-out child on it at open with `{:refused, :execution_outcome_unsupported}` -
a child whose answer could never be read back is a child whose
invocation could never be settled, the same posture
`:child_listing_unsupported` already takes for a child that could never
be cancelled.

Nothing else refuses on it. An ordinary execution and a single-child durable
subchart need no outcome payload, so an adapter written before this
callback existed is conformant unchanged and sees no behaviour change.

# `supports_input_log?`
*optional* 

```elixir
@callback supports_input_log?(opts()) :: boolean()
```

Optional declaration that this adapter keeps an execution's input log
(ADR-0010 decision 1).

The same opt-in-by-export shape `supports_metadata?/1` and
`supports_execution_outcome?/1` use: an adapter exports it and answers `true`,
the facade checks with `function_exported?/3`, and an adapter that does
not export it stores no inputs and sees no behaviour change.
`StatifierPersistence.Storage.InMemory` and every adapter written before
ADR-0010 stay conformant without a line of change.

**No execution-lifecycle call refuses on it**, and that is the deliberate
departure from `supports_metadata?/1`'s refusal at open. Those refuse
because something the host *asked for* would otherwise be silently
dropped; the input log is a derived record of inputs the host is
supplying anyway, and refusing to run a chart because the log cannot be
kept would let a diagnostic facility break the execution it is diagnosing. A
host that needs to know asks
`StatifierPersistence.Storage.input_log_supported?/1`.

# `supports_metadata?`
*optional* 

```elixir
@callback supports_metadata?(opts()) :: boolean()
```

Optional declaration that this adapter can store an execution's `metadata` map
(ADR-0006 decision 3).

Exporting it and returning `true` is how an adapter opts into the
metadata contract - the same `@optional_callbacks` plus
`function_exported?/3` shape `isolate/1` and `lock_execution/3` use. An adapter
that does not export it stores no metadata, and
`StatifierPersistence.Storage.insert_execution/5` refuses a non-empty map for
it at open with `{:error, :metadata_unsupported}` before any write
happens. An empty or absent map is never refused, which is what keeps
every adapter written before ADR-0006 conformant without a line of
change.

The refusal is at open - at the create that supplies the map - so a host
learns on its first call rather than discovering a silently dropped scope
on a later fetch. Refusing is conformance, not a gap.

# `update_execution`

```elixir
@callback update_execution(opts(), execution_record()) :: :ok | {:error, error()}
```

Overwrites the execution stored under `execution_record`'s `execution_id` with the full
record.

Returns `{:error, :execution_not_found}` when no execution exists for the id. This is
a full-record overwrite - there is no partial-update surface, so every
field in the stored row after this call is the given record's, including
a `nil` `position_blob`.

`metadata` is the one exception, and it is an exception by decision:
ADR-0006 decision 1 grants a map supplied at create and grants no way to
change it afterwards, so this callback carries the stored map forward
verbatim and ignores the `metadata` field of the record it is given. The
facade passes `%{}` there for exactly that reason. An adapter that
overwrote it would make the map mutable, which is the reopener ADR-0006's
consequences name rather than a behaviour it grants.

`outcome_blob` is the second exception, and a narrower one: a record
whose `outcome_blob` is `nil` leaves the stored value untouched, and a
record carrying a binary sets it. The payload is written once, when the
execution reaches a terminal status, and never cleared, so "nil means
unchanged" is total rather than a partial-update surface - there is no
writer that needs to set it back to `nil`. Without the carry-forward
every ordinary step of an execution that had already answered would erase its
answer, since this callback is a full-record overwrite and the stepper
builds its record from a `MachineState` that has never seen one.

Like the other execution callbacks it decodes nothing,
validates no status transition, and performs no identity check - the
facade and the lifecycle own those (ADR-0003 decisions 1 and 2, ADR-0004
decision 1).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
