resume
resume(
map?: (exchange: Exchange<Current>) => ResumeRequest | Promise<ResumeRequest>,
options?: { authorize?: ResumeAuthorizer },
): RouteBuilder<ResumeAcknowledgment>
resume(options: {
authorize?: ResumeAuthorizer,
}): RouteBuilder<ResumeAcknowledgment>
Revive an exchange deferred by .defer() and run its continuation.
// Preferred: map the ingress exchange to the payload it carries.
craft()
.id('approval-replies')
.from(mail('INBOX'))
.authenticate(mailPrincipal)
.resume((ex) => ({
token: tokenFrom(ex.headers['routecraft.mail.subject']),
result: { approved: /^yes/i.test(ex.body.text ?? '') },
}))
.to(log())
// Fallback: the body is already shaped { token, result }.
craft().id('resume-api').from(http({ path: '/resume', method: 'POST' })).resume()
.resume() addresses an exchange, not a route. direct('x') names a route and enters it through its source; resume names one deferred exchange and re-enters its pipeline partway down. That is what lets a mail-born exchange be continued by a chat-born resume: the original source takes no part in execution two, because sources create exchanges rather than revive them.
Any route that uses .resume() is a resume ingress: an HTTP webhook, a mail-reply parser, an ops CLI. There is no special resume transport, and the route need not end there: steps after the resume see the acknowledgment and can reply on the caller's own channel.
The boundary
The mapping function owns shape: find the token, build the payload. Only the ingress route knows what its transport looks like.
Revival owns validation: only the deferral knows the schema the deferring step declared, so the payload is checked against the live schema read back off the route.
This route owns authorization, through authorize. See Securing resume.
The door's own options are deliberately not on the mapper's request: the mapper shapes an attacker-controlled payload, so letting it supply the hook or name the principal would let the untrusted half of an ingress choose what the trusted half checks.
What the ingress route receives
The revived route runs to completion before .resume() continues, so the acknowledgment it puts in the body reports how execution two actually ended, and the ingress route can reply on the caller's own channel.
{
"status": "resumed", // or "duplicate"
"deferralId": "3f1c…~0",
"routeId": "payout", // the deferred route, not this one
"continuation": { "status": "completed", "body": { "paid": true }, "at": "…" }
}
continuation.status is how execution two ended, so it is not always completed: a continuation that reaches a second .defer() reports deferred and carries no body, because that body would be the SECOND deferral's acknowledgment and handing approver A approver B's resume token is not a receipt. dropped and failed are the other two.
A duplicate resume (an approver double-clicks, a webhook is redelivered) returns the first one's cached continuation result with status: "duplicate" and re-runs nothing.
Securing resume
The token proves this deployment minted it. It does not prove its holder may resume.
Routecraft ships no answer to who may. It has no notion of an approver, a role, a four-eyes rule, or an escalation, because how approvals work is your design and every framework that guesses gets it wrong for somebody. What it ships instead is the one thing you cannot build from outside: the decision runs before the store's compare-and-swap, so a refusal never spends the rightful principal's single-use link, and before the record's lifecycle is disclosed, so a refused caller cannot even learn whether the deferral is still open.
The whole contract in one line: the acknowledgment carries the contract, the record carries the context, and nothing spends the link except a valid, authorized resume winning the claim.
craft()
.id('approvals')
.from(http({ path: '/approvals', method: 'POST', auth: 'required' }))
.throttle({ rate: 20, per: 'minute', mode: 'reject' })
.resume(mapPayload, {
authorize: ({ principal, deferred, payload, record }) =>
yourPolicy(principal, deferred, payload, record),
})
.to(log())
Return false or throw to refuse. Both, and a hook that never settles, produce one RC5056 with one message: a hook whose failures can be told apart from outside is an oracle for what it knows. The log distinguishes them and a thrown cause never reaches the wire. That boundary log binds the refused principal's subject and, for a throw, the error itself; a deployment whose hooks may embed payload data in thrown errors can strip those paths from its logs with the logger's opt-in LOG_REDACT facility. An async hook is bounded by this route's own lifecycle rather than by a framework knob: it is cut short when the route stops, and sooner if the route declares a .timeout(). A hook cut short that way is logged as the refusal, while the caller sees whichever lands first, the refusal or the route's own timeout error; neither leaks the cause. The deferral's deadline is also re-checked once the hook resolves, so a resume that arrived in time and then sat behind a slow hook reports RC5047 rather than a refusal.
With no hook, the door is bearer: any holder of a valid token may resume. That is the historical behaviour and it stays the default. Resume is securable, not secured; a public door with no hook is an unauthenticated endpoint, and the .throttle() above is the only thing between it and your store.
meta is the defer site's own channel to the hook. It rides the record, never the acknowledgment, so a policy snapshot cannot be read by the party the hook exists to judge.
The continuation runs as the deferred principal
The hook gates who may inject the payload and receive the result. It does not change whose authority the rest of the route executes under: the continuation runs as the principal that deferred, restored from storage, with the resuming principal recorded as resumedBy. An approver who may resume a payout is not thereby granted the deferred run's authority. A restored principal fails a downstream route-entry .authorize() with RC5043 precisely so this cannot be confused. That is the route operation, not this hook: the hook receives deferred on purpose and never refuses it by itself.
On the agent surface, meta is model-influenced
A tool handler supplies meta on an agent deferral, and that handler ran inside a model loop that has read whatever untrusted tool output is in its thread. Treat it as what the defer site chose, not as a fact the framework vouches for, and do not let it be the only input a hook decides on.
Patterns
Each of these is real running code, proven in packages/routecraft/test/securing-resume.bun.test.ts on both its accept and its refuse path.
Four eyes. The principal that deferred may not resume their own run. Both subjects must be present, or two anonymous parties would count as two different people:
authorize: ({ principal, deferred }) => {
const resuming = principal?.subject
const requester = deferred?.subject
if (!resuming || !requester) return false
return resuming !== requester
}
Scope gate. The defer site records what the resuming principal must hold, so the requirement is the one in force at deferral time:
// .defer({ schema: Approval, meta: { requires: ['payouts:approve'] } })
authorize: ({ principal, record }) => {
const required = (record.meta as { requires?: string[] })?.requires
// No recorded requirement is a defer-site bug, not a grant. Without this,
// `[].every(...)` returns true and a site that forgot its `meta` opens
// the door to every token holder.
if (!required?.length) return false
const held = new Set(principal?.scopes ?? [])
return required.every((scope) => held.has(scope))
}
That guard is the pattern, not decoration. Every hook you write is the whole gate: there is no framework default behind it to catch a case you did not handle, which is the cost of the framework not having an opinion.
Channel segmentation. Several classes of deferral share a context under different transport auth, and each door serves only its own:
// .defer({ schema: Approval, meta: { channel: 'finance' } })
const servesChannel = (channel: string) => ({
authorize: ({ record }) => (record.meta as { channel?: string })?.channel === channel,
})
craft().id('finance-door').from(financeIngress).resume(mapPayload, servesChannel('finance'))
craft().id('ops-door').from(opsIngress).resume(mapPayload, servesChannel('ops'))
Policy travels with the deferral. The defer site snapshots its policy onto the record; the door enforces the record's copy, so editing the defer site never reaches records already deferred:
// .defer({ schema: Approval, meta: { fourEyes: true } })
authorize: ({ principal, deferred, record }) => {
const policy = record.meta as { fourEyes?: boolean } | undefined
if (!policy?.fourEyes) return true
const resuming = principal?.subject
const requester = deferred?.subject
return Boolean(resuming && requester && resuming !== requester)
}
This is a property of where you put the policy, not a framework behaviour. Read it off the live route instead and a deploy will change what deferred records accept.
Same-user continuation. A wizard or a long-running form resumes only for the person who left it:
authorize: ({ principal, deferred }) => {
const resuming = principal?.subject
const requester = deferred?.subject
if (!resuming || !requester) return false
return resuming === requester
}
An anonymous defer site can never satisfy this, and the hook says so rather than matching one absent subject against another.
Threshold by scope. What was submitted decides which scope the hook demands, so a junior approver clears the small payments and escalates the large ones:
// .defer({ schema: Settlement, meta: { threshold: 1000, senior: 'payouts:approve:large' } })
authorize: ({ principal, payload, record }) => {
const { threshold, senior } = record.meta as { threshold: number; senior: string }
// `payload` is the raw submission: schema validation has not run yet,
// so narrow it here rather than trusting its shape.
const amount = (payload as { amount?: unknown } | null)?.amount
if (typeof amount !== 'number') return false
if (amount <= threshold) return true
return (principal?.scopes ?? []).includes(senior)
}
Reading payload is what makes this expressible, and it is raw on purpose: the hook runs first, so a submission it refuses never reaches the validator and never spends the link.
Revival failures
Each of these throws in the ingress route when a .resume() discovers it, so the caller gets a typed error. RC5047 also arrives with no caller at all: the background sweeper retires overdue deferrals on its own schedule and re-enters the deferred route's error channel directly (expiry).
Three of them additionally re-enter the deferred route's error channel, so a route-scope .error() there can notify the approver and re-ask instead of leaving them at a dead link: RC5047, RC5048 and RC5050. Those are changes in the world the deferred route has to react to, and none of them is something a caller can provoke on demand.
RC5049 deliberately stays in the ingress route. A malformed payload is a per-request input error, the deferral stays resumable, and routing it through the deferred route would let anyone holding a token drive that route's re-ask path (approver notifications included) with junk. Shaping a reply to a bad payload belongs to the ingress route's own .error() handler, which is where the caller's channel is.
The two authorization refusals (RC5055, RC5056) stay in the ingress route for the same reason, and go further: both are decided before the settled-state disclosure and before either transition that can settle a record and notify. A refused holder therefore learns only that they were refused, cannot burn the rightful principal's claim, and cannot drive an approver notification with a token they should never have been able to use.
Related
.defer()-- the other half..authorize()-- guarding the ingress.- Configuration → deferral -- the store and the signing secret.