Agora
Cluster

Roles & config

The role-discriminated config/durable.ts, the worker vs api entrypoints for a store-less pod, and layered tenant authentication — with store-less isolation enforced at compile time.

config/durable.ts is a role-discriminated union. You pick the topology with a single role field, and TypeScript narrows the accepted shape on the role literal — so an invalid combination (a store-less tenant pod that names a store) is a compile error, not a runtime surprise.

Standalone (the default)

Omit role entirely and you get standalone — a control plane plus an embedded worker, identical to a config written before roles existed.

config/durable.ts
import { defineConfig, transports, stores } from '@adonis-agora/durable'

export default defineConfig({
  // role: 'standalone' is the default — you can omit it
  transport: 'queue',
  transports: { queue: transports.queue({ connection: 'redis', group: 'durable' }) },
  store: 'lucid',
  stores: { lucid: stores.lucid({ connection: 'pg' }) },
})

A standalone engine with no namespace is in the 'default' pool: it drives only 'default' runs (and legacy rows stored without a namespace), never another pool's on a shared store. Set drive: false on a web pod that should only start runs (pair it with consumers: 'never'): started runs stay pending for a node ace durable:work pod, which drives through its poll whatever drive says, so the web and worker pods can share one config file. drive: false with worker: { embedded: true } throws at boot.

Control plane

A pure coordinator: it owns the store and the transport, dispatches work, recovers crashes, fires timers, prunes retention, and answers thin-pod requests — but runs no embedded worker. Both store and transport are required here.

config/durable.ts (control plane)
import { defineConfig, transports, stores } from '@adonis-agora/durable'
import { hmacTenantVerifier } from '@adonis-agora/durable'

export default defineConfig({
  role: 'control-plane',
  transport: 'bullmq',
  transports: { bullmq: transports.bullmq({ connection: { host: '127.0.0.1', port: 6379 } }) },
  store: 'lucid',
  stores: { lucid: stores.lucid({ connection: 'pg' }) },
  // Optional: verify signed tenant tokens on incoming wire requests (see below)
  verifyTenant: hmacTenantVerifier(process.env.DURABLE_TENANT_SECRET!),
})

Store-less tenant pods need a transport that carries the run-request protocol a ProxyRunGateway and the control plane's RunRequestResponder round-trip over: bullmq, queue or db. The provider checks the transport for those methods rather than its name. For cross-ecosystem fleets (a Python or NestJS worker on the same control plane), select bullmq — it speaks the aviary wire byte-for-byte. On db the pod owns no durable store but reaches the control plane's database for the transport tables; choose it when the pods can reach that database and you want no broker at all.

A control-plane serves no app/steps in-process (only standalone embeds a worker), and unless you set runDispatcher its engine uses a no-op run dispatcher: start() persists the run as pending and returns, and the durable:work dispatch/recovery/timers loop drives it from there instead of running the body inline on the request. A control plane with no namespace is the operator: it drives, recovers and resumes the runs of every pool, which is what lets one control plane serve many tenant pools. It is the only operator there is — a standalone engine never drives every pool. The RunRequestResponder that answers tenant pods starts only when the selected transport carries the run-request methods; on any other transport the control plane simply has no tenant-facing surface.

Tenant (store-less thin pod)

A tenant pod never owns a store. Everything round-trips over the wire via the ProxyRunGateway. The isolation is a compile-time fact — store and stores are typed never, so naming one fails to type-check. The pod and its control plane must use the same transport, on the same broker:

config/durable.ts (tenant)
import { defineConfig, transports } from '@adonis-agora/durable'

export default defineConfig({
  role: 'tenant',
  transport: 'bullmq',
  transports: {
    bullmq: transports.bullmq({ connection: process.env.REDIS_URL! }),
  },
  partition: 'acme-corp',            // which tenant this pod serves (required) — see Tenancy
  tenant: { token: process.env.DURABLE_TENANT_TOKEN }, // its signed claim
  requestTimeoutMs: 10_000,          // ProxyRunGateway round-trip timeout (default 10_000)
  capabilities: ['gpu'],             // optional: extra capabilities the worker advertises
  // store: 'lucid'                  // ❌ compile error — a tenant pod may not name a store
})

The top-level partition reaches all three: the ProxyRunGateway (the tenant it claims on the wire), the worker runtime (the routing tokens its descriptor advertises) and the transport, which subscribes the <name>@acme-corp task queues the control plane dispatches the tenant's steps to. The top-level instanceId is the transport's default too, so the runtime's and the transport's liveness keys carry the same id. A partition or instanceId set on the transport itself wins.

capabilities lists what the pod advertises beyond its handler names in its descriptor; a step's requires is matched against it.

On the queue transport the pod needs no database either: pass workerKeys: false and it forwards its worker advertisement and its live-tail subscriptions to the control plane, which records them in its own key store. See tenant pods over the queue transport.

config/durable.ts (tenant, queue transport)
export default defineConfig({
  role: 'tenant',
  transport: 'queue',
  transports: { queue: transports.queue({ connection: 'redis', workerKeys: false }) },
  partition: 'acme-corp',
  tenant: { token: process.env.DURABLE_TENANT_TOKEN },
})

On the db transport the pod reaches the control plane's database for the transport tables only — it still owns no durable store, and its config still cannot name one. It writes its worker advertisement and live-tail subscriptions to the shared key table itself. See remote workflows and tenant pods.

config/durable.ts (tenant, db transport)
export default defineConfig({
  role: 'tenant',
  transport: 'db',
  transports: { db: transports.db() },
  partition: 'acme-corp',
  tenant: { token: process.env.DURABLE_TENANT_TOKEN },
})

The three layers of store-less isolation: (1) type — a tenant config cannot mention a store; (2) container — the provider registers no store binding for the tenant role, so resolving one throws; (3) object — the WorkerRuntime and ProxyRunGateway have no store field at all.

What each role validates

The checks split into compile-time (the config's type) and boot-time (the provider, when it builds the role's objects):

roleRequiresForbidsChecked at boot
standalone (or omitted)nothing — no store selects the in-memory store, no transport the in-memory transport—Serves app/steps in-process. Starts the RunRequestResponder (and honours verifyTenant) when the transport carries the run-request methods, like a control-plane.
control-planestore and transport (both typed required)—Serves no app/steps. Uses NOOP_RUN_DISPATCHER unless runDispatcher is set. The responder is started only on a transport with the run-request methods (onRunRequest, onStartRun, publishRunReply, publishTenantEvent).
tenanttransport and partition (both typed required); optional tenant.token, requestTimeoutMs, capabilitiesstore, stores (typed never)Resolving the RunGateway throws when the transport lacks the store-less methods (dispatchStartRun, dispatchRunRequest, onRunReply, onTenantEvent). Resolving the worker runtime throws when the transport cannot serve handlers (handle()). No engine and no store are bound, so resolving either throws.

partition is the axis a tenant pod is named by: it suffixes the routing tokens the pod's descriptor advertises and is the tenant the proxy claims on the wire when no token is set (and, unless the transport sets its own, the partition of the queues it consumes). namespace stays a separate, optional field on every role (a run scope; absent, it is 'default' on standalone and every pool on control-plane); a tenant pod passes it to its transport and worker runtime. What each axis actually partitions is Tenancy.

Worker vs api — the entrypoint decides

A tenant pod's shape is not another config field — it's which process you launch. AdonisJS already separates the HTTP server from ace commands, and durable leans on exactly that:

A store-less task consumer. It registers app/steps (served by the transport) and advertises app/workflows names in its descriptor, subscribes their <name>@<tenant> queues, executes bodies, and publishes results. It owns no store.

node ace durable:worker

durable:worker is the store-less loop — distinct from durable:work (the store-backed dispatch/recovery/timers loop). It stays alive until SIGINT/SIGTERM, then drains.

Run both processes on one pod if you want (less isolated; usually kept separate).

Reading and steering runs: runGateway

Every role exposes the same read/control surface, the RunGateway, from services/main:

app/controllers/runs_controller.ts
import { runGateway } from '@adonis-agora/durable/services/main'
import type { HttpContext } from '@adonisjs/core/http'

export default class RunsController {
  async show({ params, response }: HttpContext) {
    const run = await runGateway.getRun(params.id)
    if (!run) return response.notFound()

    return response.ok({ run, timeline: await runGateway.getCheckpoints(params.id) })
  }

  async approve({ params, response }: HttpContext) {
    await runGateway.signal(params.id, `approve:${params.id}`, { by: 'ops' })
    return response.accepted({})
  }
}

This is what makes application code portable across the three roles. On a store role the gateway reads the store directly; on a store-less tenant pod the identical call becomes a wire request answered by the control plane. The controller above is byte-identical either way — you never branch on role, and moving a pod from standalone to tenant changes only config/durable.ts.

The full surface:

VerbPurpose
topology()this pod's role, plus its tenant on a store-less pod. Synchronous — no round-trip.
getRun(runId)one run, or null
listRuns(query)a filtered listing (status, workflow, tag, namespace, search attributes)
getCheckpoints(runId)the run's step timeline
getRunChildren(runId)the ids of the run's child workflows
getRunDetail?(runId, { summarizeFansOver? })the run, its timeline and its children in one read, or null. With summarizeFansOver: n, each fan of more than n members comes back as one FanSummary in fans instead of a row per member, children lists the first 200 ids and childCount the total
getFanMembers?(runId, group, { offset?, limit?, failedOnly? })one page of a fan's members in seq order (limit defaults to 100, at most 500), with the matching total, or null for an unknown run
getSearchAttributes(runId)the run's search attributes
start(workflow, input, opts?)start a run; opts.runId is the idempotency key, and omitting it mints one
signal(runId, signal, payload?)deliver a signal
cancel(runId, opts?)cancel a run, with { compensate: true } to run its saga undo first
redispatchPending(runId)re-enqueue every remote step still pending — the operator escape hatch
retry?(runId)retry a failed or dead run in place: back to pending, a worker resumes it and re-attempts the failed step (engine.requeue)
continue?(runId)resume a run paused at a ctx.breakpoint(), or null when it is not paused at one
retryWithInput?(runId, input)fix and replay: a new linked run (<runId>~retry~<id>) with a corrected input, in the original's namespace
runFacets?(query)[{ status, origin, count }] over the runs matching query: the counts behind status and origin chips, exact while the list is paged
runValueFacets?(axis, query, opts?)the distinct values of one filter axis (workflow, status, namespace, origin, tag, attributeKey, attributeValue) with counts, for pickers
waitingFor?(runIds){ [runId]: { on, name } } for the given runs that are parked on a signal, webhook, child or breakpoint; others are absent
workerHealth()queue depth and live workers per group
subscribe(runId, onEvent)live-tail one run's lifecycle events; returns the unsubscribe

On a tenant pod every verb except topology() and subscribe() is a wire round-trip bounded by requestTimeoutMs (default 10_000). A request whose reply never arrives rejects with an error naming the verb (durable control plane did not answer getRun within 10000ms) instead of hanging. A reply the control plane refuses rejects with an Error carrying a code: unauthorized for an invalid or tampered token, cross-tenant for a run owned by another tenant, unsupported-request for a verb the control plane does not know. Over a tenant proxy, workerHealth() is scoped to the tenant's own groups — the control plane keeps only groups ending in @<tenant>. listRuns, runFacets and runValueFacets are scoped to the tenant's own runs (the query's namespace is replaced, not checked), waitingFor only answers for the tenant's own runs, and retry, continue and retryWithInput check ownership before they act, like every other run-scoped verb.

retry, continue, retryWithInput, runFacets, runValueFacets and waitingFor are the NestJS gateway's verbs, in the same shapes, so a mixed fleet speaks one protocol. They are optional on the interface so a gateway you wrote yourself keeps compiling; both built-in gateways implement all of them. A control-plane gateway that lacks one answers unsupported-request. subscribe() bridges onto the transport's per-tenant event stream, which the responder republishes for the tenant's own runs only.

getRunDetail and getFanMembers are optional on the interface, and every built-in gateway implements them. A custom gateway without them still works: the dashboard and the responder build the same detail from getRun, getCheckpoints and getRunChildren, and the exported loadRunDetail / loadFanMembers helpers do it for your own code.

const detail = await runGateway.getRunDetail?.(params.id, { summarizeFansOver: 50 })
// detail.fans: [{ group: 'steps:3:40000', label: 'processLine', size: 600, total: 40000, counts: { completed: 580, pending: 20 }, … }]

On a store-less tenant pod the dashboard reads run detail through the getRunDetail run-request verb, and pages a fan through getFanMembers, so the control plane summarizes a wide fan before it crosses the wire. Before answering either verb, the responder checks tenant ownership on the run alone, without loading its timeline. A control plane older than the tenant still serves both; see Rolling upgrades.

Rolling upgrades

Tenant pods and the control plane can be upgraded in either order.

TenantControl planeWhat happens
0.50+0.49.xThe old responder knows getRunDetail and getFanMembers but not the six newer verbs. It rejects those with an unknown run-request verb error, which ProxyRunGateway reads as unsupported-request; the tenant console degrades as described in the dashboard docs.
0.51+0.50.xThe old responder does not know runFacets. ProxyRunGateway counts the same (status, origin) cells from runValueFacets (the status axis once overall and once per origin), so the console's chips and backlog gauges keep working.
0.51+0.49.xNo runFacets and no runValueFacets: the proxy counts the cells from a listRuns of the tenant's matching runs, which reads every one of them, so finish upgrading the control plane soon.
0.50+before 0.49Also no getRunDetail / getFanMembers. ProxyRunGateway builds the run detail itself from getRun, getCheckpoints and getRunChildren (each still ownership-checked by the responder), summarizes wide fans on the tenant, and pages fans out of the full timeline.
0.49.x0.50+An older proxy never sends the newer verbs; the new responder answers everything it sends exactly as before.

Every fallback happens on the reply, with no timeout wait. After the control plane answers that it does not know a verb, the proxy uses the fallback for the next minute before it asks again (capabilityTtlMs on a ProxyRunGateway you build yourself, default 60_000): the control plane can be several pods of mixed versions behind one queue, and a reply does not say which pod sent it. The fallback for run detail moves every checkpoint of the run over the wire, so on a run with a very wide fan, finish upgrading the control plane soon.

From 0.50 on, a responder answers a verb it does not know with an error whose code is unsupported-request, before it touches the store, so the next new verb can fall back the same way. And every request gets a reply: a gateway that throws (in listRuns, workerHealth, the ownership read, or a tenant verifier) becomes an error reply, so a tenant no longer waits out requestTimeoutMs for an answer that will never come.

On a store role, engine (the default export of services/main) is still there for everything the gateway does not cover — registering workflows, driving poll loops, getEvent, update, the task verbs. On a tenant pod there is no engine to resolve, which is exactly the point: reach for runGateway in anything that must run on more than one role.

Layered tenant authentication

The tenant on a wire request is a claim. Without authentication, the isolation boundary is meaningless — any pod could ask for any tenant's runs. Durable stacks two layers (spec-driven; use one or both):

Prefix / network baseline

Each tenant runs on a segmented transport prefix/namespace behind a Redis/network ACL, so a pod can only reach its own prefix. This is the aviary model and needs no application code.

Signed token on top

Each tenant pod carries a secret-signed token. The control plane's RunRequestResponder verifies the signature and derives the tenant from the token, ignoring any tenant in the request body. Defense in depth.

import { signTenantToken, hmacTenantVerifier } from '@adonis-agora/durable'

// On the tenant pod — mint the token it presents (config.tenant.token):
const token = signTenantToken('acme-corp', process.env.DURABLE_TENANT_SECRET!)
// Optionally expiring (the `.exp<epochMs>` suffix is signed, so it can't be stripped/extended);
// a captured token then stops replaying after the ttl instead of being valid forever:
const shortLived = signTenantToken('acme-corp', process.env.DURABLE_TENANT_SECRET!, { ttlMs: 24 * 60 * 60 * 1000 })

// On the control plane — verify it (config.verifyTenant). A secret LIST makes rotation a
// two-step deploy: verify with [next, current] first, then re-mint tokens with `next` at leisure:
verifyTenant: hmacTenantVerifier([process.env.DURABLE_TENANT_SECRET_NEXT!, process.env.DURABLE_TENANT_SECRET!])

Without verifyTenant, the wire tenant field is trusted verbatim — any process with broker access can list, signal, cancel and start runs as any tenant (prefix/network isolation only). The responder now logs a loud warning at start when no verifier is configured; treat running without one as a conscious choice that your broker is fully isolated per tenant, not a default.

The RunRequestResponder is the trust boundary. Beyond checking the token, it forces listRuns to the requester's own tenant (no cross-tenant enumeration), validates run.namespace === tenant on every run-scoped verb before it acts (anti-IDOR), stamps the verified tenant as the namespace of every run a pod starts, republishes live-tail events only to the tenant that owns the run, and rejects unknown verbs and tampered tokens.

Next steps

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →