All notable changes to this project are documented in this file.
v1.4.2 — October 6, 2026
- Lock: update AshPostgres from 2.14.2 to 2.14.3 after the dependency-currency gate identified the new patch release. Published dependency requirements are unchanged.
- Documentation: the getting-started, errors, and recipe examples preserve unhandled typed errors for the application’s handler instead of raising on an unlisted code. Release checks exercise every documented code against all four expressions, alongside the complete, safe HTTP mapping in the Phoenix guide.
v1.4.1 — October 6, 2026
Security dependency-floor backport for the 1.4 line. There is no DSL, public API, database schema, persisted response, token wire-format, or migration change.
- Raise the Ash requirement to
>= 3.34.3 and < 4.0.0. EEF-CVE-2026-94201 affects Ash>= 3.5.1 and < 3.34.3; 3.34.3 is the first release outside that range. - Raise optional Mint to
~> 1.10 and >= 1.10.2. Mint 1.10.2 backports EEF-CVE-2026-91043, EEF-CVE-2026-92103, and EEF-CVE-2026-94194. - Repair the three Livebooks to use explicit release requirements, real Ed25519 verification, and a real independently committed PostgreSQL receipt ledger. The 1.4 notebook behavior remains the behavior of that line; no later runtime feature is backfilled.
- Contributor setup uses macOS or Linux; Windows developers use WSL2. See CONTRIBUTING.md for the supported setup and executable gates.
v1.4.0 — 2026-09-24
Breaking behavior change (the reason this is a minor, not a patch): a concurrent
same-key retry of an external-effect action that previously waited out the original and
replayed may now return :request_in_progress within the configured wait. Clients already
treat that code as wait-or-retry; consumers running check-then-act peers are the direct
beneficiaries.
Added
- Pre-peer claim lock for external effects (ADR-0010). Before any peer call, the
package takes the claim row
FOR UPDATEin the caller's open transaction with a bounded wait — the new per-protectionexternal_lock_timeout_msoption (default 2000, validated1..25_000— 5s of headroom under the committed-claim worker kill, requiresexternal_effect). The same-key retry's committed-claim worker arms the same bound. A lock timeout maps to:request_in_progress(the local path's existing concurrent-retry semantic) and is terminal. The lock is generation-safe: it binds the claim id alongside the logical key, so a reaped-and- reinserted row fails closed (:store_invariant) instead of being adopted. Motivation, observed on live PostgreSQL: without the lock, an honest in-flight retry truthfully recovered:absentand executed under the same operation key while the original's execute was still in flight — and a check-then-act peer (conforming to the contract as published at v1.3.2) double-spends in exactly that window. With the lock, two executes under one key cannot overlap at the peer; dead-caller recovery is unchanged (test/ash_onetime/external_contention_test.exs,test/ash_onetime/store/contention_test.exs). The mutation battery pins the lock twice:effect-lock-generation(id bind defeated while kept, reds on generation adoption) andpre-peer-lock-removed(the lock call removed, reds on the unlocked race — B's red-proof). Thelock_timeoutGUC is scoped to the lock: captured before, restored after — it never caps the adapter's own writes. The internal AshOnetime.Store growslock_for_effect/3and an options-takingclaim_committed/3(the 2-arity facade is unchanged).
Decisions (ADRs 0009-0011, each adversarially reviewed and judged)
- ADR-0009 (Rejected): reap-surviving derived operation key. A stateless derivation survives retention too — a post-retention retry would replay the prior generation's peer result instead of executing (a disclosed duplicate traded for an undisclosed no-op); a stateful one is correct only under an unknowable peer key-retention horizon; and the operation key is published callback API, so a derived key breaks every conforming adapter's claim join. The operator-side defense stays: business-level reconciliation before enabling the reaper on external-effect actions.
- ADR-0011 (Rejected for wall-clock): library-side callback bounding. The only
in-package bounding mechanism (
timed_callback/4's Task) runs the callback outside the caller's transaction, silently breaking transactional adapter writes. The DB-resource bound (opt-inidle_in_transaction_session_timeout) and a configurable immediate post-ambiguousrecover/3are recorded as candidates with named decision criteria.
Documentation
- External-effect contract corrected (design review 2026-09-24; decision records under
.kimosabe/specs/):- ADR 0001's claim that a duplicate side effect requires BOTH a lying adapter AND a
failing peer is corrected by a dated amendment: an honest in-flight retry alone
produced concurrent same-key executes (now prevented by the lock; the peer-side
residue — a sequential lying
:absent— keeps key idempotency a MUST, with atomic key claims a SHOULD). - The adapter execution environment is normative: both effect callbacks run inside the
caller's open PostgreSQL transaction (observed per callback, including the caller's
backend
idle in transactiononpg_stat_activity) and the package applies no timeout to them; adapters MUST bound their own peer call, and an ambiguous execute is followed by recover in the same transaction (up to two adapter timeouts of hold). - Reaping an external-effect claim documented honestly: the operation key is the claim UUID, so a post-reap retry runs under a new key and no key-based defense remains at either layer (external-effects guide, operations guide, ADR 0002 amendment).
- New
:absentvs:unknownmapping table for real HTTP peers (the 404-under-replica-lag double-spend trap) plus a worked bounded HTTP adapter in the external-effects guide. - New Recipe 4 — external effect via a transactional outbox (the outbox row as the
ExternalEffect peer, keyed by the operation key, committing with the action), including
the acceptance-receipt classifier rule, the race-absorbing
on_conflictkey claim, and the receiver-side cross-generation deduplication boundary. :request_in_progressdocumented on the external path; the previously unlisted:worker_timeoutadded to the error table.
- ADR 0001's claim that a duplicate side effect requires BOTH a lying adapter AND a
failing peer is corrected by a dated amendment: an honest in-flight retry alone
produced concurrent same-key executes (now prevented by the lock; the peer-side
residue — a sequential lying
v1.3.2 — 2026-09-20
Security
Optional Mint floor raised to
~> 1.10 and >= 1.10.1following ADR 0004:mix hex.auditflagged EEF-CVE-2026-82672 against the locked 1.10.0 — an unvalidated chunk-size line tail in Mint's HTTP/1 client enabled response smuggling against strict intermediaries on pooled connections. 1.10.1 (2026-09-19) carries the fix. Mint staysoptional: trueand runtime-excluded: the floor binds only hosts that already carry the installer closure, and no core consumer pulls it. The package check, optional-integration matrix, and dependency-security tripwires move together. OBSERVED:mix hex.auditclean after the raise.Lock:
ash3.33.4 → 3.33.8 andash_sql0.7.5 → 0.7.6, riding the dependency-currency gate (resolver-updatable drift). The published floors are unchanged —>= 3.33.4 and < 4.0.0for Ash remains the CVE-derived minimum;mix hex.auditreports no advisories against the new lock.
Added
AshOnetime.Verified.new/1— the sanctioned constructor for the opaqueVerifiedtype.tstays opaque — a Dialyzer-enforced construction convention: consumer code that builds the struct literal violates the opacity contract (contract_with_opaque, OBSERVED at 1.2.3 in a consumer sweep; suppressed there pending this release). Opacity is not the runtime anti-forgery boundary — that remains the admission path (DSL-configured verifier/minter callbacks under a bounded context, reserved-input rejection).new/1validates at mint time what every internal consumer holds aVerifiedto:keyandverifier_idnon-empty binaries (verifier_idat most 128 bytes),issued_atand the optionalexpires_atwell-formed DateTimes withexpires_atat or afterissued_at; unknown, duplicate, and missing keys are rejected, and the constructor never raises. Purely additive: no existing export changed (the export census grewnew: 1), the Elixir requirement and dependency floors are unchanged. The nonce livebook, its walkthrough mirror, and the transaction-owned admission guide now demonstrate the constructor; CI gained a test-env dialyzer leg so the consumer-shaped opacity fixture stays analyzed.
v1.3.1 — 2026-09-17
No consumer-facing changes: the published Elixir requirement (~> 1.20), every
dependency floor, and the public API are identical to v1.3.0. This release hardens
how the project itself builds and verifies — on every platform it supports.
Changed
- Toolchain identity is enforced in-repo, before anything compiles. The Elixir
support window stays
~> 1.20;config/config.exsnow refuses any Erlang/OTP build outside the supported set (28,29).System.version/0does not encode the OTP build, so a same-Elixir foreign build previously compiled silently and poisoned shared_build/PLT state. The guard never ships —config/is excluded from the package — and its set grows only in the same commit that adds a CI leg: a dedicatedotp-28CI job now proves the 28 end, while.tool-versions(Elixir 1.20.4 on OTP 29.0.3) and the primary CI runtime hold the 29 end. - macOS/Linux/Windows capability is a hard requirement, not a preference. Every
POSIX-only tool in the developer surface is gone: the dependency-currency gate
moved from a bash script to
scripts/check_deps_currency.exs(same exit-code contract; all four outcomes proven — green, resolvable drift, deliberate pin, unparsable table), everySystem.cmd("mix", ...)call site in tests and battery scripts spawns throughAshOnetime.Portable(cmd /cwrapping for Windows' .cmd shims), and.gitattributes(* text=auto eol=lf) keeps a Windows checkout byte-exact formix format --check-formatted. CI proves the weakest platform continuously: awindows-buildlane (clone, deps, format, compile, audits, credo, currency gate, hex.build) and awindows-db-batterylane that installs PostgreSQL 18 natively via chocolatey (service containers are a Linux-only feature) and runs the fullmix test. The heavier release gates (mutation battery, unpacked-package check, optional-integration matrix) remain ubuntu-proven and are named as such in the workflow. - Test database substrate:
docker compose up -dprovisions the exact PostgreSQL 18 the harness requires, with the host port taken from.env(PGPORT, default 18841 — the same port CI pins) so the dedicated instance never collides with another local listener;.env.exampledocuments thePG*variables, the fail-closedDATABASE_URL, andHEX_API_KEY. The harness still fails closed — on the dedicated shape rather than one string: ecto scheme,postgresuser, loopback host,ash_onetime_testname, any port except the shared default 5432. - Dependency currency is mechanical:
scripts/check_deps_currency.exsexits nonzero on resolver-updatable drift (mix hex.outdated"Update possible") and prints blocked packages with the requirement chains holding them. OBSERVED: dialyxir 1.4.7 → 1.4.8, ex_doc 0.40.3 → 0.40.4, and oban 2.23.1 → 2.24.1 moved to latest;mix hex.auditreported "No retired or security advisory packages found" before and after the move.
v1.3.0 — 2026-09-16
Security
- Breaking dependency change: raise the consumer floors to Ash 3.33.4,
AshPostgres
~> 2.13, AshSql 0.7.1, Igniter 0.8.4, and Mint 1.10.0, following ADR 0004. OBSERVED:mix hex.auditon September 15, 2026 reported 26 advisories against the previous lock; the OSV ranges and Hex release metadata identify these patched minimums. EEF-CVE-2026-86338 (published September 16, fixed only in Ash 3.33.4) moved the Ash floor above the initial 3.33.0 choice before release — CI's floor-cell audit caught it. The doctor, CI floor, and package check move together. Mint is optional and excluded from this package's runtime application list; existing optional integrations retain their startup boundaries. Ash 3.33 additionally requires the consumer configconfig :ash, default_string_length_count: :codepoints(or:mixed) — without it an application's own resources fail to compile; see the upgrading guide for the full consumer steps, including this one. - Lock:
ash3.32.0 → 3.33.4,ash_postgres2.11.0 → 2.13.1,ash_sql0.6.6 → 0.7.5,igniter0.8.3 → 0.8.4,mint1.9.3 → 1.10.0, withreq0.7.4,spark2.7.3, andspitfire0.4.1 riding the resolution.
Changed
- Test harness: Ash 3.33's mandatory string-length counting mode is set to the
recommended
:codepoints(config/config.exs), and the compile-fixture subprocess runners start Mix and mirror the mode (the fresh VMs read no config). The dynamic-repo transaction test's readiness receives and the test pool's checkout queue were widened to bounded patient windows — the default 100ms/6s ceilings flaked under scheduler and host load; no fail-closed semantics changed. The optional-integration matrix script now also observes the resolved Igniter and Mint versions against the security floors, gains conflict proofs (exact advised pins must fail to resolve), and joins the release-gate battery.
v1.2.3 — 2026-08-27
Changed
- Runtime application closure of the package's
.appspec —plug,oban,igniter, andstream_datanow carryruntime: false, so none of them appear in the generatedapplicationslist anymore. Before this release, every host that carried one of the optional integrations in its own dependency closure inherited it as a runtime application of ash_onetime (the extension's app spec claimed its boot and release closure), and every host inheritedstream_dataunconditionally. The opt-in contract is unchanged: the Plug module, the Oban workers, and the Igniter installer compile exactly when the host itself lists the dependency (the optional-dependency matrix passes unchanged), and the published Hex requirements still mark the three integrationsoptional: true.stream_dataremains a published requirement because Ash itself requires it transitively in every environment — Mix rejects anonly: :testrestriction alongside Ash's unrestricted entry — but it no longer contributes to this package's runtime applications. Dev-side, the dialyzer PLT now names:oban,:plug, and:igniterexplicitly, sinceruntime: falsedeps are no longer seeded into the PLT automatically while the guarded integration modules still analyze against them.
v1.2.2 — 2026-08-25
Added
AshOnetime.Transaction.claim_id/1— the one sanctioned accessor for a fresh admission's claim UUID (Admissionstays opaque by contract). The UUID is the durable address of the claim inside the store: persisting it lets a host correlate its own invocation records with the claim and hand the unchanged UUID to an external execute/recover peer. A pure read (no process/transaction/store interaction); test-pinned against the stored claim row, and the architecture export inventory is updated.
v1.2.1 — 2026-08-22
Changed
- README: a mutation-battery badge (130 sentinels, each proven RED under its own mutation before it is trusted) and an explicit test-assurance statement — every library module at 100% line coverage, with the mutation battery as the assurance bar. Documentation only; no code, DSL, or contract change.
v1.2.0 — 2026-08-22
Additive operations-preflight and hardening release. No breaking change, no DSL/contract change, no migration.
Added
mix ash_onetime.doctor --live— opt-in schema-currency preflight: connects to the database read-only and fails when the installed schema is not current for the running package (missinglogical_partitioncolumns, response-payloads table or its_defaultpartition, cleanup/reap functions by exact arity, or delete-guard triggers) — the upgrade-without-migrating failure mode, caught at preflight instead of at the first admission after deploy.- Backup/restore runbook (
documentation/operations.md) — therestore-from-backupprocedure: per-strategy exposure of a rewound admission store (bounded nonce windows, fresh re-execution of post-restore-point idempotent effects), reconciliation scoping, and the post-restore partition roll-forward. - PostgreSQL floor documentation (README, operations.md) — the SQL surface requires
PostgreSQL 11+ (declarative hash/range partitioning with default partitions,
SKIP LOCKED,pg_advisory_xact_lock(bigint)); CI exercises PostgreSQL 18 and versions below it are unverified.
Fixed
- Store digest comparisons unified to constant-time — the last two plain-
==digest comparisons in the store (the stored-payload digest check on replay load and the caller-digest check at completion) now use a size-guarded:crypto.hash_equalshelper, one convention for every digest comparison site in the library. No behavior change: neither site compares attacker-secret material, and both operands were already 32-byte-constrained. - Test suite: the
RollContentionTestfull-suite flake (~10%/run) is eliminated at the mechanism —Sandbox.start_owner!/2's asynchronous teardown could leave the caller's ownership allowance in place long enough for a back-to-back second owner in the same process to crash with{:already, :allowed}. Same-process sequential owner sites now use an in-processcheckout/checkinhelper (AshOnetime.Test.RealConnection), with a 100-cycle tripwire pinning the class; the timing-dependent telemetry-flush and ETS-TTL sleeps are deterministic. No library code changed. - Test suite: direct unit coverage for the admission decision functions — every
resolve/5arm (allexecution_class/2mappings, transaction-mode/locator/identity/ claim-state invariant rejects,:completereplay and fingerprint mismatch,:processingper mode,:collisionmatch/malformed, the exact-shape:execute_untrackedescape with a fail-closed test per guard dimension, thestore_errorfallthrough) plus the request/claim sanitizers, exposed as@doc falsetest seams and pinned by the architecture census. The dev-build seam-absence tripwire was also de-vacuated: its subprocess probe now loads the module beforefunction_exported?/3(an unloaded module probes false unconditionally).
v1.1.0 — 2026-08-20
Additive transaction-owned admission for applications whose effect boundary is an existing Ecto transaction rather than an Ash action.
Added
AshOnetime.Transaction— publicidempotency/2,nonce/2, andcomplete/2operations that require an existing PostgreSQLREAD COMMITTEDtransaction and never start or commit one. Idempotency replays exact response bytes; nonce collisions reject; typed errors and all claims remain bound to the caller transaction.- Logical partitions — a bounded UTF-8 authority component included in every claim, collision identity, response payload locator, cleanup path, and cache key. Identical operation/scope/key triples in distinct partitions are independent without importing a host application's tenant types.
mix ash_onetime.gen.logical_partitions— reversible upgrade migration for 1.0 installs. Existing rows backfill toglobal; rollback refuses while non-global authority exists.
Compatibility
- Existing Ash resource protections continue in the
globallogical partition and retain their existing public behavior. - Fresh generated installs include logical partitions. Existing installs run the new upgrade task once before admitting non-global transaction-owned work.
Documentation
- Updated Getting Started, Upgrading, README status, and every runnable Livebook to select the
1.1package line. All three notebooks now link the transaction-owned admission guide, and a documentation-currency tripwire keeps their install requirements aligned withmix.exs.
v1.0.0 — 2026-08-19
The 1.0 stability release: the surface contract is frozen (published in full in upgrading.md — docs are the contract; breaking changes to the documented surface now happen only at 2.0, with reserved break-rights for security, behavior-correcting bug fixes, and new compiler/linter warnings). Pre-1.0 (0.x) releases were explicitly non-binding; 1.0.0 is the compatibility baseline. The persisted response format (ADR-0007) and the token wire format (ADR-0008, window-bounded) are frozen compatibility surfaces.
Fixed
- Ash requirement pin validation (release tooling):
ASH_ONETIME_ASH_VERSIONis now compile-time validated — it must parse as a version AND lie within the published requirement range; pre-release and build suffixes are rejected; a release exported with the variable unset is asserted by the package gate to carry the floating requirement in the archive metadata. - Telemetry contract (
AshOnetime.Telemetry): the defaultattach/0router now covers the full closed surface — all 13 events including the:uncertain_exceptiondiagnosis event (previously silently dropped); the moduledoc was rewritten to the router reality (the phantommetrics/0reference is gone); the diagnosis event's closed atoms-only metadata shape (%{strategy, phase, exception}) is validated before emission. - Admission test seam: the Process-dictionary test-store redirect in the internal
Admission module is no longer compiled under
Mix.env() == :test— it is gated onApplication.compile_env(:ash_onetime, :allow_admission_override, false), default-off in every build environment (mirroring the token verify-clock gate). A consumer building deps withMIX_ENV=testno longer ships the redirect; calling the seam without the explicit opt-in now raises a named error.
Documentation
- ADR-0008 records the token wire-format compatibility rule (window-bounded: a 1.x reader verifies tokens minted by any earlier 1.x writer while inside their acceptance window; no permanent freeze) alongside the persisted response-format rules (ADR-0007).
- The operations runbooks' diagnostics are all executable (each snippet verified against a live database): the vacuous default-partition count became a stranded-row count, the non-runnable IEx backlog snippet was replaced by its SQL path, and the pool diagnostic is a checkout-queue watch on the repo query event.
- Worker-backoff records corrected (ADR-0005 and every prose carrier): Oban's default is ~36–40 s at three attempts, not hours; the custom backoff's rationale is re-derived honestly (spaced DDL-class retries, wide jitter, 120 s cap).
- ADR batch: ADR-0001 (external-recovery
:absenttrust boundary), ADR-0003 (live committed-claim failure taxonomy), ADR-0004 (forward-compatible floor posture) amended; ADR-0006 (point-events observability) and ADR-0007 (persisted response-format compatibility for 1.x) added. - SECURITY.md publishes the support and disclosure policy (supported-versions table, 7-day ack / weekly updates / coordinated disclosure); CI battery claims name the per-cell battery precisely; the ROADMAP is reconciled (portable status column, all eleven rows verified).
v0.7.0 — 2026-08-18
Security release: raises the Ash floor to 3.31.3 after EEF-CVE-2026-67579. Breaking dependency change — consumers on Ash 3.29.3–3.31.2 must bump Ash to ≥ 3.31.3 (the library is pre-1.0, so the minor version carries the break per the documented convention).
- Ash floor raised to 3.31.3 (from 3.31.1). EEF-CVE-2026-67579 (filter expression
injection via a forged keyset pagination cursor — HIGH, CVSS 7.5, unauthenticated, no
application-side workaround) affects Ash below 3.31.3 and is fixed only in 3.31.3; Hex
additionally retired 3.31.1. The published
>= 3.31.1requirement let consumers resolve a retired or advised Ash, andmix hex.audit— a required gate — failed on the 3.31.2 lock. The floor was derived from the complete OSV advisory inventory for hexash(11 advisories; maximum fixed-version 3.31.3; 3.31.3 not retired), not from the absence of a Hex label. See ADR-0004 (Security-driven Ash floor, amended). The CI matrix moves from[3.31.1, latest]to[3.31.3, latest]— transiently both cells resolve to 3.31.3 until the next Ash release. - Doctor floor guard directly tested: the doctor's Ash-floor verdict is extracted as a
pure
floor_status/1seam with reject tests for the retired 3.31.1 and the advised 3.31.2 (red-proven before the floor bump), plus a mutation-battery entry (doctor-ash-floor) that re-proves the guard red-capable on every release run. The Oban queue advisory verdict is likewise a pure seam (oban_queue_status/2) with both arms — Oban loaded and not loaded — covered. Doctor output is unchanged; the three strict-Credo findings that red-barred the latest-Ash CI cell are fixed. - Lock:
ash3.31.2 → 3.31.3 (withecto3.14.2 andreactor1.0.6 riding the resolution).
v0.6.0 — 2026-08-10
Minor bump: two additive enhancements (new Mix task + Phoenix guide) plus internal perf/cleanliness gap closures. No breaking change, no consumer upgrade action.
mix ash_onetime.doctor— a read-only upgrade-preflight that checks the Ash security floor (fatal if below 3.31.1), Oban queue configuration (advisory — warns on a missing:ash_onetime_partitionsqueue that strands the retention-safety path), and prefix validity. Mirrors the runtime Mix task family; run after install and on each upgrade.- Phoenix integration guide (
documentation/phoenix.md) — a runnable Phoenix controller recipe wiring the Plug, thereplayed?/1→ 200/201 +Idempotent-Replayedheader signal, and the error-code → HTTP-status mapping into a complete controller pattern. Closes the gap where every existing recipe stopped at plain Elixir tuples. - Cleanup delete-guard probe partition-scoped — the
:complete-branch probe in the install migration now constrainspartition_date = OLD.response_partition(the H1 read-path tail), turning an O(partitions) scan into a point lookup on every completed-claim delete. Same property shift as H1 (partition pruning vs cross-partition detection are mutually exclusive); the write path remains the authoritative guard. - Dead
trusted_contextparameter removed from the internal scope/key resolution path (admission.exprepare_resolved/scope_hash/scope_component/key_hash/key_component). No behavior change — the parameter was threaded but never read after M1's collapse. - L4 monitoring blind spot closed — a stuck-
availablePartitionWorker query added tooperations.md(the discard-alert watcheddiscardedonly; an unconfigured queue leaves jobs inavailableforever with no signal).
v0.5.1 — 2026-08-10
Patch: test-only hardening. No consumer-visible change; no upgrade action required.
- Invariant tripwire hardening (test-only): the five test-coverage residuals the v0.5.0
closeout accepted as documented gaps are closed. Each tripwire now observes the production
function it claims to pin, and every one is proven RED-capable by mutation (mutate the
production line, watch the test fail). The Oban worker error-tuple tests force a REAL
:lock_timeoutthrough database lock contention rather than reading source text. No production behavior change. - Internal test seams: three pure helpers are now
@doc falsepublic callables so their contracts can be pinned directly —Store.Postgres.roll_advisory_key/1,Cache.key/1,Resource.Verifier.verify_required_shape/2. Undocumented, not a supported API. - Lint:
mix credo --strictnow passes clean (implicit-tryandcond→ifrefactors in the ETS cache adapter and the telemetry default handler — behavior-identical). - Dependency: bumped
ashfrom the retired3.31.1to3.31.2(within the existing>= 3.31.1 and < 4.0.0pin). 3.31.1 was retired upstream for a breaking bug.
v0.5.0 — 2026-08-10
Minor bump: sixteen findings (M1–M5, L1–L11) from the v0.4.0 independent code review. Four
are CONSUMER-VISIBLE (read documentation/upgrading.md before upgrading); the rest are
internal hardening.
Independent code-review fixes (M1–M5, L1–L11): sixteen findings from the v0.4.0 independent code review, landed in four surface-cohesive clusters:
- M1 — bounded callback context: verifier/mint/scope callbacks now receive exactly
%{resource:, action:}(the prior code ran a deadMap.take(trusted_context, [:keys, :now])and threaded caller actor/tenant that no callback read; both dead paths removed). Least-privilege contract pinned by an admission test. - M2 — reserved-input compile check now matches the runtime guard (CONSUMER-VISIBLE): a protected
resource declaring a reserved-named attribute (
:key/:issued_at/...) with no accept now fails to compile (previously caught only at runtime byreject_reserved/1). Rename any such attribute (e.g.:idempotency_key). - M3 —
@reservedsingle-source: the reserved-input list is nowAshOnetime.reserved_verification_inputs/0, shared by the transformer (compile-time) andAdmission.reject_reserved/1(runtime); the two copies can no longer drift. - M4 — verifier defense-in-depth: the Spark verifier now re-asserts strategy in
[:idempotency, :one_time_nonce], non-nil scope, non-nil key (mirroring the transformer), catching a transformer regression before runtime. - M5 — clock override gate:
@allow_test_clock_override(Mix.env() == :test, deployment-fragile) replaced byApplication.compile_env(:ash_onetime, :allow_clock_override, false)— the verify-side:clockoverride is off by default in every build; the test suite opts in viaconfig/test.exs. - L1 — constant-time comparator consolidated onto
:crypto.hash_equals/2(the hand-rolled XOR-reduce is gone); theverify/3rescue is documented as load-bearing for wrong-length signatures (hash_equals/2raises on unequal length — verified empirically). - L2 — reap floor enforced at the guard:
reap/3rejects sub-86_400 callers with:invalid_request(no DB round-trip) instead of the migration's22023misclassified as:store_invariant. - L3 — per-tenant advisory lock: the partition-roll lock key is derived per-prefix, so
distinct tenants roll concurrently (within-tenant serialization preserved). Cannot
reintroduce a
CREATE PARTITION OFrace (per-schema partitions, distinct OIDs). - L4 — dedicated
:ash_onetime_partitionsOban queue (CONSUMER-VISIBLE):PartitionWorkermoves off the shared cleanup queue so forward partition creation (retention-safety) does not compete with routine cleanup under saturation. Operators must configure the queue.documentation/operations.mdupdated. - L5 — worker error tuples carry the inner reason (CONSUMER-VISIBLE):
{:error, :tag}→{:error, {:tag, reason}}so the distinguishable store cause survives Oban exhaustion. Consumers pattern-matching the old bare atom must update to the 2-tuple inner shape. - L6 — store-transaction exception telemetry:
committed_claim_transactionrescue emits[:ash_onetime, :uncertain_exception]with the exception class before collapsing to:dispatched_unknown. Telemetry-only posture preserved (no Logger). - L7 — cache key length-prefix: defense-in-depth framing for the cache key (today's fixed-32-byte components have no ambiguity; the framing future-proofs against a variable-length change).
- L8 — roll_forward OID scoping: the
_defaultpartition OID subquery is now namespace-scoped (pg_namespace), matching the schema-scoped existence checks. - L9 — change/generic_action dedup:
dispatch_reservation/trusted_context/unavailable_errorhoisted toAdmission(the near-verbatim duplicates removed). - L10 — ensure_callbacks cycle diagnostic:
Code.ensure_compilednow distinguishes the:unavailable/:module_unavailablecycle case with clear ordering guidance. - L11 —
prepare/3spec dead arm removed (theResult.t()arm never matched); dialyzer confirmed the downstreamstore_error/1was dead code and it was removed.
- M1 — bounded callback context: verifier/mint/scope callbacks now receive exactly
Replay read prunes to the claim's payload partition (H1): the idempotency replay read path (
load_payload/2) now constrains itsash_onetime_response_payloadsquery bypartition_dateas well asclaim_id, turning a replay of a completed claim into a primary-key point lookup on the single monthly child partition instead of a scan of every monthly partition. The cost of a replay no longer grows with partition count (retention age). No behavior change on the authoritative path — the returned payload and the digest/partition-mismatch failure arms are unchanged. One incidental property changes: the read no longer detects a stray payload row in a different partition from the claim's authoritative one (this is necessarily dropped by partition pruning); cross-partition cardinality remains enforced at write time (update_complete) and re-asserted by the cleanup delete guard. ADR-0001 gains a read-path performance subsection. The production predicate is pinned by a registered mutation test; an EXPLAIN-based mechanism proof documents the pruning directly.
v0.4.0 — 2026-08-09
Hardening, ops-readiness, and enhancement release. No breaking contract change for existing
consumers — the minor bump carries new public observability surfaces (the telemetry default
attach helper, the :worker_timeout result class, the ETS cache reference adapter) and the
span-events-out-of-scope decision. Existing consumers are unchanged; all new surfaces are
opt-in.
- External-recovery adversarial-absence proof (H10): a test proving the re-execution
invariant against a lying-
:absentadapter, plus a normative section indocumentation/external-effects.mdstating the adapter MUST prove absence and the peer MUST enforce idempotency by operation key. No runtime guard — the trust is inherent to the design (ADR-0001). The library's:absenttrust is safe against a correct peer. - Worker timeout distinguished from disconnect (H11): the committed-claim worker's 30s
timeout now surfaces as a distinct
:worker_timeoutresult_class on[:ash_onetime, :store_uncertainty], separate from:disconnectedand:unknown. All three fail closed; the distinction is operational triage (pool/lock contention vs network partition). - Oban worker backoff + discard alert (H20, ADR-0005): the three maintenance workers
(Cleanup, Partition, Reap) declare a bounded, jittered
backoff/1(30–120 s) instead of the default exponential, so transient failures retry within the retention window. A documented discard-alert SQL names the operational signal for a stranded partition roll. - Telemetry default attach handler (H21):
AshOnetime.Telemetry.attach/0— an opt-in helper routing the closed event surface into a downstream:metricstream for a consumer's own aggregator. Notelemetry_metricsdependency; the value-free invariant is preserved. - Telemetry span structure (H22): documented that the library emits point events only
(never span events), with the reason (
:telemetry.span/3cannot preserve the value-free invariant — it force-injectstelemetry_span_contextand fires:exceptioninside the span before any caller rescue) and a recommended consumer-applied:telemetry.span/3wrapper. - Operations runbook (H23): three named procedures (backlog-stuck, partition-discard-detected, pool-saturated) with exact SQL/telemetry queries.
- ETS cache reference adapter (H30):
AshOnetime.Cache.Ets— bounded, TTL-aware, supervised, no third-party dependency. Makes the cache-degradation path reachable without a Redis dependency. - Admission unit tests (H31), key_source/claim property tests (H32): direct test coverage for the pure decision functions and the security-boundary invariants (the suite grows from 500 to 553 tests).
- Runtime security-surface docs (H33):
@docontoken.ex,key_source.ex,fingerprint.ex,telemetry.expublic functions. - CI-matrix-asserted compatibility documented (H34): CONTRIBUTING names the CI matrix as the guard against transitive semantic drift (not the dep bounds).
v0.3.0 — 2026-08-09
Security release: raises the Ash floor to the CVE-patched 3.31.1 and makes the architecture census robust to dependency framework-injection drift. Breaking dependency change — consumers on Ash 3.29.3–3.31.0 must bump Ash to ≥ 3.31.1 (the library is pre-1.0, so the minor version carries the break per the documented convention).
- Ash floor raised to 3.31.1 (from 3.29.3). EEF-CVE-2026-70395 (predicate injection in
manage_relationshipbelongs_to lookup disclosing secret lookup keys) and EEF-CVE-2026-69659 (memory exhaustion via unbounded keyset-cursor deserialization) affect Ash below 3.31.1; both are patched in 3.31.1.mix hex.audit— a required gate — now fails on the 3.29.3 lock. Consumers pinned to Ash 3.29.3–3.31.0 must bump Ash to ≥ 3.31.1. See ADR-0004 (Security-driven Ash floor). The CI matrix narrows from[3.29.3, 3.30.1, latest]to[3.31.1, latest]. - Export census version-tolerance (test harness): the documented public-export census no
longer breaks on Splode/Ash framework-injection drift. It derives each module's
project-authored exports from its own source AST, so framework-injected functions (e.g.
Splode 0.3.2's
keyword_list_options?/0) are auto-classified rather than hand-maintained, and the census guards exactly the project-owned public surface. This fixed a CI failure present since v0.1.0 across all Ash-matrix cells. Also fixes a seed-dependent flake in the ARCH-1 Splode-membership assertion (force-loads the module before the check).
v0.2.0 — 2026-08-09
Feature release: the DPoP replay fence, forward response-partition maintenance (SEC-5/6), and notebook-per-concern docs. New user-facing capabilities (minor bump); no contract break since v0.1.1 — existing consumers are unchanged, all new surfaces are opt-in.
- DPoP replay fence (admission): add
commit: :independent(default:with_action) on:one_time_nonceprotections. When set, the nonce claim commits in its own transaction before the action body runs (via the existingclaim_committedworker), so a body failure cannot make the proof reusable — RFC 9449 §11.1 request-attempt scope. A reused proof within the acceptance window is rejected with:nonce_already_usedvia the existing collision path. Opt-in: existing nonce consumers are byte-for-byte unchanged. Rejected (compile error) on:idempotency. No new table, migration, error code, or telemetry event. See ADR-0003 (Independent-commit nonce). Operational note: theclaim_committedworker uses a +1 connection checkout per in-flight protected request and a 30s timeout (fails closed); size the pool accordingly. - SEC-5 (data-layer): add forward monthly
response_payloadspartition creation (Store.roll_partitions/2,mix ash_onetime.roll_partitions,AshOnetime.Oban.PartitionWorker) plus a one-time forward migration (mix ash_onetime.gen.roll_forward). The install migration generates a fixed 13-month window; once retention exceeds it, payloads routed to_defaultand were never dropped (the drop path excludes_default), silently defeating bounded retention (ADR-0001:65 "reuse after retention is a new execution"). The roll keeps the window ahead of retention; the forward migration back-fills elapsed partitions, adds the index, and drains past-retention payloads stranded in_defaultvia a claim-scoped delete. The roll is idempotent and concurrency-safe (advisory-locked, boundedlock_timeout). Existing installs run the forward migration once. - SEC-6 (data-layer): add an index on
ash_onetime_idempotency_claims(response_partition). Cleanup's partition-empty check was a full scan per partition per cycle. - Telemetry: add
:partitions_createdto the[:ash_onetime, :cleanup]event's result-class enum (closed-schema extension, pinned by a mutation fixture). See Telemetry.
v0.1.1 — 2026-08-08
Adoption polish: a runnable Livebook walkthrough, richer Igniter installer, and adoption docs. No contract change; safe minor bump from v0.1.0.
- Add runnable Livebook notebooks — one per
concern (idempotency, one-time nonces, external recovery) — covering fresh execution, replay,
fingerprint conflict, nonce spend/reuse, and the external-effect protocol end-to-end against a
real PostgreSQL. Each notebook's code is regression-pinned by
test/ash_onetime/livebook_walkthrough_test.exs. - Extend the Igniter installer with a repeatable
--resource MyApp.MyResourceflag that wiresAshOnetime.Resourceinto a resource and scaffolds a starteronetimeblock. Non-resource targets and missing modules are rejected loudly instead of silently no-op'ing. - Add adoption docs: Recipes, Telemetry, Upgrading, and FAQ. Add a README "When to use this vs. hand-rolled idempotency" section and a "Try it" livebook pointer.
- Reorder Getting started to lead with the consumer quickstart (install → protect → handle the result); move the test-DB harness to CONTRIBUTING. Fix the stale "not published yet" line (the package is live on Hex).
v0.1.0 — 2026-08-08
- Breaking (DSL): collapse the dual
limitssurface into a singleprotect-level vocabulary. Response-size limits can no longer be declared on theresponseentity (response ..., limits: [...]); declare them onprotectinstead. Theprotect limits:option now accepts the full 11-key union vocabulary:max_key_bytes,max_token_bytes,max_scope_components,max_fingerprint_bytes,verifier_timeout_ms,max_cache_entry_bytes(key/verification/cache paths), andmax_response_bytes,max_response_depth,max_response_nodes,max_response_entries,max_response_scalar_bytes(response payload). All keys are validated at compile time. To migrate, move anyresponse ..., limits: [max_response_*: ...]keys ontoprotect ..., limits: [...]. - Make
AshOnetime.Errora Splode error of class:invalidso Ash recognizes it and preserves the typed:codethrough the action pipeline. Before, a protected-action failure was wrapped asAsh.Error.Unknown.UnknownErrorand the code (e.g.:nonce_already_used,:key_reused_with_different_request) was lost before it could reach the caller. AddAshOnetime.Error.code/1to recover the code from a leaf or class wrapper. Seedocumentation/errors.mdfor the code→HTTP table. - Add a caller-visible replayed-vs-fresh signal. After
Ash.create/2/Ash.run_action/2returns,AshOnetime.replayed?/1reports whether the result was a stored replay (true), a fresh execution (false), or carries no signal (nil— untracked execution, primitive-return action, or unprotected). The signal rides__metadata__[:ash_onetime]for tracked admission classes;:untrackedis deliberately not stamped to preserve untracked transparency. Seedocumentation/replay.md. - Broaden the Ash dependency requirement from
~> 3.29.0(only 3.29.x) to>= 3.29.3 and < 4.0.0, so the package installs across the whole Ash 3.x line. The floor is 3.29.3, not 3.29.0: EEF-CVE-2026-55736 (private action arguments settable by user input) affects Ash 3.29.0–3.29.2 and is fixed in 3.29.3. - Add a CI compatibility matrix (
.github/workflows/ci.yml), configured to run the full gate battery — includingmix hex.audit— against the 3.29.3 floor, each intermediate minor, and a floatinglatestAsh 3.x cell on every push and pull request once the repository is pushed to a GitHub remote. - Establish the standalone Mix package, PostgreSQL 18 test harness, package boundary checks, accepted architecture decision, and project documentation.
- Add the per-action Spark resource DSL, normalized introspection, precompile rejection boundary, fail-closed runtime stubs, compile-fixture battery, and mutation proofs.
- Add PostgreSQL-authoritative idempotency and one-time nonce admission with exact operation, scope, key, fingerprint, transaction, and failure-direction invariants.
- Add transactional CRUD and generic-action execution, classified typed response persistence,
digest-bound replay, and replay-safe lifecycle enforcement. The response contract digest binds
the codec options, so stored bytes cannot be reinterpreted under changed options; replayed
results carry the same
selected/tenantmetadata as a first execution. - Enforce the configurable
max_scope_components,max_fingerprint_bytes, andmax_response_byteslimit overrides at their declared values, not only their package ceilings. - Add bounded canonical encoding, HMAC-SHA-256 and Ed25519 signing, trusted verification facts, self-identifying tokens, and inclusive nonce windows.
- Add committed external-effect recovery points, stable peer operation keys, conservative ambiguous-outcome handling, and crash recovery.
- Add unpartitioned and operation-hash-partitioned claim layouts, date-partitioned response payloads, strict bounded cleanup, deletion guards, the prune task, and optional Oban cleanup.
- Add PostgreSQL-gated cache degradation, optional Plug header extraction, closed value-free telemetry, deterministic Igniter installation, and migration generation.
- Add system, architecture, mutation, documentation, exact Hex archive, unpacked consumer, and dependency audit release gates, run on the pinned Elixir 1.20.2 / Erlang/OTP 29 runtime.