detached callback context
situation
- backend services use async-local context to thread database access, logging, operation identity, correlation traces across service boundaries — no parameter drilling.
- normal request path works: middleware establishes context, downstream services call
useContext(). - managed-process step runtime invokes step callbacks from its own event loop, outside the parent function's await chain.
- a step body calling
readWorkflow(id)fails with "useContext() called outside async local scope" — the callback was spawned in a different execution context. - global fallbacks (database without correlation) hide the problem and lose trace identity.
- threading every dependency through step signatures spreads platform concerns into domain code.
- failure mode: entering a detached callback without re-entering the boundary loses operation visibility and fails strict context access.
design
- platform boundary establishes strict async local context;
useContextfails if absent. - a small wrapper re-enters that context for each detached step callback, adds process and step trace identity.
- wrapper is also the single place recording durable step status and publishing structured events.
- recording stays fail-open: observability cannot change the result of a successfully executed step.
decision process
first assumption — "context created at process start will be visible in every step" — fails: the managed runtime invokes a callback from its own event source. three reactions:
| option | result | why |
|---|---|---|
| add global fallbacks for database and logger. | rejected. | it hides the boundary error and loses operation correlation. |
| thread every dependency through step functions. | rejected. | it spreads platform concerns through domain signatures and is easy to omit on a new path. |
| re-enter the existing context at the detached callback. | chosen. | the platform-specific fix stays at one boundary; lower services retain strict useContext() access. |
- the wrapper is where durable step identity is known — the right place to stamp traces and attempt records, not every step body.
why
- the detached callback is the one handoff where context inheritance silently breaks: the runtime invokes it from its own event source, so strict context access throws exactly there and nowhere else.
- tempting escapes make things worse:
- global fallback hides the boundary error but strips every query and log of correlation.
- threading dependencies through step signatures spreads platform concerns into domain code a new path can forget.
- rebinding at the one wrapper that knows the handoff exists keeps the failure explicit and testable: infrastructure dependencies stop widening every service signature, and a step cannot silently run with a global database or an uncorrelated logger.
transferable rule
- use execution context to remove infrastructure prop drilling, but enumerate every asynchronous handoff.
- rebind only at the handoff boundary; keep lower layers unaware of the framework that required it.
verification reasoning
- normal boundary-to-service call must resolve context.
- detached callback must resolve the same request/trace fields after rebinding.
- force recording failure: business step still completes, diagnostic emitted.
- verifies strict context ownership and the explicit fail-open observability boundary.
good and bad
bad
// in the step runtime's event loopconst stepRuntime = managedStepProvider.createStep("write_data");step.do("write_data", async () => {// this callback runs in the runtime's context, not the request's contextconst workflow = await readWorkflowRun(workflowRunId);// ERROR: useContext() called outside async local scope// because readWorkflowRun -> useContext() -> db access fails on boundary checkreturn workflow.output;});
- callback runs detached,
useContext()throws, step fails. - only workarounds: global fallbacks (no correlation) or parameter threading (platform concerns in domain code).
good
// a wrapper binds execution context before the step bodyconst stepRuntime = managedStepProvider.createStep("write_data");const contextBound = createContextBoundStep(context).do("write_data", async () => {// the wrapper re-enters the request-local context// now readWorkflowRun -> useContext() -> db access succeeds with correlationconst workflow = await readWorkflowRun(workflowRunId);return workflow.output;});// the wrapper also records durable step status and publishes structured events// fail-open: if recording fails, the step still completes, diagnostic emitted
- the wrapper is the only place that knows it is invoked outside the normal chain: re-enters the context once, stamps traces and attempt records, keeps lower services unaware they run detached.