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.

FieldTypeDescription
tokenstringThe signed token minted when the exchange deferred.
resultunknownThe submitted payload, validated against the deferring site's schema when it declared one.
resumedByPrincipalRefWho resumed it. Defaults to the ingress exchange's own principal, which is the value worth recording: it was verified live here. Set it explicitly only when the resuming principal is not the caller.

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.

OptionTypeDescription
authorize({ principal, deferred, payload, record }) => boolean | Promise<boolean>Decides who may resume. Omitted, the door is bearer. See Securing resume.
elevate({ principal, deferred, payload, record }) => Principal | Promise<Principal>Re-mints the principal the continuation runs with. Omitted, it runs as the restored one. See Elevating within the same identity.

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())
ArgumentWhat it is
principalWhoever this route's .authenticate() resolved, verified live at click time. undefined when it resolved nobody.
deferredThe principal that deferred the exchange, restored from storage and branded as such. Reference data, never a credential.
payloadThe submission exactly as it arrived, before schema validation. Narrow it yourself: a hook that reads into it is reading unvalidated input.
recordid, meta, routeId, deferredAt, expiresAt. Never the deferred body.

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.

Warning

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.

Warning

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.

CodeCause
RC5041The token is malformed or its signature does not verify.
RC5046The token verifies but the store holds no such deferral, or its route is not registered in this context.
RC5047The deferral's ttl elapsed. Raised by a late resume or by the sweeper, whichever reaches it first.
RC5048The steps after the defer point, or the declared schema, changed while the exchange was deferred, so the stored approval no longer authorizes what would run. Refused before any of those steps execute.
RC5049The payload does not satisfy the declared schema. Ingress route only. The deferral stays resumable, so a corrected payload still works.
RC5050The deferral was denied, typically because the run carrying it was cancelled.
RC5055The credential names a different call than the one this record deferred on. Non-destructive: the record stays resumable by the rightful credential.
RC5056This route's authorize hook refused the principal, or its elevate hook returned a principal outside the identity rule. Non-destructive.

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:

ComparedNot compared
(issuer, subject), roles, subjectProfile, email, name, audience, clientId; mayAct on the subject; the actor chain's depth, and every prior actor wholescopes, which is the point; and kind, scheme, expiresAt, claims, userinfoClaims, because a re-mint is a fresh verification and those describe HOW it was verified rather than WHO

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.

Warning

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.