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.
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' }) },
})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.
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!),
})For cross-ecosystem fleets (a Python or NestJS worker on the same control plane), select the bullmq transport — it speaks the aviary wire byte-for-byte. For an Adonis-only split, the queue and db transports work too.
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:
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
// store: 'lucid' // ❌ compile error — a tenant pod may not name a store
})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.
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:workerdurable: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:
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:
| Verb | Purpose |
|---|---|
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 |
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 |
workerHealth() | queue depth and live workers per group |
subscribe(runId, onEvent) | live-tail one run's lifecycle events; returns the unsubscribe |
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, continue, retryWithInput. 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 get/signal/cancel (anti-IDOR), and rejects unknown verbs and tampered tokens.
Next steps
- Tenancy — what
namespaceandpartitionactually partition, and the rules that decide which namespace a new run gets. - Thin workers — the store-less worker runtime, from
durable:workerdown to the turn runner. - Handshake & capability negotiation — keep a mixed fleet version-safe.
- Cross-ecosystem interop — add Python and NestJS workers.
Topologies
Run durable as a single process, split the control plane from the workers, or spread store-less "thin" pods per tenant — one config field, no code changes to your workflows.
Thin workers
The @adonis-agora/durable/worker subpath — WorkerRuntime, the descriptor registry, and the turn/step runners that let a worker pod execute steps and workflow turns without ever importing Lucid or owning a state store.