Skip to content

Delivery

Delivery is the egress half that palumb owns. Your workflow expresses intent — “send this content on this kind of channel” — and palumb makes the real provider call. Your code never holds provider credentials and never talks to SMTP/Slack/etc. directly.

When a workflow declares a step.send, palumb’s engine turns it into an internal POST /v1/deliver (authenticated with an internal token + the tenant id). You never call /v1/deliver yourself — it is not part of the tenant API.

  1. Resolve the channel. palumb finds your enabled channel of the requested kind — lowest priority first, then oldest — and decrypts its config. Disabled channels are skipped.
  2. Resolve the address. It reads the recipient address from subscriber.contacts[kind] (the run’s subscriber, or the { to } override).
  3. Record + send. It writes a DeliveryAttempt (the egress ledger), calls the provider, then records the outcome.

Each physical send is one DeliveryAttempt row. Its status:

StatusMeaning
pendingThe attempt was recorded, the provider call is in flight
sentAccepted by the provider (the MVP terminal success)
failedThe send failed terminally
delivered / bouncedReserved for provider delivery webhooks (future)

sent is terminal — an attempt that reached it never transitions out.

Every attempt carries an idempotency key, derived per workflow step and stable across retries. The ledger enforces it with a unique constraint on (tenant, idempotencyKey). So if the engine retries a send — a transient error, a crash-and-resume — palumb finds the prior attempt: if it is already sent, it short-circuits and returns the cached result instead of re-sending. Retries never double-deliver.

The ingress NotificationLog records what entered palumb; the DeliveryAttempt records what went out. They are deliberately separate because the relationship isn’t one-to-one:

  • A digest collapses N ingress items into one delivery.
  • A single run can step.send several times (e.g. an escalation that rings multiple people), each its own attempt.

A shared row could model neither.

Delivery failures map cleanly onto the engine’s retry behavior:

  • Configuration error → terminal (4xx). No enabled channel for the kind, or no contact address for the subscriber. Retrying cannot fix it, so it surfaces as a TerminalError; the workflow sees a clear, non-retried failure.
  • Transport/provider failure → transient (5xx / network). Surfaced as a normal error so the engine retries the durable step. The idempotency key keeps those retries from double-sending.

See Architecture › Failure behavior for the full picture.

A channel of a given kind maps to a provider adapter. Today palumb ships email-smtp (kind email) — a generic SMTP relay. New kinds (slack, telegram, whatsapp) and providers are added behind the same resolve-decrypt-send path, so workflows keep sending by kind and the delivery mechanics stay internal.