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.

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 package is published on Hex as v0.6.0 and 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.

Compatibility

  • Elixir ~> 1.20 (developed and tested on 1.20.2)
  • Erlang/OTP 29
  • Ash >= 3.31.1 and < 4.0.0 (the whole 3.x line from the 3.31.1 floor up)
  • AshPostgres 2
  • PostgreSQL 18 for the project test harness

The floor is Ash 3.31.1, 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), and EEF-CVE-2026-69659 (memory exhaustion via unbounded keyset-cursor deserialization) all affect Ash below 3.31.1 — a security library must not admit a vulnerable floor. Compatibility across the range is verified by the full gate battery — including mix hex.audit — run against the 3.31.1 floor and the latest published Ash 3.x. .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.