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' }) },
})

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!),
})

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 factstore and stores are typed never, so naming one fails to type-check:

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
  // 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: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
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 pendingthe 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

On this page