Event publishing: the local delivery chain
The atomic, priority-ordered sequence every event walks inside its app. Each step is a runtime.hooks.LocalDelivery.use(fn, {priority}) registration; the runtime runs them in descending priority for one event at a time. LocalDelivery is the one built-in runtime hook — forge's fold steps attach to it when installed:
1000 — idempotency (short-circuits on duplicate dedupKey)
800 — actors (state transitions — folds the event into actor state)
600 — projections (read-model folds)
400 — workflows (correlation + workflow fire)A step that wants to veto subsequent steps sets payload.deduped = true and skips next(). That's how the idempotency step short-circuits the chain for a duplicate event. After the chain (if nothing vetoed), the runtime fans out to when listeners.
Cross-process delivery is not a chain step. The bus and the outbound sinks are the outgoing leg of publish, applied after local delivery completes — and only for events marked .public(). emit never reaches them. See events and listeners for the emit / publish split.
Why a single chain?
Every write that mutates the system passes through these participants. Putting them in one chain at fixed priorities means:
- The order is structural, not negotiated. A new sub-plugin attaches at its slot and runs in the right place no matter when it was installed.
- The chain is atomic per event. If actors throw, projections don't fold; if projections throw, workflows don't fire. Failure is loud and observable.
- The observer slot (any priority outside the forge band, e.g.
-1) is a passive seat — observers see every non-deduped event and emit metrics, audit, drift detection without changing flow.
Forge's participants
When you install the forge plugin set via forgePlugins(...), each concern plugin attaches its own step at the priorities above. The action-specific hook slots are materialised by actionsPlugin:
runtime.defineHook("ActionDispatching");
runtime.defineHook("ActionCompleted");
runtime.defineHook("ActionFailed");
runtime.defineHook("EventRecording");
runtime.defineHook("EventRecorded");The sub-plugins compose à la carte: idempotencyPlugin, actorsPlugin, projectionsPlugin, and workflowsPlugin each attach their fold step at the fixed priority above; actionsPlugin owns the command pipeline and the shared publish path; queriesPlugin and dlqPlugin don't touch the chain at all (reads and the dead-letter sink live outside it). Pick the ones you need, leave the rest. See forge-subplugins for a runnable example.
Attaching an observer
Observers run after every forge step (they slot in at a low priority like -1). They must always call next() and never modify the payload — they're passive.
runtime.hooks.LocalDelivery.use(
async (payload, next) => {
if (!payload.deduped) {
metrics.increment(payload.eventName);
}
await next();
},
{ name: "metrics.event-published", priority: -1 },
);See the event-observer example for a full plugin.
Dedup semantics
The payload.dedupKey is the envelope's messageId (or a derived key when one envelope emits multiple events). The idempotency step's recordIfNew(dedupKey) is atomic — concurrent publishes with the same key see exactly one winner.
Swap the in-memory store for a Redis-backed one by overriding the idempotencyStore option on forgePlugins or idempotencyPlugin.
Telemetry
Every published event ends in one of two records:
event.published— the chain ran to completion.event.deduped— the idempotency step short-circuited.
Both carry the event, the envelope, and the source ("in-process" for local publishes, "external" for events arriving from the bus or source chain). A locally emitted event records event.emitted instead. Studio's Trace page stitches them by correlationId.
See also
- Idempotency
- Capability
- Outbound sinks
- The
examples/forge-subpluginsdemo, which composes the chain from individual sub-plugins.