ash_hooks 1.2+ supports Ash attribute multitenancy: every dispatch, ingest, delivery run, sweep, and reconciliation threads an explicit tenant, and the package fails closed with a named error when a multitenant resource is touched without one. This checklist walks one app — two tenants or two thousand — through the same steps. The reasoning behind the floor is ADR-0011.
The contract
Declare the SAME attribute strategy on the resources a given operation touches — for the full outbound machine that is your Subscription, your Endpoint, and your OutboundDelivery ledger; for inbound, your InboundDelivery ledger. (An inbound-only app declares tenancy on its ledger alone — each operation verifies the set it actually reads.)
attributes do
# ...the extension's injected fields...
attribute :org_id, :string, allow_nil?: false
end
multitenancy do
strategy :attribute
attribute :org_id
endTwo declarations are rejected outright with {:error, :tenancy_mismatch}:
global?: true— it looks consistent while silently allowing tenant-less reads, which would disable every guarantee below.- non-
:attributestrategies — attribute is the only strategy that works across every data layer the package supports.
The ordered steps
0. Drain in-flight Oban jobs from the 1.1.x shape (skip if you have
no queued deliveries). The moment your resources declare multitenancy,
jobs enqueued by the old version — whose args carry no tenant — start
failing closed with {:error, :tenant_required} when they run. That is
the correct, safe behavior, but it is noisy: drain or complete the old
queue BEFORE deploying the version whose resources declare tenancy.
1. Add the tenant columns, then backfill them. First the columns (in the adopter's migration):
ALTER TABLE webhook_endpoints ADD COLUMN org_id TEXT;
-- ...likewise for webhook_subscriptions, inbound_deliveries,
-- outbound_deliveriesThen backfill — every legacy row must get its tenant value:
-- your mapping from existing rows to their owning tenant
UPDATE webhook_endpoints SET org_id = <derived per row>;
UPDATE webhook_subscriptions SET org_id = <derived per row>;
UPDATE inbound_deliveries SET org_id = <derived per row>;
UPDATE outbound_deliveries SET org_id = <derived per row>;Verify zero NULL tenants remain:
-- must return 0 for every table
SELECT COUNT(*) FROM outbound_deliveries WHERE org_id IS NULL;A NULL-tenant row is a quiet trap: the unique identities become
tenant-scoped in step 2, and NULL never equals a tenant value — so a new
delivery creates a PARALLEL row instead of matching the old one, the
enqueue then collides with the historical completed job, and the new row
sits at :pending forever. NULL-tenant rows are also invisible to every
tenant-scoped sweep. Backfill before proceeding; the count above is the
gate.
2. Regenerate migrations so the unique identities include the tenant.
The unique identities (unique_delivery on endpoint+event,
unique_ingest on provider+external id + scope) become tenant-prefixed
unique indexes — run your mix ash.codegen / migration generator after
adding the declarations. Dedup is per-tenant by construction from here:
the same provider event id in two tenants is two rows, both processed.
3. Deploy, and thread the tenant through your call sites.
# outbound — every call
AshHooks.dispatch(Order, :order_paid, event, tenant: org.id)
# inbound — the tenant rides the request context
AshHooks.Ingress.ingest(WebhookLedger, :stripe, raw_body, %{signature: sig, tenant: org.id})
# the worker (unchanged) — the enqueue serializes the row's tenant into
# job args; Delivery.run/2 recovers it after any restart4. Adopt the per-tenant operations surface.
- Sweeps are per-tenant:
reap/2and bothprune/2s take:tenant;AshHooks.reap_all/2andAshHooks.prune_all/2sweep a list of tenants sequentially — one tenant's failure never stops the others — and return each tenant's result plus a total:{:ok, %{results: %{"org_a" => {:ok, 3}, ...}, total: 3}}. - Reconciliation of rows stranded at
:pendingby a crash between the row write and the enqueue:AshHooks.reconcile_pending(Order, :order_paid, tenant: org.id, enqueue: {MyWorker, :enqueue}). The delivery resource needstimestamps()(inserted_atdrives the staleness cutoff — the default of five minutes is right for almost everyone). Concurrent reconcilers never double-claim a row; note that the enqueue callback receives the event reconstructed from the row (id, type, payload — its metadata map is empty), so write custom callbacks accordingly. - Tenant-aware secrets (all optional): the worker macro's
tenant_aware_secrets: true(resolver becomesf(ref, tenant)), the inboundsecret fn tenant -> {:ok, secret} end, and the providerwebhook_signing_secret(connection, tenant)override for per-Organization connection custody.
The isolation guarantees (what you may rely on)
- A dispatch for tenant A cannot create a delivery row for tenant B's endpoint; a subscription pointing at another tenant's endpoint_id resolves to not-found and is skipped; the 410-disable cannot be aimed cross-tenant.
- The inbound dedup identity is per-tenant; the claim fence, marks, renewal, and redaction are per-tenant.
- A tenant-less call against a multitenant set returns
{:error, :tenant_required}before any data access. - Workers recover full tenant context from job args alone (string
tenants are the supported shape; a custom
parse_attributepairs withtenant_from_attribute, which the worker inverts through). - Reconciliation claims each stranded row at most once per run. One caveat across mechanisms: a re-dispatch of a reconciled event claims the row again through the ordinary repair path and may enqueue it a second time — with the package's Oban worker this is deduplicated by job uniqueness; if you supply your OWN enqueue function, it must tolerate being called twice for the same row.
Single-tenant apps
Change nothing. No tenant passed, no tenant threaded: behavior is identical to 1.1.x — the library's own test suite runs entirely over tenant-less fixtures in both versions.