Escalation ladder
Operational and regulated notifications have a property marketing emails don’t: someone has to act on them. A safety check is due, an incident is open, a compliance deadline is approaching — and if the responsible person doesn’t acknowledge in time, it has to escalate, not just sit in an inbox.
This example builds a generalized acknowledge-or-escalate ladder:
- Notify the responder.
- Wait for an
acknowledgedsignal — for a bounded time. - No acknowledgement before the timeout? Climb to the next rung (rising urgency, or a stronger channel).
- Run out of rungs? Record a breach and fire a final notice.
It’s nothing but the waitForEvent building block in a
loop — palumb ships no bespoke “escalation” primitive. The ladder is plain
TypeScript you own and can shape to your domain.
A reusable helper
Section titled “A reusable helper”Each rung notifies, then waits for the acknowledgement. The decision to climb is driven by whether the inbound signal arrived — never by palumb polling your backend (events-in / sends-out).
import type { SendContent, StepTools } from '@palumb/sdk/bridge';
interface EscalationRung { id: string; // unique, stable step-id segment channel: string; // 'email' today; 'sms'/'slack' later waitFor: number | string; // ack window — seconds or '15 minutes' to?: string; // who to notify; omit = the run's subscriber render: (ctx: { rung: number }) => SendContent;}
type EscalationResult = { acknowledged: boolean; rung?: string; by?: string };
async function runEscalation( step: StepTools, opts: { rungs: EscalationRung[]; ackEvent?: string; // signal that counts as ack (default 'acknowledged') onExhausted?: () => SendContent; // optional final breach notice },): Promise<EscalationResult> { const ackEvent = opts.ackEvent ?? 'acknowledged'; for (let i = 0; i < opts.rungs.length; i++) { const rung = opts.rungs[i]; await step.send(`notify_${rung.id}`, rung.channel, () => rung.render({ rung: i + 1 }), { to: rung.to, // route this rung to a specific responder }); const ack = await step.waitForEvent<{ by?: string }>(`ack_${rung.id}`, { event: ackEvent, timeout: rung.waitFor, }); if (ack) { await step.run(`resolved_${rung.id}`, async () => ({ acknowledged: true, rung: rung.id })); return { acknowledged: true, rung: rung.id, by: ack.by }; } } if (opts.onExhausted) { await step.send('notify_breach', opts.rungs[0]?.channel ?? 'email', opts.onExhausted); } await step.run('breachRecorded', async () => ({ acknowledged: false })); return { acknowledged: false };}Every step.* call is durable: completed rungs are replayed from history, never
re-run, so the loop survives restarts and waits of any length.
An instance: a compliance deadline
Section titled “An instance: a compliance deadline”import { workflow } from '@palumb/sdk/bridge';
const incidentEscalation = workflow('incidentEscalation', async ({ payload, step }) => { const task = String(payload.task ?? 'a compliance task'); // The run is triggered FOR the operator, so rung 1 omits `to`. The higher // rungs route to their own responder — ids the tenant passes in the payload // (you own the org chart; no runtime pull). await runEscalation(step, { rungs: [ { id: 'operator', channel: 'email', waitFor: '15 minutes', render: () => ({ subject: `Action required: ${task}`, body: `<p><b>${task}</b> is due. Please acknowledge to close it out.</p>` }) }, { id: 'supervisor', channel: 'email', waitFor: '15 minutes', to: payload.supervisorId as string, render: () => ({ subject: `Escalation: ${task} not acknowledged`, body: `<p><b>${task}</b> has not been acknowledged by the operator. Please follow up.</p>` }) }, { id: 'manager', channel: 'email', waitFor: '1 hour', to: payload.managerId as string, render: () => ({ subject: `Escalation: ${task} still open`, body: `<p><b>${task}</b> remains unacknowledged up the chain.</p>` }) }, ], onExhausted: () => ({ subject: `Unacknowledged: ${task}`, body: `<p><b>${task}</b> was never acknowledged. Logged as a breach.</p>` }), });});Trigger it, then acknowledge
Section titled “Trigger it, then acknowledge”Start a run — the response carries the run id:
curl -sS "$PALUMB_BASE_URL/v1/events" \ -H "Authorization: Bearer $PALUMB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "workflowId": "incidentEscalation", "to": { "subscriberId": "operator_1" }, "payload": { "task": "Daily fire-door inspection", "supervisorId": "supervisor_1", "managerId": "manager_1" } }'When the responder acknowledges (clicks a link, hits a button in your app), your backend signals the waiting run with the run id:
curl -sS "$PALUMB_BASE_URL/v1/events/$RUN_ID/signal" \ -H "Authorization: Bearer $PALUMB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "event": "acknowledged", "data": { "by": "operator@acme.example" } }'The run is suspended on exactly one rung at a time, so a single acknowledged
signal resolves whichever rung is currently waiting and stops the climb. If no
signal arrives before the last rung’s timeout, the workflow records the breach.
The flow
Section titled “The flow”flowchart TD
T["POST /v1/events<br/>incidentEscalation"]
R1["rung 1 · notify operator<br/>waitForEvent('acknowledged', 15m)"]
R2["rung 2 · notify supervisor<br/>waitForEvent('acknowledged', 15m)"]
R3["rung 3 · notify manager<br/>waitForEvent('acknowledged', 1h)"]
B["breach · final notice + record"]
A(("acknowledged"))
T --> R1
R1 -- timeout --> R2
R2 -- timeout --> R3
R3 -- timeout --> B
R1 -- "signal" --> A
R2 -- "signal" --> A
R3 -- "signal" --> A
Routing each rung to a different person
Section titled “Routing each rung to a different person”By default a run notifies the subscriber it was triggered for. Pass
step.send(.., { to }) to direct a rung at a different subscriber — that’s
how the supervisor and manager rungs above reach their own inbox instead of the
operator’s. The recipient is any subscriber of your tenant (palumb resolves it
within the tenant; an unknown id fails the send), so one run can walk a real
responder chain — operator → supervisor → manager — with a single shared
acknowledgement: whoever acks first stops the whole climb.
You decide who each rung targets — here the ids come straight from the trigger
payload (supervisorId, managerId), keeping palumb out of your org chart
(events-in / sends-out). You can equally escalate the
channel to the same person (email → later sms → slack) by changing
channel instead of to.
Production durations
Section titled “Production durations”The timeouts above (15 minutes, 1 hour) are real durations the engine parses;
use short ones ('20 seconds') only when exercising the flow locally. palumb
holds the suspended run durably for the whole window — there is no cost to a long
wait and nothing to keep alive on your side.
Related
Section titled “Related”- Welcome email — the wait-and-branch shape this generalizes.
- Durability & Restate — why a multi-day wait is free.
- SDK API —
waitForEvent,send,run.