Troubleshooting
Symptom-first answers to the errors and stuck states you will actually hit — NonDeterminismError, NestedWorkflowCallError, a caught suspend that corrupts a run, runs wedged in pending, running or suspended, timers and schedules that never fire, waitForRun hanging across pods, jobs stuck in active on the queue transport, steps that run twice, node ace processes that hang or steal work, schema and dashboard surprises, and the failures fixed by upgrading to 0.50 or 0.51.
This page is organized by what you see — an error name, a stuck status, a hung command — rather than by subsystem. Each entry names the symptom, why it happens, and the fix. For the operator's map from a stuck run to the knob that heals it, see Failure modes & recovery; for the pre-flight checklist, see Running in production.
Two commands answer most first questions:
node ace durable:runs --status=suspended # what is parked, how long since it moved, its oldest pending remote step
node ace durable:runs --stale=1h # only runs that look stranded, each with a durable:retry hintNonDeterminismError on resume
Symptom: a run that was replaying (resumed after a suspend, or recovered after a crash) fails with:
non-determinism at checkout:42#3: code expects "refund" but history recorded "charge". The workflow
changed under an in-flight run — register a new workflow version.Cause: replay pairs each step with the checkpoint recorded at its logical position (seq). The engine throws the moment the name at a position no longer matches — the workflow's code changed shape (a step inserted, removed or reordered) while this run was in flight, without a new version. Continuing would feed the wrong checkpoint into the wrong step, so it fails loudly. Other common causes of the same error:
- a stale worker — an old build still running against a shared store or broker picks up a run whose history its code no longer matches;
- a hand-rolled
catchthat ran cleanup on a suspend (see below); - a non-deterministic value in the workflow body (
Date.now(),Math.random(),process.env) that sent a replay down a different branch.
Fix:
- For a structural change, register the new version alongside the old one: in-flight runs finish on the version they started on, new runs get the newest. For a small change, guard it with
ctx.patched(id)instead. See Versioning & determinism. - Deploy workers together with the web/engine processes, and kill stray local processes pointed at a shared backend — see the stale-worker pitfall in Tenancy.
- Catch it before production: the ESLint plugin flags non-deterministic sources at author time, and
node ace durable:export+assertReplayablereplays a real run's history against your current code in CI (Replay CI).
NestedWorkflowCallError: a ctx call inside a step body
Symptom: a run fails at a step with NestedWorkflowCallError (a FatalError, code nested_workflow_call).
Cause: something inside a ctx.localStep / ctx.transaction / ctx.sideEffect / ctx.now body called a workflow primitive — ctx.startChild, ctx.child, ctx.step, ctx.sleep, … — directly or through a BaseWorkflow.start() / .dispatch() static that resolves the ambient ctx. A step body runs once and then replays from its checkpoint without re-running, so the nested call would claim a journal position that no replay claims again. Before this guard the run kept going and died later with a NonDeterminismError, far from the cause.
Fix: move the call out of the step body into the workflow body (start the child before or after the step, passing what the step computed). From a dispatched step handler, OtherWorkflow.dispatch() starts a top-level run — handlers never see the workflow's ctx, in-process transports included. See Workflow primitives inside a step body.
A caught suspend runs compensation and corrupts the run
Symptom: a workflow with its own try/catch around a ctx.step (or ctx.sleep, ctx.waitForSignal, …) runs its cleanup on what was an ordinary suspend — a compensation fires on a run that was simply sleeping — and the run later fails on resume with NonDeterminismError.
Cause: a durable sleep, a still-pending step and ctx.continueAsNew unwind the current turn by throwing a control-flow signal. A catch that does not tell the two apart treats the suspend as a failure and records extra steps into history that a later replay never produces.
Fix: rethrow control-flow signals untouched before running any cleanup:
import { isWorkflowControlFlowSignal } from '@adonis-agora/durable'
try {
await ctx.step('chargeCard', input)
} catch (error) {
if (isWorkflowControlFlowSignal(error)) throw error // suspend / continue-as-new: rethrow as-is
await ctx.step('refund', input) // a real failure — safe to clean up
throw error
}Better still, use the built-in compensate option (Sagas), which only ever runs on a genuine failure. The lint rule rethrow-control-flow-signals reports a catch missing this guard. See Catching errors.
workflow … is not registered
Symptom: start throws workflow <name> is not registered, or a resume fails with workflow <name>@<version> is not registered — keep the prior version deployed so in-flight runs can drain (skew protection).
Cause:
- A version was removed while runs were still on it. A run always replays on the version it started on; the second message is the engine refusing to replay it against a different one.
- Discovery registered nothing. The class must be exported from a module under
workflowsPath(defaultapp/workflows) with astatic workflow = { name, version }.workflowsPath: falseturns discovery off. When the generated barrel.adonisjs/durable/workflows.jsexists, the provider registers from it and skips the directory scan, so a barrel older than your newest workflow misses it — it is regenerated by the Assemblerinithook whenserve/buildstarts. Releases before the discovery fix skipped every.tsmodule in a dev app silently — upgrade if you are on one. - Rolling-deploy skew. On a shared broker, a step result can be consumed by a pod that does not have the workflow yet. The checkpoint is still written, and the next recovery tick on a pod that does have it replays past it.
Fix: keep the old version registered until node ace durable:runs --workflow=<name> shows none of its runs in flight, then drop it; check the export and path for discovery. At boot the provider also warns, once, about onError routes naming a handler workflow that nothing here or in the live fleet serves — read that warning as the same typo caught earlier.
A run is stuck in pending
Symptom: a run sits at pending instead of moving to running within moments.
Cause: pending means persisted, not yet picked up. On the default dispatcher the starting process picks it up on a microtask, so a lingering pending means that did not happen and no worker loop picked it up afterwards:
- No dispatcher executes it, and no loop is running.
role: 'control-plane', or arunDispatcherthat does nothing, leaves every startpendingforengine.runPending()— which only the worker loop calls. With nodurable:workand noworker: { embedded: true }, nothing ever does. - The starting process died or was draining. A process that is shutting down refuses to pick up new runs, and one that crashed between the write and the pickup takes the microtask with it. The run stays
pendingfor the next worker-loop tick. - Namespace mismatch. The poll paths only act on runs in the engine's own
namespace. A run started withopts.namespace(or from a pool) that no engine polls just sits — see Tenancy.
Fix: make sure at least one process ticks the worker loop (node ace durable:work, or worker: { embedded: true } on a web process) in the run's namespace. The next tick picks the run up with no manual action.
A run is stuck in running
Symptom: the dashboard shows running and nothing moves, typically after a deploy or a crash.
Cause: the process that held the run's lease died. A live executor renews its lease every leaseMs / 2; a dead one stops, and recoverIncomplete() reclaims the run once the lease lapses — but only a ticking worker loop calls recoverIncomplete(). If the run's recoveryAttempts keeps climbing, it is being reclaimed and crashing the process every time: a poison pill.
Fix: run a worker loop and wait about leaseMs (default 30 s). For a poison pill, set maxRecoveryAttempts so it moves to dead instead of crash-looping, then fix and node ace durable:retry <runId>. A run that is genuinely busy in a long ctx.localStep also reads running — its lease keeps renewing, so recovery leaves it alone. A run found running with a remote step still in flight is parked back to suspended instead of counted, so it never climbs toward dead while its worker is busy.
A run is stuck in suspended
Symptom: the run is parked at suspended and never resumes.
Cause: suspended means it is durably waiting on something — a ctx.sleep timer, a ctx.waitForSignal token, a child, a webhook, or a dispatched ctx.step result. Find out which: the dashboard's run graph shows the pending node, and durable:runs shows the age of its oldest pending remote step. Then:
- A timer that never fires — see the next entry.
- A signal token mismatch.
waitForSignal(token)waits on a global string, not a run-scoped one. A sender that spells the token differently delivers to nobody — the payload is buffered under its own token and waits for a matching waiter that never comes. Derive the token from something both sides know (approve:${orderId}). - A lost remote dispatch. The worker died holding the job, or the transport dropped it: the step's checkpoint sits
pendingforever and, by design, the engine will not guess that a slow worker is dead. That is the stranded signature.
Fix: deliver the right signal (engine.signal(token, payload)); for a stranded step, engine.redispatchPending(runId) re-enqueues its pending remote checkpoints now, and remoteRedispatchMs self-heals future ones. node ace durable:runs --stale lists the candidates, and engine.onStalled pages you about them. If the wait can never be satisfied, cancel the run.
Timers, sleeps and schedules never fire
Symptom: ctx.sleep never wakes, a retry backoff never retries, a { timeoutMs } signal wait never times out, an executionTimeout never cancels, or a scheduled workflow never starts.
Cause: all of them are driven by the worker loop's phases (resumeDueTimers, sweepTimeouts, schedules), not by in-process timers. A web process does not run that loop unless you ask it to:
- no
node ace durable:workprocess, and noworker: { embedded: true }; worker.embeddedset, but the process is not thewebenvironment (the embedded loop deliberately never starts in a console process);- the loop runs, but in a different
namespacefrom the runs; - for runtime schedules created through
engine.schedules,persistedSchedules: trueis not set; forcronschedules, the optionalcron-parserpeer is not installed.
Fix: run the loop where the runs live — see Scheduling → Running the loop in the web process and durable:work.
waitForRun or Workflow.start() hangs on a multi-pod deployment
Symptom: await engine.waitForRun(runId) — or the blocking BaseWorkflow.start(input), which is built on it — never returns (or hits its timeout), although the dashboard shows the run completed.
Cause: waitForRun reads the run once, then waits for its lifecycle events on this engine instance. Without a control plane, events stay on the process that produced them. If the run suspended and was resumed elsewhere — by durable:work after a ctx.sleep, by a signal handled on another pod, by a worker that consumed the step result — the waiting process never hears it settle.
Fix: add controlPlanes.redis so events fan out to every pod, or don't block a request on a long-lived run: dispatch it, return the run id, and poll getRun.
Cancelling a run does not stop the work in progress
Symptom: you cancel a run, its status reads cancelled at once, yet the step it was executing on another pod keeps going.
Cause: the cancel itself is durable — the run row is cancelled, the executing turn's later settle is refused, and nothing resumes it again. What reaches the executing process is a broadcast, and without a control plane it is local only. Even with one, the engine does not abort user code by itself: it fires engine.onCancel(listener) on every instance, and in-flight work stops only if you wired a listener that aborts it.
Fix: add a control plane for cross-pod delivery, and use engine.onCancel to abort long work cooperatively. Make steps idempotent: a step that finishes after the cancel has done its side effect.
Jobs stuck in active on the queue transport
Symptom: on transports.queue(...), jobs sit in the backend's active state, checkpoints stay pending, and nothing completes.
Cause: in rough order of likelihood:
@adonisjs/queue0.7 on a durable release older than 0.45. The 0.7 adapter contract finishes a claim by its lease token; older durable releases passed the job id, which on Redis finished nothing and left every jobactive(and the stalled sweep then delivered it again, running each step twice).- A worker vanished mid-step. The stalled-claim sweep re-delivers a claim once it has gone unrenewed for
stalledThresholdMs— 2 minutes by default (30 minutes on releases before claim renewal, or on an adapter withoutrenewJobs) — and fails a job permanently aftermaxStalledCount(default 3) reclaims. Adapters withoutrecoverStalledJobsare skipped, so their jobs are never re-delivered. - A short-lived process claimed it and died. Before
consumersexisted, anynode acecommand consumed broker jobs; the symptom wasactivejobs stamped with the ids of long-gone commands.consumers: 'always'brings that behaviour back. - Older releases' results loop. A result whose resumed turn awaited the next
timeoutMsstep on the same serial loop could deadlock until a liveness timer fired; fixed in 0.38.2.
Fix: upgrade to a current release; wait out (or lower, carefully) stalledThresholdMs; for an adapter that cannot reclaim, engine.redispatchPending(runId) re-drives the run from its checkpoint, which is the source of truth. Keep consumers at 'auto' (or 'never' on web pods). See Stalled-job reclaim.
A step runs twice
Symptom: a side effect happens more than once for one logical run — an email sent twice, a charge attempted twice.
Cause: rarely a completed step re-running on replay — a completed checkpoint short-circuits replay. The usual suspects:
- The side effect lives in the workflow body, not in a step. The body re-runs from the top on every replay; only step results are checkpointed.
- A redelivery window shorter than a silence. On the queue transport, claims are renewed while a step runs, so only a worker that stops renewing — crashed, or its event loop blocked longer than
stalledThresholdMs— gets its job re-delivered; the transport reports that toonErroraslost N of M claim(s). LikewiseremoteRedispatchMsbelow a step's real duration re-dispatches a live, non-heartbeating step. - A crash between the work and its checkpoint. If a process dies after a worker ran a step but before its result was recorded, the step runs again on recovery. That is inherent to at-least-once execution.
Fix: move side effects into ctx.localStep / ctx.step (or ctx.sideEffect for a value); raise stalledThresholdMs above the longest event-loop block and remoteRedispatchMs above the longest legitimate step; make handlers idempotent on the stable stepId (runId:seq) they receive.
A node ace command hangs, or waits forever for a step
Symptom: a script that starts a run and awaits a remote step never gets the result; or a command such as node ace codegen never exits.
Cause:
- The script is a pure producer. With the default
consumers: 'auto', aconsole/replprocess does not start the broker consumer loops — on purpose, because a one-off process that consumes claims jobs it then dies holding, steals results addressed to the long-lived engine, and never exits while jobs are queued. So nothing in the script hears the result. - A warmup command on an older release built the engine at boot, opening queue, Redis and database connections nothing closed. Fixed in 0.45.
Fix: start the run and let the worker fleet do the work, or set consumers: 'always' for a script that genuinely must round-trip a step inline (it competes with the fleet while it runs). Upgrade for the warmup hang. See consumers.
Web pods take step jobs and results away from the workers
Symptom: on the queue transport, steps execute on web pods, or results are consumed by pods that then have to resume runs mid-request.
Cause: web processes consume eagerly, and the queues are point-to-point, so each delivery lands on whichever process polls first. See Delivery under multiple instances.
Fix: set consumers: 'never' on the web fleet — durable:work re-enables consumption for itself. Every broker transport honours this gate: queue since 0.17, db and bullmq since 0.50 (before 0.50 they consumed in every process that booted them — see the upgrade note).
Durable jobs fail under queue:work
Symptom: after pointing node ace queue:work at every queue, durable step jobs end up failed.
Cause: durable's jobs are never meant for queue:work. On @adonisjs/queue 0.7 a worker that meets a job class it does not know puts it back up to unknownJobRetries times, then fails it.
Fix: keep queue:work off durable's <prefix>:… queues (default prefix durable); durable:work serves them.
A run went dead
Symptom: the run's status is dead, a terminal state distinct from failed.
Cause: maxRecoveryAttempts is set and the run exceeded it: crash recovery kept resuming it, and it kept taking the process down — a deserialization bug, an unguarded non-deterministic change, an infinite loop. The engine stopped touching it, with a max_recovery_attempts error.
Fix: dead is terminal but not lost. Diagnose from its history, ship the fix, then node ace durable:retry <runId> (or the dashboard's Retry). The retry resets recoveryAttempts to 0, so the run is not dead-lettered again by the next recovery pass. Use engine.onDead or a DLQ workflow for alerting. See Dead-letter queue.
A step fails on its first attempt, or after exhausting retries
Symptom: either the run fails after a step used up its retries, or it fails on the very first attempt even though retries is higher.
Cause: both are correct behaviours. retries defaults to 1 (a single try); an ordinary throw is retried up to that budget with the step's backoff. A FatalError is never retried, whatever retries says — NestedWorkflowCallError is one too.
Fix: for instant failures, check whether the step or a library it calls throws FatalError for that condition. For slow failures, raise retries / backoffMaxMs if the downstream is expected to recover. See Retries & backoff.
SignalTimeoutError
Symptom: ctx.waitForSignal(token, { timeoutMs }) throws timed out after 259200000ms waiting for signal "approve:order-42".
Cause: expected — it is how a bounded wait reports that its deadline passed before a signal arrived. (ctx.waitForEvent with timeoutMs throws the same error.)
Fix: catch it and branch:
import { SignalTimeoutError } from '@adonis-agora/durable'
try {
const decision = await ctx.waitForSignal<Decision>(`approve:${order.id}`, {
timeoutMs: 3 * 24 * 60 * 60 * 1000,
})
return decision.approved ? { status: 'approved' } : { status: 'rejected' }
} catch (err) {
if (err instanceof SignalTimeoutError) return { status: 'expired' }
throw err
}A bounded wait takes two logical positions (the deadline and the wait), so adding or removing { timeoutMs } on a wait with runs in flight is a shape change: treat it like a version change. See Sleep & signals.
node ace migration:run hangs on the durable migration
Symptom: the durable tables migration never finishes, typically on SQLite or any pool: { max: 1 } setup.
Cause: createDurableTables checks out its own connection from the pool. Inside the migrator's transaction, the single connection is already held, so it waits until the acquire timeout.
Fix: keep static disableTransactions = true on the migration (the published one has it). See Lucid → Run the migration.
A boot warning says the schema was repaired in place
Symptom: at boot, @adonis-agora/durable: repaired the durable schema in place — added ….
Cause: the database was behind the installed library version, and autoSchema (on by default) added the missing columns or indexes at runtime. Nothing recorded that in your migration history.
Fix: run node ace migration:run so the change is recorded like any other. If your database user may not run DDL at boot, set autoSchema: false and run the migration after every upgrade. (On releases before 0.38.3, an index repair run inside an open transaction could abort it — current transaction is aborted on every later statement — upgrade if you see that.) See Lucid → The repair warning.
The dashboard denies access, or shows no runs
Symptom: the console answers with its access-denied page, or loads with an empty run list.
Cause:
- Access denied. The default
authorizefails closed in every environment: it requires a bearer token equal toDURABLE_DASHBOARD_TOKENand denies everything when that variable is unset. - No runs. With
scopeReads: true(and anamespace), the engine reads through a store view that only sees its own namespace, so runs stamped with another namespace are invisible to this dashboard by design. With nostoreconfigured, the engine uses the in-memory store, so each process only sees its own runs — and loses them on restart. Two pools on different namespaces, or a dashboard pointed at a different Lucid connection, also see different sets. - Empty worker health.
engine.workerHealth()and the/api/healthpanel read group health off the transport, and onlybullmq,queue(with a worker-key store) anddbreport it; oneventEmitterit is empty, not "healthy".
Fix: set the token or your own authorize (Dashboard → Authorization); select the Lucid store and check namespace / scopeReads on the process serving the dashboard; for stranded-run detection off BullMQ, use durable:runs --stale or engine.onStalled.
Two pools execute each other's runs
Symptom: a run started by one deployment executes on another that shares the database or broker, or a direct resume throws NamespaceMismatch.
Cause: both engines left namespace unset, so both are 'default' — by construction the same pool.
Fix: set a distinct namespace per pool. See the two-pools mistake.
Upgrade notes: failures fixed in 0.50 and 0.51
Each entry below is a bug in an older release, not a configuration mistake. If the symptom matches and you are on an affected version, upgrading is the fix; the workaround is for when you cannot upgrade yet.
Upgrade: node ace commands claim jobs on bullmq or db (fixed in 0.50)
Symptom: on transports.bullmq(...) or transports.db(...), steps are claimed by short-lived node ace commands or a REPL that then exit holding them; results meant for the long-lived engine disappear; a boot or migration command with queued jobs never exits. On bullmq a claimed job stays held until BullMQ's stall check, on db until the row lease expires.
Cause: before 0.50 only the queue transport honoured consumers. bullmq and db started consuming in every process that booted them.
Fix: upgrade to 0.50. With the default consumers: 'auto', console and repl processes are pure producers on every broker transport, and durable:work re-enables consumption for itself (CLI → durable:work). Before upgrading, avoid running node ace commands against a production broker while jobs are queued.
Upgrade: a redis:// connection string is ignored on bullmq (fixed in 0.50)
Symptom: transports.bullmq({ connection: 'redis://host:port/db' }) boots fine, but every client connects to 127.0.0.1:6379 — jobs pile up in a local Redis (or the connection fails) while the real one stays empty.
Cause: before 0.50 the string passed validation and was then spread into the ioredis options character by character, so the URL was lost and ioredis used its defaults.
Fix: upgrade to 0.50: a redis:// / rediss:// URL is parsed into host, port, credentials, db and TLS, and a malformed URL throws at boot. Until then, pass an options object ({ host, port, password, db }) or an ioredis instance. See BullMQ → Connection.
Upgrade: the eventEmitter transport crashes the process with an unhandled rejection (fixed in 0.50)
Symptom: on transports.eventEmitter(...), the process dies with an unhandled promise rejection when the store hiccups while a step result settles, and the result is lost.
Cause: before 0.50 the engine's result and heartbeat handlers were called as void handler(...), so a rejection had no handler — fatal under Node's default --unhandled-rejections=throw.
Fix: upgrade to 0.50: failures go to the transport's new onError option (default console.error), a failed result is redelivered from memory with backoff until close(), and a failed heartbeat is dropped.
Upgrade: a step runs twice on the db transport (fixed in 0.50)
Symptom: on transports.db(...), a step that outlives leaseMs is followed by other steps running twice and writing a second result; one worker holds many task rows while others sit idle.
Cause: before 0.50 the task loop claimed up to batchSize (20) rows per poll, ran them one at a time, and renewed only the row it was running. When an earlier step outlived leaseMs, an idle worker reclaimed the rest of the batch and ran it, and the first worker then ran those rows again.
Fix: upgrade to 0.50: the loop claims only as many rows as it has free concurrency slots, renews every held claim every leaseMs / 3, re-checks a claim before running a row, and writes the result only while it still owns the claim. Note the behaviour change: an unconfigured worker now runs one row at a time, so raise concurrency to run steps in parallel. See DB transport → The claim protocol.
Upgrade: the queue transport never renews claims (fixed in 0.50)
Symptom: on transports.queue(...), a step that runs longer than stalledThresholdMs is re-delivered and runs twice, while a crashed worker's job is not recovered for half an hour.
Cause: before 0.50 the transport drove the @adonisjs/queue adapter directly, bypassing the Worker heartbeat, so a claim's age was the step's runtime. The 30-minute stalledThresholdMs default had to exceed the longest step.
Fix: upgrade to 0.50: the transport calls adapter.renewJobs every half stalledThresholdMs for its in-flight claims, and the default drops to 2 minutes on adapters that can renew. During a rolling upgrade an old worker that does not renew can have a long step reclaimed by a new instance's 2-minute sweep; pin stalledThresholdMs to the old value until every worker is upgraded if a double run is expensive. See Queue → Stalled-job reclaim.
Upgrade: remote workflow turns hang with several engine replicas (fixed in 0.51)
Symptom: a remote workflow (engine.registerRemote(...), a thin worker, a Python or NestJS worker) works with one engine replica but stalls with two or more: the run stays leased with a turn out, the worker logged the decision, and nothing advances. On db, a second turn can be silently ignored.
Cause: before 0.51 the executor waited for each turn's decision in an in-memory map. On bullmq and queue the decisions queue is point-to-point, so a replica that had not dispatched the turn often consumed the decision, found no waiter and dropped it. Turns were also numbered with a per-process counter, so two replicas could send the same taskId.
Fix: upgrade to 0.51: the engine records a random taskId on the run (awaitingDecisionTaskId) before dispatching, and any replica that takes the decision applies it durably; stale or duplicate decisions are dropped. Set remoteAdvanceSilenceMs so a turn whose worker died is re-dispatched instead of waiting forever. The schema gains a nullable awaiting_decision_task_id column, added by autoSchema or the migration. Before upgrading, run a single engine replica for remote workflows.
Upgrade: a by-name start for a tenant routes to the operator's worker (fixed in 0.51)
Symptom: a workflow started by name with no local registration (convention routing to a live worker group) runs on the bare <name> group — the operator's own worker — even though the run carries a tenant namespace, or fails with workflow <name> is not registered when only the tenant's <name>@<tenant> worker is live.
Cause: before 0.51 convention routing ignored the run's namespace and treated every start as if namespace were absent: it looked for and dispatched to the bare group, the operator's.
Fix: upgrade to 0.51: convention routing is per run, <name>@<namespace> (bare only for an absent or 'default' namespace) — the same rule steps follow — and is re-checked on every start, so an operator starting a tenant's run reaches the tenant's worker. See Tenancy.
Upgrade: a standalone app with no namespace drives every pool's runs (fixed in 0.51)
Symptom: several apps share one database through the Lucid store, and only some of them set namespace. The app that leaves namespace unset recovers, resumes and sweeps runs belonging to the other apps' pools, then fails them with workflow <name> is not registered or runs their code against the wrong app.
Cause: before 0.51 an engine without namespace was the operator on every role. The operator scopes nothing: its poll, recovery and timer sweeps cover every namespace in the store, so a plain standalone app on a shared store acted as the fleet's operator without meaning to.
Fix: upgrade to 0.51. On role: 'standalone' an absent namespace now means the 'default' pool, so the engine drives only 'default' runs (rows written before the upgrade read back as 'default'). Only a control-plane with no namespace is still the operator. If you relied on a standalone app driving every pool, give that duty to a control plane. Before upgrading, set an explicit namespace on every app that shares the store. See Tenancy.
Running in production
The operational checklist for taking @adonis-agora/durable from a single dev process to a real deployment — picking a store and a transport, choosing a topology, who drives timers and recovery, leases and the dead-letter cap, draining on deploy, replay-safe releases, schema ownership, retention, consumers, encryption at rest, and observability.
API reference
Every public member of WorkflowCtx, BaseWorkflow and the authoring decorators, the WorkflowEngine's selected surface, the RunGateway, the run-detail and fan types, the DurableConfig options of config/durable.ts, the driver factories, the observability and command building blocks, the thrown error classes, the run/checkpoint status enums and the package's subpath exports — one line each, distilled from source.