Skip to content

Architecture

This page is the detailed version of How it works. It covers the three moving parts, the request-to-delivery loop, the data model, and what happens when something fails.

flowchart LR
    App["Your app"]
    Core["palumb-core<br/>(managed control plane)<br/>auth · audience · channels<br/>trigger ingress + delivery"]
    Engine["palumb-engine<br/>(durable runtime on Restate)<br/>executor · digest/cron"]
    Bridge["Your Bridge<br/>(@palumb/sdk webhook)<br/>workflow() + step.*"]
    Provider["Provider<br/>SMTP / Slack / …"]
    PG[("PostgreSQL<br/>tenants · audience<br/>channels · ledgers")]

    App -->|"POST /v1/events"| Core
    Core -->|"submit run"| Engine
    Engine -->|"HMAC-signed: run next step"| Bridge
    Bridge -->|"step result"| Engine
    Engine -->|"send step → /v1/deliver"| Core
    Core -->|"resolve channel, decrypt, send"| Provider
    Core --- PG
  1. palumb-core — the managed control plane. A NestJS service with a PostgreSQL database. It is the only component that holds tenant state and secrets. It exposes a REST API, authenticates every call by API key, owns delivery, and is a client of the engine — it submits runs but never hosts any workflow code itself.

  2. palumb-engine — the durable runtime. A Restate host running a generic executor that drives your Bridge step by step, plus the durable building blocks (digest, cron). It persists workflow state and timers and drives retries. palumb operates it as part of the managed platform.

  3. Your Bridge — built with @palumb/sdk. One small signed HTTP endpoint you deploy. Your workflows are plain TypeScript (workflow() + step.*); the engine calls the webhook to run each step. No Restate runs on your side — the durability lives in palumb.

  1. Trigger. Your app calls POST /v1/events with { workflowId, to, payload? }. workflowId is the name of one of your workflows; to is either { subscriberId } or { topics: [...] }. The API key identifies the tenant.

  2. Resolve recipients. palumb expands to into a deduplicated set of subscriber external ids: a single id passes through; topics fan out via the subscription table. Exactly one of subscriberId / topics must be present (otherwise 422).

  3. Persist + start a run, per subscriber. For each resolved subscriber, palumb writes a NotificationLog row (received), then submits a one-way run of the generic executor on palumb-engine (POST {ingress}/PalumbExecutor/{runId}/run/send), carrying your Bridge binding (webhookUrl + signing secret) and the payload as data. It records dispatched (with the invocationId) or failed. One subscriber’s failure does not abort the fan-out — each run sees exactly one subscriber.

  4. The engine drives your Bridge. It calls your webhook (HMAC-signed) to run the next un-completed step, records the result durably, and loops until the workflow finishes. Your workflow may branch, call your own services, wait days for an event, or route into a digest. State and timers survive restarts.

  5. Send. A step.send(id, channel, render) declares a delivery; the engine turns it into an internal POST /v1/deliver (authenticated with an internal token + the tenant id), deriving a stable idempotency key per step.

  6. Deliver. palumb resolves your enabled channel of that kind (lowest priority wins), decrypts its credentials, resolves the recipient address from subscriber.contacts[kind], records a DeliveryAttempt, calls the provider, and records sent or failed. The attempt is idempotent on its key.

All tables are tenant-scoped. The important entities:

EntityPurposeNotes
TenantThe accountHolds the Bridge binding: webhookUrl + an encrypted signing secret
ApiKeyMachine authStores only a SHA-256 hash + a non-secret prefix
SubscriberA recipientAddressed by your external_id; contacts per kind
TopicA pub/sub groupKeyed; born implicitly on first subscribe
SubscriptionSubscriber ↔ topicUsed for the trigger fan-out
TenantChannelAn enabled channelHolds the encrypted provider config
NotificationLogIngress ledgerOne row per dispatched subscriber
DeliveryAttemptEgress ledgerOne row per send; unique on idempotency key

Two separate ledgers are deliberate: a digest collapses N items into one delivery, so ingress and egress cannot share a row. See Channels, Audience, Triggers, and Delivery for each in depth.

Durability is the reason palumb exists, so the failure semantics are explicit:

  • Transient failure on send (5xx / network). Surfaced as a normal error so the engine retries the durable step. The idempotency key keeps retries from double-sending: a key already marked sent short-circuits.
  • Configuration error on send (4xx). No enabled channel for the kind, or no contact address for the subscriber → surfaced as a TerminalError. It is not retried, because retrying cannot fix it; the workflow sees a clear terminal failure. A bad signature or unknown workflow on the Bridge is terminal the same way.
  • Crash mid-workflow. The engine restores the run’s durable state and timers and resumes — a digest waiting out its window keeps waiting; an already-completed step is not re-run.
  • Dispatch failure for one subscriber. Logged as failed on that subscriber’s NotificationLog; the rest of the fan-out proceeds.
  • Credentials never leave palumb. Channel configs are encrypted at rest (AES-256-GCM) and decrypted only inside palumb-core at send time. No API response ever returns a secret.
  • API keys are stored hashed. Only the SHA-256 hash is persisted; the raw key is shown to you exactly once, at creation.
  • The Bridge hop is signed. palumb signs every call to your webhook with an HMAC secret it generates at onboarding, returns once, and stores encrypted at rest. Your webhook verifies the signature, so only palumb can drive it.
  • Your code never sees secrets and palumb never sees your logic. The HTTP edges are also the trust boundaries.