Repository navigation
Releases: baselabs/ash_hooks
Release list
ash_hooks 2.0.4
Fixed
- Generated Oban workers no longer report Dialyzer pattern-match warnings for
unsaved uniqueness conflicts. Oban's job type declares a persisted ID but its
Basic engine can return an unsaved conflict; admission reads the ID through a
private map helper. Retry limits, not-persisted refusals, identity checks, and
runnable-state checks retain their behavior. - The type gate now compiles the same generated worker used by the PostgreSQL
suite. Real Oban.Basic transaction tests force an unsaved conflict, verify
retry after the winning transaction commits, and verify refusal after all
twenty retries. - Dependency currency: Ash 3.34.4 → 3.34.6 (resolves EEF-CVE-2026-101028 in the
locked graph) and AshPostgres 2.14.3 → 2.14.5.
AshHooks 2.0.1
2.0.1 — October 6, 2026
Delivery rows now retain declaration and queue ownership, fence each send attempt,
and recover unfinished work after queue exhaustion or an interrupted endpoint disable.
HubSpot retries use a stable batch identity, and the default transport bounds the
complete HTTP operation.
Existing installations require the ordered schema and identity migration in
UPGRADING.md before running 2.0 workers or ingress.
Changed
- Outbound rows persist declaration ownership and queue route. A declaration
cannot recover another declaration's rows. Named callbacks have stable routes;
anonymous callbacks can supplyenqueue_keyfor durable recovery. - Send and recovery admission use separate UUID tokens and expiring leases.
Every result is fenced to the live attempt; retry ceilings are checked before
sending. The complete ledger primary key is used throughout. - A 410 response records
:disable_pendingbefore disabling the matching endpoint
configuration. Recovery completes that obligation without another HTTP request. - Oban uniqueness covers runnable jobs and the durable route/tenant identity.
Completed or discarded jobs do not strand recoverable rows; an unpersisted
conflict is an admission error. - HubSpot's default dedup identity canonicalizes the complete batch, ignoring only
each outer event'sattemptNumber. The optional providerevent_identity/1
callback follows an explicit declaration extractor in precedence. Retained
legacy rows are adopted explicitly, preserving payloads and an audit map. - Ash's supported floor is 3.34.3, including its security correction and the tenant
inverse API used by workers. The lock resolves Ash 3.34.4, AshPostgres 2.14.2,
and Spark 2.7.6.
Fixed
- Minimal production consumers start
:public_keyand:sslthrough the package's
application dependencies; optional packages are unnecessary for default HTTPS. - Direct delivery calls apply the documented retry defaults for omitted or
nil
settings, including the attempt ceiling and boundedRetry-After, without
requiring an Oban worker. - Required keyset pagination, bounded retention deletion, custom ledger keys,
renamed UUID endpoint/subscription keys, and aliased worker HTTP-option resolvers. - Endpoint status values are cast and constrained at compile time; empty enabled
sets, overlapping states, and primary-key mappings are rejected. - IDs on direct event structs and HTTP headers reject control bytes and malformed UTF-8.
Telemetry reasons use a fixed vocabulary, preventing arbitrary tokens from escaping. - SSRF classification covers special-purpose address ranges; IPv6 Host values are
bracketed. Bounded HTTP follows interim responses to the final response, stops
at its body cap, and uses one deadline across resolution and transport. - HubSpot's finite event taxonomy includes generic object events.
Documentation and delivery
- Current install/version pins, terminal inbound acknowledgments, generated DSL
references, migration instructions, and an executable 2.0.1 Livebook. - Candidate archive inspection and notebook execution precede publication; the
normal notebook then executes against the exact published version. - Documentation changes trigger Linux CI. The minimum-consumer leg pins Ash
3.34.3; package, PostgreSQL, coverage, and Livebook checks feedall-checks-pass. - Keep notebook protocols unconsolidated so dynamically defined resources use their custom display without consolidation warnings.
1.3.0
Historical release record for the existing v1.3.0 tag, dated October 5, 2026 (commit a9f98095d632a28aa4861a5fc4154a24451367c0). This GitHub record was reconciled on October 6, 2026; the original tag and package are unchanged. The notes below include the subsequent correction to the delivery guarantee. See current releases for upgrade guidance.
1.3.0 — October 5, 2026
Additive options for domain naming and lifecycle, a compile
fix for generated (non-writable) primary keys, typed subscription
matching, loud documentation of the policy/id/adapter obligations, and a
Linux CI leg exercising an AshPostgres uuid_v7 consumer shape.
Added (additive options — non-breaking per ADR-0010)
payload_attributeon theoutbound_deliverysection. The exact
payload-bytes column's name is configurable (default:payload):
rename it when the consumer's domain reservespayloadfor its own
sole payload store. The injected column, the:dispatchaccept list,
and every signing/sending read follow the configured name; a name
colliding with another injected field fails closed at compile.prune_action :noneon theoutbound_deliverysection. Opt out
of the retentiondestroy :pruneaction's injection for append-only
audit ledgers (no destroy action exists on the resource at all);
AshHooks.Delivery.prune/2then fails loud with a named error —
deletion is the consumer's own surface. Default:destroyis
unchanged.status_attribute/enabled_values/disabled_valueon the new
endpointsection. Map the durable enable/disable onto the
consumer's OWN switch (e.g. anactiveboolean):statusis then
not injected — one switch, so the consumer's off switch and the
package's can never silently disagree. The dispatcher's skip and the
send path's dead-letter both route throughAshHooks.Endpoint.enabled?/1;
the 410 auto-disable flips the mapped attribute. The mapped attribute
must be consumer-declared (fail-closed at compile).- A Linux CI leg with a Postgres service exercising the transformers
against an AshPostgres, uuid_v7-keyed, sole-store-payload consumer
shape (postgresjob; the suite is env-gated byASH_HOOKS_POSTGRES=1
and excluded from ordinary dev runs).ash_postgresis a dev/test-only
dependency — never ships, never constrains consumers.
Fixed
:dispatch/:ingestno longer accept:idon resources whose
primary key is a non-writable:id(e.g.uuid_v7_primary_key):
Ash'sValidateAcceptraised at compile on exactly that consumer
shape, making the extensions unadoptable without replacing the PK.
The accept lists carry:idonly when the PK is a writable:id
(the package-injected default — byte-identical classification); the
transformer's decision is persisted and the runtimes read it back, so
the compiled action and the classification path can never disagree
(composite and non-:idprimary keys included). For the non-writable
shape, created/duplicate classification pre-reads the unique identity
instead: exact in every sequential case (provider redeliveries,
producer re-fires) and wherever the primary read can see the existing
row (a base-filtered read that hides it classifies:created). Under
a misclassification the storage upsert preserves one row.
A double send stays impossible through theCorrection in 2.0.0: the 1.3 outboundmark_sendingCAS and
inbound claim fence.
state transition did not fence every late result or expired owner.
Version 2.0 adds attempt tokens, leases, and separate enqueue claims;
network sends remain at-least-once and require receiver deduplication.
In 1.3 the enqueue seam was the configured
enqueuer's own fence — the default Oban enqueuer's
endpoint_id+event_uuid job uniqueness, or the consumer's own dedup
for a customenqueue:. ADR-0010 note: the injected action accept
lists are covered surface — this is a behavior correction (the
previous accept list required a writable PK that consumers with
generated PKs cannot provide), not a removal; no working consumer can
regress (the shape did not compile).- Covered-surface tightening alongside the H4 mapping: the send path
dead-letters an endpoint whose status is anything other than
:enabled(previously only an exact:disableddead-lettered, so a
consumer-redeclaredstatuswith extra values delivered). The
dispatcher's skip and the send path now route through the ONE
AshHooks.Endpoint.enabled?/1check — fail-closed by design. AshHooks.Subscription.matches?/2normalizesevent_typesentries
withto_string/1. An atom-typed register ({:array, :atom}— a
closed enum) previously matched NOTHING: zero deliveries, no error.
Atom and string entries (and the"*"/:"*"wildcard in either
representation) now match identically. Consumers redeclaring
event_typeskeep their own constraints — the injected["*"]
default is not forced on them.
Changed
- CI is Linux-only; the macOS and Windows build legs are removed.
Tri-OS stays a developer-portability requirement (clone, set up, and
test must work on every OS) — it was never meant to be a CI topology,
and the README no longer claims dedicated per-OS legs. Also hygiene:
the workflow's pinned GitHub Actions (checkout, cache, upload-artifact)
moved off the Node 20 runtime to their current releases. No library
code changed. - The DSL cheat-sheet drift gate is no longer vacuous. The package
leg'smix spark.cheat_sheets --checkran without an extension list
and checked nothing; it now passes the five extensions explicitly, so
a staledocumentation/dsls/actually fails the build (the sheets in
this release are regenerated under that invocation). - Dev/test
mintbumped 1.10.1 → 1.11.0 (EEF-CVE-2026-94194 /
-91043 / -92103, via the dev-only igniter→req→finch tree; never ships
in the package).ash_postgres2.13.1 is added dev/test-only for the
Postgres leg above.
Docs
- Policy obligation at the extension site (
OutboundDelivery,
Endpointmoduledocs): the injected write actions are named and the
consumer-owned policy obligation is stated loudly — the inbound half's
"you write your own" posture, mirrored outbound. The seven delivery
actions plus the endpoint's:disable. - Deterministic
Event.idguidance (Event,Dispatcher
moduledocs): derive the id from the artifact's stable id — a generated
id turns every producer re-fire into a duplicate POST per sweep. - The adapter-author contract (
AshHooks.Httpmoduledoc): headers
must be a LIST of{name, value}tuples (a map silently loses
Retry-After), and the resolve-once/connect-pinned obligation that
closes the DNS-rebinding TOCTOU is the adapter's, with the driver's
send-time check as the residual. retry_after_cap_secondssemantics (Workermoduledoc): the
86,400 default is receiver-held-state budget — up to 24 hours per
attempt on one header; lower it for an exhaust-fast posture.- The 410 auto-disable posture (
Deliverymoduledoc): an
unattributed system bulk write by design (no actor in a versioned
register), no built-in tenant-facing signal — a receiver that answers
410 once goes dark silently unless the app wires the
[:ash_hooks, :delivery, :disable]telemetry event or accepts the
posture in writing.
1.2.1 — documentation tree ships in the tarball
Packaging fix, no library code change.
Fixed
- The documentation tree now ships in the hex tarball. 1.2.0's package
carried only the top-level docs (README, CHANGELOG, UPGRADING, usage-rules),
sodocumentation/— including the multi-tenant adoption checklist — was
reachable only on GitHub/hexdocs while UPGRADING.md and the CHANGELOG
referenced it by repo-relative path. Installed-package readers now get the
tutorials (get-started, tenancy-adoption-checklist), the DSL reference, and
the Livebook alongside those references. No library code changed; hexdocs
and the package page README are unaffected.
v1.2.0 — multi-tenancy (attribute-aligned, fail-closed)
Packaging follow-up in 1.2.1: the documentation tree (including this release's tenancy adoption checklist) now ships in the hex tarball.
Tenancy release (ADR-0011, additive and opt-in): ash_hooks aligns with
Ash attribute multitenancy — an explicit tenant threads every data call,
and the package fails closed with named errors before any data access
when a multitenant resource is touched without one. Single-tenant
adopters change nothing: the entire pre-1.2 suite passes unchanged over
tenant-less fixtures inside the now-610-test gate, because threading a
tenant through resources with no multitenancy declaration is
mechanically inert.
Added
- Tenant threading on every path —
AshHooks.dispatch/4(:tenant
opt),AshHooks.Ingress.ingest/4(ctx[:tenant]), the claim/mark/
renew/redact/prune/reap heads (:tenantopt, new optional arity),
AshHooks.Delivery.run/2(tenant recovered from job args), and both
prunes. The tenancy contract: consumers declare
multitenancy :attribute, attribute: <same attr>on the resources a
given operation touches; the package verifies that set on every entry
point and returns{:error, :tenant_required}/
{:error, :tenancy_mismatch}before any data access (Ash's native
fail-closed stays the backstop;global?: trueis rejected — it
silently disables fail-closed reads). A compile-time verifier
additionally rejectsmultitenancy :bypass,:bypass_all, and
:allow_globalactions on multitenant package resources. - Per-tenant operations —
AshHooks.reap_all/2and
AshHooks.prune_all/2sweep a tenant enumerable sequentially with
per-tenant error isolation and totals
({:ok, %{results: %{tenant => {:ok, n} | {:error, reason}}, total: n}}). AshHooks.reconcile_pending/3— the supported reconciliation for
delivery rows stranded at:pendingby a crash between row write and
enqueue: a WHERE-gated CAS flip to:enqueue_failed(real state
change — matched-records is the win signal; exactly one winner per
row among concurrent reconcilers, proven under a custom non-Oban
seam), then enqueue through the same seam dispatch takes. Exactly-once
ACROSS claim mechanisms (reconciliation vs re-dispatch repair) is the
enqueue seam's contract — the canonical Oban seam's job uniqueness
provides it; a custom seam must be idempotent itself.- Tenant-aware secret resolution (all optional) — the worker macro's
:tenant_aware_secrets(2-arityf(ref, tenant)resolver contract);
the inboundsecret fn tenant -> ... endsource; the provider
behaviour's optionalwebhook_signing_secret/2
(Organization × connection custody;use AshHooks.Providerships an
overridable default delegating to/1); and:tenantin the
verify_signature/3context map. - Oban args carry the tenant — the generated
enqueue/2serializes
the row's tenant (string tenants are the supported shape; non-string
tenants must round-tripparse_attributeover JSON); uniqueness keys
stay[:endpoint_id, :event_uuid], and a pre-tenancy tenant-less job
still conflicts with the tenant-bearing job on the same pair (proven
on the real Oban engine). - The dispatch result contract is now a public typespec
(AshHooks.Dispatcher.dispatch_result/0) including the new
:reconciledstatus. - The multi-tenant adoption checklist
(documentation/tutorials/tenancy-adoption-checklist.md) and ADR-0011.
Migration notes
Two adopter classes see action; single-tenant staying single-tenant
sees none. See UPGRADING.md and the adoption checklist: (1) cutover
with in-flight 1.1.x Oban jobs — drain before serving multitenant
dispatch (pre-tenancy args fail closed, correct but noisy); (2)
single→multi on populated tables — backfill the tenant attribute first
(NULL-tenant rows form a shadow partition that strands effect-once),
then regenerate identity indexes, then enable.
v1.1.1
1.1.1 — 2026-09-16
Tri-OS proof release. No covered-surface change (patch, ADR-0010): the
package now carries mechanical proof of macOS, Linux, and Windows
support — a dedicated CI leg per OS runs the full 555-test suite on
the dev-default toolchain, all nine legs green, and the README claims
exactly the OSes the legs prove.
Added
- CI legs on macOS and Windows (dev-default toolchain Elixir 1.20.4 /
OTP 28,compile --warnings-as-errors+ the full suite). Linux was
already proven by every existing leg. .gitattributes(* text=auto eol=lf): every OS checks out
byte-identical sources; all tracked files were already LF, so no
renormalization was needed.
Fixed
- Four test-side portability defects exposed by the first Windows run
(the package and its sqlite NIF substrate compiled and ran on
Windows throughout — every defect was in fixtures):- two
:inetshttpd fixtures hardcodedserver_root: "/tmp",
nonexistent on Windows — one failure hid as asetup_all
invalidation that silently excludes a whole module's tests from
the failed count; both now useSystem.tmp_dir!(); - CRLF checkout on Windows broke the install-task fixture's byte
assertions (=~ "plug Plug.Parsers\n"vs"\r\n"bytes); - httpd's URL→path translation mangles backslashes, so every request
returned 500 on Windows — the fixtures now hand httpd
forward-slashserver_root/document_root(a no-op on unix).
- two
v1.1.0
1.1.0 — 2026-09-16
Support-window release: the Elixir floor rises to 1.20 (a minor, not a
major, by ADR-0010 rule 4 — the first exercise of that rule), the
repo's own toolchain becomes self-enforcing and lockstep-pinned, and
dependencies sit at latest with mix hex.audit running in CI.
Changed
- The supported Elixir window is now
~> 1.20(was~> 1.17) —
nothing below Elixir 1.20 is supported, by owner decision; the OTP
floor is 28 (CI tests 28 and 29). Consumers on 1.17–1.19 get a
resolver-level refusal instead of a compile. ADR-0010 rule 4 makes a
supported-floor bump a MINOR release, so a~> 1.0pin resolves
1.1.0 and then fails resolution on old Elixirs — pin
{:ash_hooks, "~> 1.0.4"}until you can move (see UPGRADING.md). CI
tests the window on Erlang/OTP 28 (the dev default, pinned in
.tool-versionsand mirrored by a dedicated leg) and 29; the floor
leg drops the lock and resolves at the floor to keep the requirement
honest. - Repo-local toolchain enforcement — consumers are unaffected:
config/
is excluded from the hex tarball. Development runs on one pinned
toolchain (.tool-versions: Elixir 1.20.4 / Erlang/OTP 28) mirrored
by a dedicated CI leg;config/config.exsrefuses any OTP release CI
does not test (currently 28/29) before anything compiles, because
System.version/0does not encode the OTP build and a same-Elixir
foreign build would otherwise compile incompatible BEAMs silently.
The window,.tool-versions, the OTP allowlist, and the CI matrix
move together in ONE commit.mix hex.auditnow runs in CI on every
push. - Dependencies moved to latest (
mix hex.outdatedshows zero "Update
possible"): oban 2.23.1 → 2.24.1, dialyxir 1.4.7 → 1.4.8, ex_doc
0.40.3 → 0.40.4.mix hex.auditclean before and after (and OSV shows
no advisories against the three new versions).
Fixed
- A
%Regex{}inside a module attribute (@redaction_patterns) embeds
the compiledre_pattern, which is a reference on OTP 28 — Elixir
< 1.19 cannot escape references when injecting an attribute into a
function body, so the package did not compile for consumers on
1.17–1.19 × OTP 28 (a combination the old window admitted). The
patterns are now built by adefp(identical regexes, flags, and
order); with the floor now at 1.20 that combination is out-of-window,
and thedefpkeeps the escape class dead if the floor ever drops
below 1.19. mix dialyzeron a fresh PLT no longer reports 13 unknown-function/
unknown-type warnings intest/support:use AshSqlite.Repoemits
Ecto.Adapters.SQLdelegations, and the deps-PLT app enumeration does
not reliably include:ecto_sql— it is now explicit in
plt_add_appswith the reason inline.
Added
scripts/check-currency.sh: exits nonzero whenevermix hex.outdated
shows resolvable drift, and prints the packages whose latest release the
resolver will not take under current requirements — each of those must
carry an inline deliberate-pin reason inmix.exs. Fail-closed: an
erroredhex.outdatedrun (lock mismatch, resolver failure) also exits
nonzero.
v1.0.4 — current-Ash compatibility + byte-bound corrections
Current-Ash compatibility release: the repo's own app surfaces carry Ash 3.33's required default_string_length_count, and the byte-bound correction family that requirement exposed is fixed.
Fixed
- Current-Ash compatibility — Ash 3.33 requires every application that compiles resources to set
config :ash, :default_string_length_count(the app-level half of GHSA-cwjv-574p-59f6), which broke the CI floor and Livebook legs on lock-free resolution. The test application (repo-local, never shipped), the get-started Livebook, and the tutorial now carry the setting; the package writes no:ashconfiguration — consumers keep that choice, and both documented values work (see UPGRADING.md). A tripwire test locks the boundary. - Byte-bound corrections (each red-proven):
- Captured snippets and error summaries are capped in BYTES instead of grapheme-sliced — under
:codepointsa grapheme-sliced 2048-char snippet of combining characters spans 4000+ codepoints and crashed the post-send ledger write (the same re-send poison class the control-byte strip closes). Event.new/1and the dispatcher guards bound ids/types by bytes — a 128-grapheme combining type previously passed validation and then failed every endpoint's dispatch create under:codepoints.- A thrown 255-char token no longer overflows
last_error(262 bytes failed the enqueue-failure write itself). - An inbound handler failing with an invalid-UTF-8 binary now records
[binary]instead of losing the failure record and leaving the delivery re-drivable.
- Captured snippets and error summaries are capped in BYTES instead of grapheme-sliced — under
- The tutorial's requirements line matches the tested floor (Elixir ~> 1.17, ADR-0010).
Changed
mix.lock: ash 3.33.4; dev/test substrate cleared of published advisories (ash_sqlite 0.2.19 / ash_sql 0.7.5 — test-only;mix hex.auditclean).
v1.0.3 — docs-currency release
No functional changes — 1.0.2's coverage gate and fixes unchanged. This release closes the audit's currency gaps:
Changed
- The send-failure contract tests dropped their 512MB bodies for 16MB with the peer's receive window pinned to 1KB — the same in-flight guarantee at 1/32nd the allocation. CI uploads the cover HTML when the coverage gate reds.
Docs
- UPGRADING now carries the 1.0.2 behavior notes: the
:httpcliteral-IP HTTPS refusal (:ip_literal_https_needs_bounded— use the default adapter for literal-IP HTTPS) and the:http_optsthreading fix. - The
:httpcandBoundedmoduledocs document the literal-IP posture and the:cacertstrust-store seam; ADR-0009 records the seam decision in Consequences.
v1.0.2 — the 100% coverage release
The maintainership directive superseding 1.0.1's print-only coverage posture, worked through the wayfinder decisions (#20-#22): line coverage of lib/ now gates CI at 100% — every runtime-reachable line covered, 31 provably-dead defensive arms deleted with proofs rather than exempted, and the ignore list carrying only what :cover structurally cannot observe (Spark's generated DSL sections, test-support, four compile-window module-body lines).
Added
- Injectable
:cacertstrust bundle on both HTTP adapters (Target.ssl_options/2; workerhttp_opts— compile-time literals or{m, f, a}resolved per-perform) for private-CA endpoints. Default unchanged (OTP CA store). - TLS success paths covered by real CA-verified loopback sessions against committed local fixtures, including the literal-IP iPAddress-SAN floor.
Fixed
CertSan.ip_san_match?/2fails closed (returnsfalse) instead of crashing its caller on garbage SAN bytes — asn1 decode failures exit on OTP 29 and raise on 27/28; the bare catch covers every class (cross-vendor reviewed).- The alternate
:httpcadapter refuses literal-IP HTTPS fail-closed (:ip_literal_https_needs_bounded): it cannot run the IP-SAN floor, and chain validation alone would let any chain-valid cert authenticate the endpoint IP. Use the default adapter there. use AshHooks.Workerno longer silently drops:http_opts.
Send failures now classify through the read path — the Erlang inet/ssl drivers queue sends and surface peer-death errors on the next operation (probed on both platform families, both transports); the unreachable {:send_failed, _} wrap is gone with the probe evidence in the commit messages.