ash_onetime

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.

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 — serialized by a pre-peer claim lock so concurrent same-key retries cannot overlap at the peer (v1.4.0, ADR-0010).

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.

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. The release gate executes the notebook source against the real PostgreSQL ledger and real cryptographic verification before local packaging and again from the published Hex package after release.

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

Status

This guide targets v1.6.0. Nonces can retain claims longer than their proof acceptance window with optional retain_for, and transaction-owned callers can read the stored deadline through Transaction.nonce_retention_deadline/2. See One-time nonces. No migration is required; Ash must be at least 3.34.6; see Upgrading.

Every protected action chooses :idempotency or :one_time_nonce and declares a nonempty scope; there is no default strategy or global scope fallback. The release battery includes a mutation battery whose rows must each fail under their named source mutation, plus direct execution of the three Livebooks against PostgreSQL. Full release history is in the CHANGELOG.

Compatibility

The security floors follow ADR 0004. The October 10, 2026 amendment raises the Ash floor to 3.34.6 for EEF-CVE-2026-101028, which is fixed in that release. The earlier floor addressed these advisories: EEF-CVE-2026-94201 affects Ash >= 3.5.1 and < 3.34.3; Ash 3.33.11 contains the EEF-CVE-2026-93477 fix but remains affected by EEF-CVE-2026-94201. Mint 1.10.2 backports the fixes for EEF-CVE-2026-91043, EEF-CVE-2026-92103, and EEF-CVE-2026-94194. Optional Igniter and Mint remain excluded from this package's runtime applications. 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.34.6 floor and the latest published Ash 3.x. Each compatibility cell unlocks the full dependency graph before resolution so the committed lock cannot hold a transitive dependency back. The release battery (mutation matrix, executable Livebooks, unpacked-package check, DSL cheat-sheet freshness) runs against the committed lock, not per cell. .github/workflows/ci.yml is configured to re-run this matrix on every push and pull request, plus a dedicated OTP 28 leg covering the other end of the supported runtime set. The pinned development runtime is Elixir 1.20.4 / Erlang/OTP 29.0.3 (.tool-versions).

Development

Point .env at a PostgreSQL 18 you run (the repository ships no database container) as documented in CONTRIBUTING.md, then:

cp .env.example .env   # set PGPORT and DATABASE_URL's port to your server's port
set -a && . ./.env && set +a
mix deps.get
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.