AshHooks.Delivery (AshHooks v0.2.2)

Copy Markdown View Source

The delivery runtime driver: one pending delivery row → signed HTTP send → reconciled ledger row. Pure functions over the consumer's resource modules and an injected HTTP adapter — Oban-free by construction; use AshHooks.Worker wraps this as an Oban worker inside the consuming app (ADR-0004's host-injection boundary).

The delivery ROW is the single retry-policy source (ADR-0008): the driver refuses to re-send :succeeded/:dead_letter rows, waits on next_attempt_at, counts attempts against the ceiling, and honors Retry-After (bounded). Oban is the durable TRIGGER — {:snooze, s} re-drives later without exhausting the job (snooze extends max_attempts; only this module's ceiling decides dead-letter).

Classification (the design note's table, one source):

  • 2xx → :succeeded (status + allowlisted content-type summary — NO body bytes by default; a per-call snippet_capture: true config persists the floor-redacted body marked [captured], ADR-0005's snippet amendment)
  • 408/429 → retryable, Retry-After when present (integer seconds or HTTP-date; clamped [1, retry_after_cap]), else backoff
  • 410 → endpoint durably :disabled + row :dead_letter
  • other 4xx → :dead_letter (client errors do not self-heal)
  • 3xx → :dead_letter (redirect_refused — never followed)
  • 5xx / transport error / secret-resolution failure → retryable backoff
  • send-time SSRF refusal / disabled-or-gone endpoint → :dead_letter

Backoff: min(base · 2^min(attempts, 16), max_backoff) seconds plus :rand.uniform(delay) jitter, re-clamped — always ≥ 1 second.

Summary

Functions

Retention hook: deletes TERMINAL delivery rows (:succeeded, :dead_letter) older than older_than, by the resource's inserted_at (add Ash timestamps() to the resource and its migration). Non-terminal rows are never deleted. Returns {:ok, deleted_count}.

The package snippet floor (ADR-0005, amended 2026-08-22) — what opt-in-captured bodies pass through before persistence: NFKC normalization (fullwidth homoglyph markers fold), a bounded-fixpoint decode chain (percent ×2 + JSON \u per-escape, re-run until stable — a \u0025 escape can materialize % only after the percent layers), the separator-tolerant marker patterns, the ≥16-char union-alphabet entropy rule, a control-byte strip, and the 2048 cap. Un-redaction is impossible by construction; invalid UTF-8 collapses to [binary].

Drives one delivery (args: %{"endpoint_id" => ..., "event_uuid" => ...}, string keys — Oban's JSON round-trip shape; atom keys tolerated).

The DEFAULT response-snippet summary (ADR-0005's 2026-08-22 amendment): a fixed grammar over the status and one ALLOWLISTED content-type token — never body bytes, never a body-derived digest (a hash is correlation material that explains nothing).

Functions

prune(deliv_mod, opts)

@spec prune(
  module(),
  keyword()
) :: {:ok, non_neg_integer()} | {:error, term()}

Retention hook: deletes TERMINAL delivery rows (:succeeded, :dead_letter) older than older_than, by the resource's inserted_at (add Ash timestamps() to the resource and its migration). Non-terminal rows are never deleted. Returns {:ok, deleted_count}.

redact(body)

@spec redact(term()) :: String.t() | nil

The package snippet floor (ADR-0005, amended 2026-08-22) — what opt-in-captured bodies pass through before persistence: NFKC normalization (fullwidth homoglyph markers fold), a bounded-fixpoint decode chain (percent ×2 + JSON \u per-escape, re-run until stable — a \u0025 escape can materialize % only after the percent layers), the separator-tolerant marker patterns, the ≥16-char union-alphabet entropy rule, a control-byte strip, and the 2048 cap. Un-redaction is impossible by construction; invalid UTF-8 collapses to [binary].

run(args, config)

@spec run(
  map(),
  keyword()
) :: :ok | {:snooze, pos_integer()} | {:error, term()}

Drives one delivery (args: %{"endpoint_id" => ..., "event_uuid" => ...}, string keys — Oban's JSON round-trip shape; atom keys tolerated).

Returns :ok (terminal or attempted-to-terminal), {:snooze, seconds} (retry later), or {:error, term} for a broken trigger (row missing → :ok; the durable row is the record — a missing row is a completed or reaped delivery, not a failure).

summarize(status, headers)

@spec summarize(integer() | nil, keyword() | list() | nil) :: String.t()

The DEFAULT response-snippet summary (ADR-0005's 2026-08-22 amendment): a fixed grammar over the status and one ALLOWLISTED content-type token — never body bytes, never a body-derived digest (a hash is correlation material that explains nothing).

"200 json token=application/json"   # the type was allowlisted
"200 text token=other"              # anything else collapses to other

The status is an integer, the kind comes from a fixed vocabulary (json | html | text | xml | binary | other), and the token is either an exact allowlist member or the literal other — a hostile Content-Type header cannot smuggle material into the ledger through this string.