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.
What happens on a send
Section titled “What happens on a send”- Resolve the channel. palumb finds your enabled channel
of the requested kind — lowest
priorityfirst, then oldest — and decrypts its config. Disabled channels are skipped. - Resolve the address. It reads the recipient address from
subscriber.contacts[kind](the run’s subscriber, or the{ to }override). - Record + send. It writes a
DeliveryAttempt(the egress ledger), calls the provider, then records the outcome.
The egress ledger
Section titled “The egress ledger”Each physical send is one DeliveryAttempt row. Its status:
| Status | Meaning |
|---|---|
pending | The attempt was recorded, the provider call is in flight |
sent | Accepted by the provider (the MVP terminal success) |
failed | The send failed terminally |
delivered / bounced | Reserved for provider delivery webhooks (future) |
sent is terminal — an attempt that reached it never transitions out.
Idempotency
Section titled “Idempotency”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.
Why egress is its own ledger
Section titled “Why egress is its own ledger”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.sendseveral times (e.g. an escalation that rings multiple people), each its own attempt.
A shared row could model neither.
Failure semantics
Section titled “Failure semantics”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 aTerminalError; 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.
The provider adapter model
Section titled “The provider adapter model”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.
Related
Section titled “Related”- Sending messages — the SDK side (
step.send). - Channels — what gets resolved at send time.
- Triggers — the ingress half and its ledger.