Current section

Files

Jump to
ash_hooks usage-rules.md
Raw

usage-rules.md

# AshHooks usage rules

Guidance for maintainers and coding assistants working in applications that use
AshHooks 3.1.0.

## Build events from exact bytes

- Construct outbound events with `AshHooks.Event.new/1`. Pass the exact binary that will
  be signed and sent; do not pass a map and re-encode it later.
- Derive the event ID from the logical event's stable identity whenever the producer can
  run again. Include a revision or change ID when one source record emits several events.
  Reusing the event ID lets the ledger and receiver deduplicate repeated delivery.
- Do not construct `%AshHooks.Event{}` directly. Dispatch revalidates raw structs, but the
  constructor gives the caller the validation error before any row is written.
- Event IDs cannot contain dots, spaces, invalid UTF-8, or control characters because the
  ID is also serialized as the `webhook-id` header.

## Receive webhooks through the ledger

- Add `AshHooks` and `AshHooks.InboundDelivery` to the inbound ledger, set `provider` on
  every inbound declaration, and forward HTTP webhook routes to `AshHooks.Plug`.
  Set `resource:` and exactly one of `source:` or `resolve: {module, function, args}`.
  `AshHooks.Ingress.ingest/4` remains the lower-level path for custom transports.
  Signature verification is over the exact raw bytes. There
  is no name-to-module convention and there are no built-in vendor verifiers.
- Configure `body_reader: {AshHooks.BodyReader, :read_body, []}` on any preceding
  `Plug.Parsers`, or have your own endpoint reader call
  `AshHooks.BodyReader.put_raw_body(conn, complete_bytes)` before parsing. Supply
  the whole body exactly as sent, never partial or re-encoded bytes; the plug
  retrieves it through `AshHooks.BodyReader.raw_body/1` without a second read.
  Direct ingress callers also use `AshHooks.BodyReader.raw_body(conn)`.
  Without a parser the plug reads the body itself. Never reconstruct
  signed bytes from parsed params. Configure parser body limits separately from the
  plug's `length:`, `read_length:` and `read_timeout:` for direct reads.
- Use `resolve:` to look up a stored endpoint from the request path and return
  `{:ok, %{source: source, tenant: tenant, scope: scope, connection: connection}}`.
  Only `source` is required in that map; multitenant ledgers require `tenant`, and
  scoped identities require their scope slots. Do not trust a path's tenant directly.
  Return `{:error, :not_found}` for 404 or `{:error, :unavailable}` for 503; neither writes.
- Set `request_uri:` to a configured public base URL or an MFA returning the full URI
  when the sender signs the URL. The plug never derives it from the internal conn
  authority. `methods:` defaults to `["POST"]`; other methods receive 405 with `allow`.
- A provider written directly against `AshHooks.Provider` reads only `ctx.signature`
  (its `signature_source/0` is `:header` by default). Name its header with the plug's
  `signature_header:` option, or return `:signature_header` from the resolver to set
  it per source. A missing or repeated header is refused as unsigned with 400.
  Configured providers (`verify: [...]`) read their own headers and need neither.
- Provider `handshake/1` may return `{:respond, status, headers, body}` before method
  and verification checks, or `:deliver`. It receives method, decoded query params,
  request headers and raw bytes. No delivery is written for a handshake response.
  Signature verification has not run yet: authenticate any challenge exactly as the
  sender's protocol requires before answering it.
- Provider `response/1` overrides the plug's `respond:` MFA; `:default` falls through
  to the host policy, then `AshHooks.Plug.default_response/2` (outcome and validated
  plug options). Both policies receive
  the exact ingest outcome and never override a plug refusal. Defaults acknowledge
  terminal rows with 200, retry other ledger outcomes with 500, reject typed signature,
  timestamp and malformed-payload errors with 400, and retry other failures with 503.
- Set `retry_after:` to a positive number of seconds (default 60) for the required
  `retry-after` header on default-mapping and resolver 503s. Provider and host
  ingest-response policies own their 503 headers.
- Use `refuse: {module, function, args}` to shape refusal headers and bodies. It
  receives `:not_found`, `:unavailable`, `:method_not_allowed`, `:too_large`, or
  `:body_consumed` and returns `{headers, body}` or `:default`. It cannot change the
  status; required `allow` and `retry-after` headers win on a name conflict.
- Describe a sender with `use AshHooks.Provider, verify: [...]` (options in
  `AshHooks.Provider.Scheme`). Copy the scheme from the sender's own documentation:
  algorithm, signature location, encoding, secret encoding, signed input, and
  timestamp. Configured providers read their signature from `ctx.headers`.
  The plug supplies the ordered request headers and method. For direct
  `Ingress.ingest/4` calls, pass `headers: conn.req_headers` (so repeated lines
  survive), `method`, and the public `request_uri` when the scheme signs them.
- Keep every verification option and key in code or the secret source. Never build
  options, keys, algorithms, or key URLs from the request. Public-key schemes take the
  sender's PEM key from the secret source.
- Use a `signed_input` or `timestamp` host function only where the sender canonicalizes
  (sorted form fields, decoded URLs, fields from the body, unusual timestamp formats).
  A `signed_input` function returns the binary to verify; a `timestamp` function returns
  `{:ok, unix_seconds}`. Any other result fails verification.
- When configuration cannot express a scheme, implement `verify_signature/3` with the
  primitives in `AshHooks.Provider.Signature`. Compare in constant time and return an
  error tuple for every malformed input; never raise.
- A configured provider classifies every request as `:webhook`. Override
  `parse_event_type/1` with an explicit table of the sender's event names (never
  `String.to_atom/1`), and implement application effects and their idempotency in
  `handle_event/2`.
- Include every sender scope needed for uniqueness in `scope_identity`, and create the
  generated unique index in the database. The ledger is the deduplication boundary; do
  not add a separate seen-events table.
- An explicit `event_id` extractor takes precedence. Invalid extractor results
use the raw-body digest. Without an extractor, ingress uses `event_identity/1`
when implemented; an invalid provider identity fails ingestion.

## Dispatch and drive outbound deliveries

- Add `AshHooks` to the emitting resource, declare `outbound`, and use
  `AshHooks.dispatch/4` for fanout. It creates one durable delivery obligation per
  matching enabled endpoint.
- `use AshHooks.Worker` is the supported Oban integration. Pass its `enqueue/2` callback
  to dispatch so each row receives a durable, uniquely identified trigger.
- `AshHooks.Delivery.run/2` is also a supported execution path. It drives an existing
  delivery row directly for queue-free hosts, custom schedulers, and one-row diagnostics.
  It does not create the row: dispatch first, then call `run/2` with the delivery identity
  and a complete configuration for the delivery resource, endpoint resource, secret
  resolver, retry limits, and backoff limits. Honor `{:snooze, seconds}` by scheduling the
  row again after that delay.
- A direct runner must impose the same outer scheduling discipline as the worker. Keep
  the attempt deadline finite and allow the result-write finalization window to complete.
- The row owns retries, attempt counts, send leases, and terminal state. Oban or a custom
  scheduler is a trigger only; do not add a second retry policy around the row.
- Delivery is at least once across a crash during transport. Receivers must deduplicate
  the stable `webhook-id`.
- `retryable_statuses` optionally replaces the HTTP retry set. 2xx, redirects, and 410
  keep their package-owned rules. Host `delivery_headers` callbacks receive the delivery,
  endpoint, and tenant; they cannot override signature or framing headers.
- Optional certificate pins require HTTPS and `AshHooks.Http.Bounded`; pin mismatch
  fails before sending and is non-retryable. Resolve per-endpoint pins from current
  host configuration, never from process-local state another node would need.

## Preserve durable ownership and recovery

- Do not edit `dispatch_source`, `dispatch_route`, attempt tokens, enqueue tokens, leases,
  or endpoint snapshots. Dispatch, direct execution, workers, and reconciliation use
  these fields to reject the wrong declaration, route, or stale owner.
- Run `AshHooks.reconcile_pending/3` periodically for every outbound declaration and
  tenant that uses queued delivery. It recovers stale pending rows, enqueue failures, due
  retries, expired sends, and interrupted endpoint disables. Future retries and live
  leases remain untouched.
- Keep the reconciliation cutoff beyond normal enqueue latency. The default is five
  minutes.
- Named worker callbacks carry a stable route automatically. An anonymous enqueue
  callback that must survive reconciliation needs a stable `enqueue_key`. An unkeyed
  anonymous callback cannot be reconstructed and is reported as `:unresolved_route`.
- Custom enqueue callbacks must be idempotent. The enqueue effect may succeed before a
  claimant crashes and releases its lease.
- Event-log replay takes `declaration: {Emitter, :event_name}` and the same tenant
  as dispatch. Enable `resend_tracking :on` to store the requester and request time.
  Enqueue receives the created delivery and its event. Dry runs write nothing.
- Health sweeps wait `warning_interval_seconds` (default 60) from the stored warning
  time before disabling. Counts and telemetry reflect successful writes.
- When pruning with `attempts:`, both resources must support transactions. A history
  deletion error rolls back the batch and is returned; for SQLite, enable
  `write_transactions?/0` on the repo.
- A 410 response first records `:disable_pending` with an endpoint snapshot. Recovery may
  complete that disable only if the endpoint still matches the snapshot; an old response
  cannot disable replacement configuration.

## Treat identity upgrades as data migrations

- When adopting a provider-defined identity for rows created with the former raw-body
  digest, first call `AshHooks.Ingress.plan_legacy_identity_adoption/3` and review its
  unresolved rows, conflicts, representative choices, and before/after keys.
- Quiesce ingress and reapers before calling
  `AshHooks.Ingress.adopt_legacy_identity/3` with `quiesced?: true`. Use the same scope,
  tenant, and canonical-ID overrides that were reviewed in the plan.
- Adoption keeps one representative for each canonical identity. Sibling audit rows stay
  stored with their original payload and digest and become terminal `:superseded`; they
  are never reaped into handler execution.
- Never rewrite provider identities or mark rows superseded with ad hoc updates.

## Keep resource references and tenancy consistent

- Endpoint and subscription resources each need one UUID-storage-compatible primary key;
  the attribute may have any name and may use `:uuid_v7`. The DSL rejects composite or
  non-UUID keys where scalar delivery references cannot represent them.
- Delivery and inbound ledgers may use consumer-defined or composite primary keys. Use
  the package's primary-key helpers and full key maps rather than assuming a field named
  `id`.
- In a multitenant application, use the same attribute-tenancy contract on every resource
  touched by an operation, including optional history and event-log resources, and
  pass the tenant to every entry point. `AshHooks.Tenancy.resolve/2` checks only
  its supplied modules; entry points include every resource they read or write. Global tenancy is
  rejected. Missing or mismatched tenancy fails before data access.
- Queue arguments serialize the row tenant and full delivery key so a restart can recover
  the same scope. Do not replace them with ambient process context.

## Keep secrets, responses, and retention bounded

- Secrets are resolvers, never literals: use `{module, function, args}`, `{:app_env,
  path}`, a zero-arity function, or a tenant-aware one-arity function. Persist only secret
  references.
- The default HTTP adapter validates destinations at send time, pins the validated
  address, never follows redirects, rejects unsafe headers, and applies one finite deadline across
  DNS, connect, send, and reads. Keep these guarantees when supplying a custom adapter.
- Response snippets store no body bytes by default. Enable `snippet_capture: true` only
  for an explicit diagnostic run. A custom snippet redactor receives the raw captured
  bytes and must return a binary or `nil`; a crash or invalid return falls back to the
  package's sanitized summary.
- Use `AshHooks.Ingress.prune/2` and `AshHooks.Delivery.prune/2` to prune supported
  terminal states. Apply a separate application retention policy to tracked
  `:failed_verification` rows.
  Only terminal rows are deleted. Inbound `:superseded` rows are terminal and remain
  available for audit until the configured retention pass removes them.
- The package injects no read policy. Define narrow consumer actions and Ash policies for
  ledger, endpoint, and subscription access. Avoid broad `accept` lists on internal state
  fields.

## Common mistakes

- Baking `validate_destination: false` or an `http_opts` `timeout:` at or above `:attempt_timeout`
  into a worker module (the macro refuses both; the bypass is the `:test`-only destination-validation bypass).
- Decoding JSON before capturing the raw request body used by the provider signature.
- Generating a new event ID each time the same source record is dispatched.
- Calling `Delivery.run/2` without handling `{:snooze, seconds}`.
- Running queued delivery without scheduling `reconcile_pending/3`.
- Using an anonymous enqueue callback without a stable `enqueue_key` when recovery is
  required.
- Leaving the generated `:webhook` classification in place when handlers need the
  sender's event types.
- Passing only `ctx.signature` to a configured provider: it reads its signature from
  `ctx.headers`.
- Reading or updating a row through an assumed `.id` instead of its declared primary key.
- Registering a telemetry prefix instead of the complete event name expected by
  `:telemetry.attach_many/4`.