AshHooks.OutboundDelivery (AshHooks v2.0.3)

Copy Markdown View Source

Turns the consumer's resource into the outbound delivery ledger: a durable, deduplicated obligation recording that an event must be delivered to an endpoint (the outbound twin of AshHooks.InboundDelivery). Sends remain at-least-once, and receivers deduplicate retries by webhook id.

use Ash.Resource,
  data_layer: AshSqlite.DataLayer,
  extensions: [AshHooks.OutboundDelivery]

# optional: rename the exact-bytes column when `payload` is
# reserved by the consumer's domain (e.g. a sole payload store)
outbound_delivery do
  payload_attribute :event_bytes
end

The extension injects the delivery fields (event_uuid — the webhook id, immutable across retries; the exact payload bytes to sign; endpoint_id/subscription_id; immutable dispatch_source and dispatch_route; the lifecycle status; accounting attempts; fenced send and enqueue tokens/leases; the durable 410 endpoint snapshot; the machine-written response_status/response_snippet/ next_attempt_at; bounded last_error), the unique_delivery identity on [:endpoint_id, :event_uuid]. The ownership fields default to explicit direct/unbound markers so existing direct callers of :dispatch remain valid; the first authorized direct driver and worker route bind them once with compare-and-set updates. Host declarations that replace these fields with incompatible types, nullability, defaults, or writable machine fields fail resource verification. Oban admission includes this receiver identity plus the complete row key, source, route, and tenant. The extension also injects the machine primitives :dispatch (no-touch unique upsert, the :ingest mirror) and :mark_enqueue_failed.

Storage-level uniqueness on unique_delivery is the idempotency primitive: the consumer's migration must carry the matching unique index (ADR-0003's argument, applied outbound).

response_status, response_snippet (the no-body summary by default — status + allowlisted content-type token; the [captured]-marked, redacted body only on the runtime's per-call opt-in), and next_attempt_at are writable?: false — no consumer create/update input accepts them; they reach the ledger only as arguments of the runtime's machine primitives (:mark_succeeded, :mark_send_failed), fenced to the current attempt. Request and response headers are not stored on this resource.

Rows contain the exact payload bytes you dispatched. Define read policies in your domain; the extension does not inject them.

Policy obligation: the package injects write actions; your application defines their authorization. Under a permissive base policy fragment (an authorize-if-active-member fallback), every actor could otherwise reach the machine primitives — mark a delivery succeeded (suppressing a real send), dead-letter one, requeue, or prune the ledger. The injected write actions on this resource: :dispatch, :mark_enqueue_failed, :bind_dispatch_source, :bind_dispatch_route, :claim_enqueue, :release_enqueue, :requeue, :prune (the retention destroy — omittable via prune_action :none), :mark_sending, :mark_succeeded, :mark_send_failed, :mark_disable_pending, :finalize_disable. Cover each with an action-specific policy (the runtime's internal calls run authorize?: false and never consult them), or ensure no actor-facing surface (json_api/graphql/code_interface/admin) exposes the resource. The runtime writes through these actions — a policy per action is the complete seam.

Summary

Functions

The delivery state machine's statuses, in lifecycle order.

Functions

outbound_delivery(body)

(macro)

statuses()

@spec statuses() :: [atom()]

The delivery state machine's statuses, in lifecycle order.