mutation battery

ash_onetime is an Ash extension for explicit keyed-effect semantics. It separates replay-safe idempotency from collision-rejecting one-time nonces and uses PostgreSQL as the authoritative admissions store.

What it does

Every protected action declares one strategy and a nonempty scope. PostgreSQL admits exactly once per key under the declared scope; the unique constraint — not application code — decides concurrent races. There is no admission pre-read.

  • :idempotency — execute the effect once per key, store the classified response, and replay it verbatim on retries within the retention boundary. A conflicting fingerprint (same key, different request) is terminal and never re-executes. The effect, the admission claim, and the encoded response commit or roll back together in the action's transaction.
  • :one_time_nonce — authenticate a single opportunity and reject every collision. The spend lives and dies with the action transaction by default; a retry must bear a fresh proof.
  • commit: :independent (nonce, opt-in, RFC 9449 §11.1) — the DPoP replay fence. The nonce claim commits in its own transaction before the action body runs, so a downstream failure cannot make the proof reusable. A reused proof is rejected with :nonce_already_used for the acceptance window. Default-off; existing nonce consumers are byte-for-byte unchanged.

Optional, non-admitting surfaces: a response cache, a Plug, Oban workers for cleanup and forward partition creation, and an external-effect execute/recover protocol for peers that need to observe or reverse a side effect.

Hosts that already own one authoritative Ecto transaction can use the public AshOnetime.Transaction boundary instead of wrapping an Ash action. It reserves idempotency and nonce authority inside the caller's existing READ COMMITTED transaction, so the claim, the host effect, and the exact replay bytes commit or roll back together. Logical partitions isolate tenants or authority planes without importing the host's actor types. See Transaction-owned admission.

When to use this vs. hand-rolled idempotency

Use ash_onetime when you need a correct admission layer for effectful Ash actions and do not want to re-derive the failure modes a hand-rolled table-plus-flag approach ships with.

  • The correctness guarantee: a PostgreSQL-authoritative, once-per-key local effect and typed replay within the declared retention boundary. The effect, the admission claim, and the encoded response commit or roll back together in the action's existing transaction; there is no admission pre-read, so the unique constraint — not application code — decides concurrent races. A conflicting fingerprint is terminal and never re-executes.
  • What it replaces: the bespoke idempotency-key table, the "did this already run?" pre-check that races under concurrency, the stored-response schema you have to version and re-encode, the fingerprint binding you have to remember to update, and the silent double-execution that lands when you forget. Each of these is a class of bug this library was built to make impossible at the boundary.
  • When NOT to use it: read-only or non-transactional actions (the library rejects them), resources without AshPostgres (PostgreSQL is the authoritative store; there is no fallback), and workloads that need end-to-end exactly-once delivery rather than once-per-key local admission (delivery is a separate concern — see External effects). If you have no effectful action to protect, there is nothing to admit.

Quick start

defmodule MyApp.Charge do
  use Ash.Resource,
    domain: MyApp.Domain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshOnetime.Resource]

  postgres do
    table "charges"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :account_id, :uuid, allow_nil?: false, public?: true
    attribute :amount, :integer, allow_nil?: false, public?: true
  end

  actions do
    defaults [:read]

    create :charge do
      transaction? true
      argument :idempotency_key, :string, allow_nil?: false
      accept [:account_id, :amount]
    end

    # A DPoP-protected redemption: the proof's spend survives a downstream failure.
    action :redeem, :integer do
      transaction? true
      argument :value, :integer, allow_nil?: false
      argument :proof, :string, allow_nil?: false
      run MyApp.Redeem
    end
  end

  onetime do
    protect :charge do
      strategy :idempotency
      scope([{:static, "charge"}, {:attribute, :account_id}])
      key({:client, :idempotency_key})
      fingerprint(attributes: [:account_id, :amount])
      response(MyApp.ChargeCodec, fields: [:id, :account_id, :amount], classify: MyApp.Classifier)
      retention({24, :hour})
    end

    protect :redeem do
      strategy :one_time_nonce
      scope([{:static, "redeem"}])
      key({:verified, :proof, MyApp.ProofVerifier})
      window(max_age: {5, :minute}, clock_skew: {30, :second})
      commit :independent   # RFC 9449 §11.1 replay fence (omit for the default :with_action)
    end
  end
end

Install with mix ash_onetime.install (Igniter-powered) — it wires the repo, generates the migration, and sets up the schema. See the Getting started guide for the full walkthrough.

Try it

Runnable Livebook notebooks — one per concern — walk through each strategy against a real PostgreSQL. Open them in Livebook, set DATABASE_URL, and run every cell. Each notebook's code is regression-pinned so it never ships broken.

See Security model for the authority and fail-closed contract, and Recipes for end-to-end payment, webhook, and redemption patterns.

Status

The current package release is v1.2.3 on Hex. It tightens the package's runtime application closure — no optional integration (Plug, Oban, Igniter) or property-test dependency is claimed as a runtime application of the extension anymore — on top of v1.2.2's AshOnetime.Transaction.claim_id/1 accessor (the one sanctioned read of a fresh admission's claim UUID), v1.2's operations preflight, backup/restore runbook, and constant-time digest-comparison unification, and v1.1's transaction-owned admission and logical partitions. The source is public. Every protected action chooses :idempotency or :one_time_nonce and declares a nonempty scope; there is no default strategy or global scope fallback. PostgreSQL-authoritative admission, transactional Ash execution, typed replay, fail-closed nonce spending, signed tokens, external-effect recovery, bounded cleanup, optional cache/Plug/Oban integrations, the DPoP replay fence, and release gates are present.

Test assurance runs deeper than line coverage: every library module sits at 100% line coverage, and the bar is the mutation battery — 130 sentinels, each proven to fail under its own mutation before it is trusted, so a green suite means the guards actually guard.

Compatibility

  • Elixir ~> 1.20 (developed and tested on 1.20.2)
  • Erlang/OTP 29
  • Ash >= 3.31.3 and < 4.0.0 (the whole 3.x line from the 3.31.3 floor up)
  • AshPostgres 2
  • PostgreSQL 18 for the project test harness. The SQL surface requires PostgreSQL 11+ (declarative hash/range partitioning with default partitions, SKIP LOCKED, pg_advisory_xact_lock(bigint)); versions below 18 are not exercised by this project's CI — treat them as unverified.

The floor is Ash 3.31.3, not the earlier 3.x line: EEF-CVE-2026-55736 (private action arguments settable by user input, fixed in 3.29.3), EEF-CVE-2026-70395 (predicate injection in manage_relationship belongs_to lookup disclosing secret lookup keys, fixed in 3.31.1), EEF-CVE-2026-69659 (memory exhaustion via unbounded keyset-cursor deserialization, fixed in 3.31.1), and EEF-CVE-2026-67579 (filter expression injection via a forged keyset pagination cursor — HIGH, fixed only in 3.31.3) all affect Ash below 3.31.3 — a security library must not admit a vulnerable floor. Compatibility across the range is verified per matrix cell by the standard gate battery — format, compile with warnings-as-errors, mix hex.audit (Hex security advisories), mix deps.audit, the full test suite, mix credo --strict, mix dialyzer, mix docs --warnings-as-errors, and mix hex.build — run against the 3.31.3 floor and the latest published Ash 3.x. The release battery (mutation matrix, unpacked-package check, DSL cheat-sheet freshness) runs once per push in the release-checks job against the committed lock, not per cell. .github/workflows/ci.yml is configured to re-run this matrix on every push and pull request. The pinned development runtime is Elixir 1.20.2 / Erlang/OTP 29 (.tool-versions).

Development

Start the dedicated test database and run the suite as documented in CONTRIBUTING.md, then:

mix deps.get
DATABASE_URL=ecto://postgres:postgres@127.0.0.1:18841/ash_onetime_test mix test

See CONTRIBUTING.md for the complete gate battery and usage-rules.md for non-negotiable integration boundaries. The Getting started guide covers installing the package and protecting your first action.

Handling results at the call boundary

A protected action's failure carries a typed :code that survives the Ash pipeline, and its success carries a replayed-vs-fresh signal. Both are observable after Ash.create/2 / Ash.run_action/2 returns:

case Ash.create(changeset) do
  {:ok, record} ->
    # 201 on fresh execution (replayed? == false), 200 + Idempotent-Replayed on retry (true).
    status = if AshOnetime.replayed?(record), do: 200, else: 201

  {:error, error} ->
    # The typed code reaches the caller — map it to HTTP.
    case AshOnetime.Error.code(error) do
      :nonce_already_used -> {:conflict, "nonce was already used"}
      :key_reused_with_different_request -> {:conflict, "key reused with a different request"}
      :request_in_progress -> {:conflict, "request is already processing"}
      nil -> {:internal_server_error, "unexpected error"}
    end
end

AshOnetime.replayed?/1 is tri-state: true (tracked replay), false (tracked fresh), or nil (untracked execution, primitive-return action, or unprotected — see Replay). The full code→HTTP table is in Errors.

Guides

License

MIT. See the repository LICENSE file.