resume
resume(
map?: (exchange: Exchange<Current>) => ResumeRequest | Promise<ResumeRequest>,
options?: { authorize?: ResumeAuthorizer, elevate?: ResumeElevator },
): RouteBuilder<ResumeAcknowledgment>
resume(options: {
authorize?: ResumeAuthorizer,
elevate?: ResumeElevator,
}): 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 site declared (a .defer() step, or the .error() or error hook that parked from the error path), 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.
Changing the authority the continuation carries is a different question with a different hook: elevate.
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.
Elevating within the same identity
A continuation normally runs as the principal that deferred, which came back from the store marked restored and is therefore refused by any route-entry .authorize(). That is the correct default: a shape read off disk is not a credential.
elevate is how an application says "I re-verified this identity just now, and a human lent it one more scope". It answers a different question from authorize, which is why it is a second hook rather than a widened return type: authorize decides who may resume, elevate decides what authority the continuation carries.
craft()
.id('step-up-decide')
.from(http({ path: '/step-up/decide', method: 'POST' }))
.resume(mapper, {
authorize: ({ principal, record }) => principal?.email === record.meta.approver,
elevate: ({ deferred, payload }) => applyStepUp(deferred, parseDecision(payload)),
})
It returns the live principal the continuation runs with, or throws to refuse. Refuse, never reconcile: if the person has left, their roles changed, or the agent's registration differs from what parked, throw and let the requester ask again, so the new park carries the new ring.
The identity rule
The framework enforces three things and has no other opinion.
It must be live. A principal that is not live-branded is refused, including the restored one the hook is handed. A re-mint is by construction a fresh verification.
It must be the same two parties. Everything below is compared structurally, on the subject and on the outermost actor, and any difference is RC5056:
The lend is bounded. When the park was raised on an RC5038 refusal, the framework records the scopes that refusal named in a field it owns on the record, never in meta. elevate may add at most those scopes, and anything wider is RC5056. The executor reads the same field to refuse a second park for scopes a lend was already asked for, so a lend that does not satisfy the gate cannot ask a human forever.
Where it runs
Immediately after authorize (or in its place when the door declares none), above the record's lifecycle disclosure and above the claim. Its result is held and applied after the claim is won. Below either settling transition, a refused caller could burn the rightful principal's single-use link and drive the approver notification with a credential that was never theirs.
Because the principal is live, the continuation re-runs the route's .authorize(). The lent scope has to satisfy the gate that refused it, or the resume fails the way the original call did.
What a lend can and cannot reach
A scope check reads the SUBJECT's ring unless the gate opts in with effective: true, and then the outermost actor's ring and no further. A lend landed on the actor's ring is therefore read only by a gate declaring effective: true; a subject-ring-only gate that refused a member is refused by design, and no lend to the agent helps. The effective flag on the RC5038 cause is what lets a handler decline to raise a step-up that could never succeed.
claims is outside the compared set, so a continuation whose gate is authorize({ predicate }) and whose predicate reads principal.claims sees the ELEVATED principal's claims, which are whatever the door minted, not the claims the exchange parked with.
resumedBy still records the door's own live principal, not the elevated one: who resumed it and what the continuation ran as are two different facts.
Related
.defer()-- the other half.recovery.defer()-- parking from the error path, which is what raises the parkselevatecompletes..authorize()-- guarding the ingress.- Configuration → deferral -- the store and the signing secret.