54 KiB
orca
orca is the ecosystem's active orchestration artifact. Its name comes
from ORChestration Active and its job is not to transport events, nor to know
concrete modules, nor to replace bus. Its responsibility is to run declarative
actions when events arrive from the bus, with ordering, dependencies,
results, failure policies, timers, optional transactions and traceability.
The central idea:
modules / server / connection
-> bus publishes typed events
-> orca selects registered actions
-> orca runs a pipeline
-> actions return Result and tokens
-> orca decides to continue, abort, skip or finish
orca does not know that session, cache, perm, connection, storage or
http exist. The application registers actions that call those modules. This
avoids inter-module coupling being hidden inside bus, connection or
active-app.
Document Status
This README is orca's design reference. v1 is 100% closed and the global suite passes (115 files / 1471 tests, 161 on orca between engine and reactive wrapper). The engine exposes:
createEngineOrca({ bus, timers, logger?, maxRuns?, reentry? })— imperative enginecreateActiveOrca({ ... })— Svelte 5 wrapper with reactive snapshots (runningSnapshot,recentRunsSnapshot,latestRun,committedSnapshot,disposedSnapshot)onEvent(event, action) → detachconfigureEvent(event, { queuePolicy })—fifo(default) /replace/drop-latest/parallelvalidate()— static analysis of the graph (issues with severity error or warn)commit()— freezes the graph; a lateronEventthrowsOrcaFrozenErroronChange(listener)— notifies register/detach/run-start/run-end/ commit/dispose for reactive integrations- Canonical stages (
guard / pre / main / post / cleanup / finally) - Per-event run-queue +
OrcaEnvelopewith runtime metadata (eventId,traceId,parentEventId,depth,stack) OrcaActionContext:eventId,traceId,depth,signal,emit(),tokens,tokenPayloads,loggerOrcaResult:success / skipped / error / interrupted / timeout / fatalOrcaRunResultwithtokens,tokenPayloads,actions[]andcompensations[]- Reentry guards:
maxDepth,maxEventsPerTrace,repeatedEventLimit,dedupeKeywith policiesskip / abort-trace / error actionTimeoutMsper action — race against timer + per-action abort, isolated from siblingsOrcaFatalwith precedenceFATAL > TIMEOUT > ABORTED > INTERRUPTED > PARTIAL > SUCCESS- Gates
unless→abortOn→fanIn→after(evaluation order);fanIn: { tokens, min }for k-of-n quorum parallel: trueper action — concurrent waves withPromise.alltransaction: 'tx-id'— atomic groups with immediate LIFO rollback when a member fails- Tokens with payload (
emits: [{ token, payload }]) +ctx.tokenPayloads compensateper action — LIFO rollback beforeFINALLY, best-effort, run-once- Bus interception —
bus.publishfrom within an action is attributed to the active run/action viaAsyncLocalStorage(Node/Bun; root-event fallback in browsers withoutAsyncContext) - Structured diagnostics (
orca.run.*,orca.action.*,orca.event.emitted,orca.reentry.blocked,orca.trace.aborted,orca.compensation.*,orca.queue.dropped) applyStandardOrca(App)preset aggregator inarts/active-app/presets/
What is accepted for v2 is described in the "Roadmap v2" section at the end.
The architectural decisions that underpin the design:
bustransports local events.orcaruns actions associated with bus events.timercoordinates timers, timeouts and clocks.loggerreceives diagnostics/logs.- The application decides which modules each action touches.
- Nothing destructive runs by default without being registered.
- Clean payload, separate runtime envelope: the module never knows
orca; only theOrcaActions receivectx.
Naming
| Concept | Name |
|---|---|
| Artifact | orca |
| Imperative root | createEngineOrca() / EngineOrca |
| Reactive root | createActiveOrca() / ActiveOrca |
| Action | OrcaAction |
| Result | OrcaResult |
| Event execution | OrcaRunResult |
| Semantic token | OrcaToken |
The intended public alias would be:
import { createEngineOrca, orcaSuccess } from '$orca';
What Problem It Solves
Without orca, inter-module reactions tend to end up scattered:
session knows cache
cache knows perm
connection knows session
active-app knows everything
That scales badly. A change of identity, permissions, tenant, connectivity or a
remote event from the future active-server can involve several actions:
APP_EVENT_USER_IDENTITY_CHANGED
-> cancel private HTTP
-> clear private cache
-> invalidate permissions
-> reauthenticate connections
-> clear private storage
-> record audit/diagnostics
orca lets that flow be declared in a single place, testable and traceable,
without the modules knowing about each other.
What It Is Not
orca is not:
- An event bus. That's
bus. - A realtime transport. That's
connection. - A permissions engine. That's
perm. - A cache. That's
cache. - A replacement for
timer,loggeroractive-app. - A durable server-side workflow in the Temporal style.
- A system that decides business logic on its own.
- An internal dependency that other artifacts consume in order to work.
The rule:
orca does not know modules; orca knows events, actions and results.
Ecosystem invariant:
artifacts publish events on bus
the application decides what to orchestrate with orca
session, cache, perm, connection, auth or http must not depend on
orca for their internal flows. If an artifact needs to coordinate its own
internal state, it does so inside the artifact. If an application wants to
coordinate several artifacts when something happens, it registers actions in
orca.
Prior Art
orca takes ideas from several ecosystems, but copies none of them:
| Reference | Usable idea | Difference from orca |
|---|---|---|
| Redux Toolkit listener middleware | listeners, async workflows, cancellation, take, condition, fork |
orca is not tied to Redux or reducers |
| Redux-Saga | concurrency, fork, join, race, takeLatest |
orca avoids generators and uses explicit Result |
| NgRx Effects | isolate side-effects from components | orca does not depend on RxJS or Angular |
| redux-observable | actions in, actions out | orca does not force an Rx stream |
| Effector | events, effects, scopes, allSettled |
orca defines stages, tokens and policies |
| Effect-TS | Result, timeout, retry, schedules | orca must be much smaller and adapter-driven |
| Temporal / Durable Functions | workflows with steps, retries and timers | orca v0 is not durable or server-authoritative |
Concrete ideas worth stealing:
- RTK listener middleware: cancellation and concurrency policies without a generator DSL.
- XState v5
setup(): declare events, tokens and actions before building the runtime to gain inference and validation. - Effect Workflow: separate action/activity from workflow/run, and study explicit compensations.
- Temporal: keep deterministic discipline for a possible future replay/audit.
Mental Contract
An orchestration is defined like this:
Orca.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_CLEAR_PRIVATE_CACHE,
stage: ORCA_STAGE_MAIN,
after: [ORCA_TOKEN_HTTP_PRIVATE_CANCELLED],
abortOn: [ORCA_TOKEN_CACHE_ERROR],
actionTimeoutMs: 2_000,
onError: ORCA_ON_ERROR_ABORT_RUN,
action: async (payload) => {
const result = await App.cache.clearActorScope(payload.previousActorId);
if (!result.ok) {
return orcaError(result.error, {
emits: [ORCA_TOKEN_CACHE_ERROR]
});
}
return orcaSuccess({
emits: [ORCA_TOKEN_CACHE_OK]
});
}
});
orca only sees:
- event received
- registered action
- stage
- required tokens
- emitted tokens
- result
- error policy
- timers
The action is the one that decides to call App.cache, App.perm,
App.connections or any other service.
Typed Setup (roadmap v2)
Not implemented. See "Roadmap v2" below.
The current API is direct: createEngineOrca() or createActiveOrca() with
onEvent() and configureEvent(). Events, tokens and action ids live as
constants exported by the app or by the presets:
const Orca = createEngineOrca({ bus, timers, logger });
Orca.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_CLEAR_PRIVATE_CACHE,
stage: ORCA_STAGE_MAIN,
provides: [ORCA_TOKEN_CACHE_CLEARED],
action: (payload, ctx) => {
// ...
}
});
For v2 a typed wrapper such as setupOrca({ events, tokens, actions }) is being
considered that would infer payloads, validate phantom tokens in gates, restrict
emits to provides and emit a navigable graph. Today that validation is static
and best-effort via Orca.validate() (see the "Validate" section).
Events
orca connects to bus and listens to events declared via constants.
Loose strings must not be used in application actions.
Example of an app event:
export const APP_EVENT_USER_IDENTITY_CHANGED = 'app.user.identity.changed' as const;
Example registration:
Orca.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, ACTION_RESET_PRIVATE_STATE);
The event payload must be safe:
- no tokens
- no passwords
- no secrets
- no authorization headers
- no raw webhook body
- no unnecessary private data
If an action needs sensitive information, it must resolve it from the corresponding module at the moment of running the action.
Actions
An action is a unit of work associated with an event.
interface OrcaAction<TPayload = unknown, TValue = unknown> {
readonly id: OrcaActionId;
readonly stage: OrcaStage;
readonly after?: readonly OrcaToken[]; // wait for these tokens before running
readonly unless?: readonly OrcaToken[]; // idempotency: skipped if any is present
readonly abortOn?: readonly OrcaToken[]; // BLOCKED if any is present
readonly fanIn?: OrcaFanInSpec; // quorum: runs with `min` of `tokens` present
readonly provides?: readonly OrcaToken[]; // tokens it declares it emits (guides validate())
readonly actionTimeoutMs?: number; // per-action timeout → OrcaTimeout + signal abort
readonly transaction?: string; // atomic-group tag (LIFO compensation on failure)
readonly parallel?: boolean; // concurrent wave with consecutive actions of the same stage
readonly onError?: OrcaErrorPolicy; // reaction to OrcaError (CONTINUE by default / ABORT_RUN)
readonly compensate?: OrcaActionFn<TPayload, void>; // rollback if the run aborts after its success
readonly action: OrcaActionFn<TPayload, TValue>;
}
There is no priority, tokenTimeoutMs, onFatal or onTimeout: the
intra-stage order is registration order, the only timeout is actionTimeoutMs,
and an unrecoverable failure is signaled by returning OrcaFatal from the
action (not with a separate policy).
An action must be:
- named with a constant
- idempotent when possible
- testable in isolation
- explicit about its failure policy
- explicit if it needs a transaction
- explicit if it can wait for tokens
- safe with respect to payloads carrying sensitive data
Stages
Stages give structure to the pipeline. Within a stage the order is
registration order (there is no numeric priority); to order by dependencies
use tokens (after / provides), and for intra-stage concurrency, parallel.
guard validates preconditions
pre prepares the environment and cancels incompatible work
main runs the main effect
post derived reactions after main
cleanup cleanup of temporary state
finally diagnostics, metrics and final traces
Intended constants:
export const ORCA_STAGE_GUARD = 'guard' as const;
export const ORCA_STAGE_PRE = 'pre' as const;
export const ORCA_STAGE_MAIN = 'main' as const;
export const ORCA_STAGE_POST = 'post' as const;
export const ORCA_STAGE_CLEANUP = 'cleanup' as const;
export const ORCA_STAGE_FINALLY = 'finally' as const;
finally must be able to run even if the pipeline aborted, unless the
orchestrator has been disposed.
Concurrency within the stage (parallel)
Within a stage the execution order is the registration order. An action can
declare parallel: true: the consecutive actions with parallel: true and
the same stage form a wave that runs in parallel via Promise.all. A
sequential action (the default, parallel: false) breaks the wave and runs to
completion before the next one starts.
The tokens emitted within a wave are merged into the run's set after the wave
settles; the gates (after / unless / abortOn / fanIn) are evaluated at
the start of the wave against the tokens accumulated before, so the siblings of
the same wave never see each other's tokens. If a parallel action returns
OrcaFatal or fails with onError: 'abort-run', the run aborts only after the
wave settles — the siblings are not cancelled mid-execution.
There are no sync / async modes or ORCA_EXEC_* constants: the only
intra-stage concurrency lever is parallel. The concurrency between runs
of the same event is configured separately with
configureEvent(event, { queuePolicy }) (next section).
Run Concurrency
A run is a concrete execution of an event. A run's internal pipeline does not by
itself resolve what happens when the same event enters several times while a
previous execution is still alive. That decision is part of the event's contract
and is configured with Orca.configureEvent(event, { queuePolicy }).
Live constants (consts.ts):
ORCA_QUEUE_FIFO; // 'fifo'
ORCA_QUEUE_REPLACE_QUEUED; // 'replace-queued'
ORCA_QUEUE_DROP_LATEST; // 'drop-latest'
ORCA_QUEUE_PARALLEL; // 'parallel'
Semantics:
| Mode | Use |
|---|---|
fifo (default) |
Each event queues a run; non-parallel runs are serialized globally. Simple guarantee. |
replace-queued |
If an event arrives while another is queued, the queued one is discarded and replaced by the new one. Does not abort in-flight. Latest pending intent wins. |
drop-latest |
If there is a run of the same event in-flight or queued, the incoming one is discarded. Ignores retriggers during work. |
parallel |
Launches concurrent runs; parallel events do not take the global lock. Footgun: overlaps side-effects. |
replace-queued lets the active run finish (FINALLY included) — the "strong"
variant that aborts in-flight (replace-current, takeLatest) is in Roadmap v2.
parallel is not the default for a reason: in destructive orchestrations
(identity change, logout, tenant switch, permission invalidation, private cache),
overlapping runs is a direct source of race conditions. Explicit opt-in per event
and visible diagnostics.
Tokens
Tokens are semantic facts produced by actions.
Examples:
export const ORCA_TOKEN_HTTP_PRIVATE_CANCELLED = 'http:private_cancelled' as const;
export const ORCA_TOKEN_CACHE_OK = 'cache:ok' as const;
export const ORCA_TOKEN_CACHE_ERROR = 'cache:error' as const;
export const ORCA_TOKEN_PERMISSIONS_INVALIDATED = 'perm:invalidated' as const;
export const ORCA_TOKEN_CONNECTIONS_REAUTHENTICATED = 'connection:reauthenticated' as const;
An action can:
after: wait for tokens before running.unless: skip if a token already exists.abortOn: abort or block if a token appears.provides: declare the tokens it can produce.
Tokens avoid depending on the concrete name of another action. The action
connection.reauth does not need to know whether the cache was cleared by
clearCacheV1 or resetPrivateCache; it only waits for cache:ok.
Token Scope
Tokens are scoped to the run. A token emitted during a run of
APP_EVENT_USER_IDENTITY_CHANGED does not exist for the next run of the same
event nor for a different event.
run A emits cache:ok
run B does not see cache:ok unless one of B's actions emits it again
This avoids accidental leaks between executions. If the application needs a
persistent fact, that fact must live in a real module (cache, session,
perm, storage) or be published as a new event on bus, not as a global
token.
Allowed cases:
- tokens emitted by actions of the same run
- explicit seed tokens when creating a run
- tokens derived from the event's configuration
Cases forbidden in v0:
- global token shared between events
- token persisted in storage
- token automatically reused between runs
- token with TTL
- sticky token
This rule must not be configurable in v0. If persistent state is needed, the
source of truth is an artifact (session, cache, perm, storage) and not
orca.
Flag Tokens and Payload Tokens
Tokens are semantic names (string) that a run accumulates as actions emit them.
Basic form — pure flag, no data:
return orcaSuccess({ emits: [ORCA_TOKEN_CACHE_OK] });
A downstream action decides whether to act by checking ctx.tokens.has(...)
directly or via declarative gates (after / unless / abortOn / fanIn). If
it needs to carry data (beyond "this happened"), use the payload form — emits
accepts { token, payload } entries mixed with loose strings:
return orcaSuccess({
emits: [
ORCA_TOKEN_CACHE_OK,
{ token: ORCA_TOKEN_USER_LOADED, payload: { id: 'u-7', tenantId: 't-3' } }
]
});
The payload is read downstream with ctx.tokenPayloads.get(token) (a
ReadonlyMap<OrcaToken, unknown>). Tokens emitted as a loose string do not
appear in the map, so ctx.tokens.has('x') and ctx.tokenPayloads.has('x') can
diverge (presence vs. payload). The gates still compare only names — the payload
is an orthogonal channel for intra-run coordination.
Typed limitation: payload is typed as unknown; authors cast. Inference such
as setupOrca({ tokens: { Token: SchemaPayload } }) is left for v2.
Graph Validation
orca must validate the configuration before running in production. At a
minimum:
- detect static cycles between tokens
- detect actions that wait for tokens that no action can produce
- detect tokens declared in
abortOnorunlesswith obvious typos - detect actions duplicated by
idwithin the same event - detect
providesthat are never emitted in any possible result if it can be inferred
Validation does not replace tests, but it must fail early when the graph is
impossible. A pipeline that can get blocked by configuration must fail when
registering actions or during Orca.validate(), not six months later in a real
session.
Intended API:
Orca.validate();
Orca.commit();
validate() checks the configuration without closing the registry. commit()
validates and freezes the configuration for execution. In development, orca can
run validate() automatically before the first event if the user did not do it.
OrcaEnvelope and OrcaActionContext
Every event the engine processes is wrapped in an internal OrcaEnvelope before
reaching the run queue:
interface OrcaEnvelope<TPayload> {
readonly event: string;
readonly payload: TPayload;
readonly meta: OrcaEventMeta;
}
interface OrcaEventMeta {
readonly eventId: OrcaEventId;
readonly traceId: OrcaTraceId;
readonly parentEventId?: OrcaEventId;
readonly parentRunId?: OrcaRunId;
readonly emittedByAction?: OrcaActionId;
readonly depth: number;
readonly stack: readonly string[];
readonly publishedAt: number;
readonly dedupeKey?: string;
}
The payload stays clean ({ userId, tenantId, ... }). The meta is owned by
orca's runtime.
Each OrcaAction receives an OrcaActionContext with the metadata useful for
that execution:
interface OrcaActionContext {
readonly runId: OrcaRunId;
readonly event: string;
readonly stage: OrcaStage;
readonly eventId: OrcaEventId;
readonly traceId: OrcaTraceId;
readonly parentEventId?: OrcaEventId;
readonly depth: number;
readonly tokens: ReadonlySet<OrcaToken>;
readonly signal: AbortSignal;
readonly logger: Logger;
emit<TPayload>(event: string, payload: TPayload, options?: OrcaEmitOptions): OrcaEventId | null;
}
Rule: module → bus, action → ctx.emit
The responsibility boundary is clear:
Module → bus.publish() (never knows orca)
Action → ctx.emit() (preserves traceId, parentEventId, depth)
A module never receives ctx. If an OrcaAction wants to emit a derived event
during a run, it uses ctx.emit() so orca creates a child envelope (same
traceId, parentEventId = ctx.eventId, depth = ctx.depth + 1,
emittedByAction = the action's id).
If instead the action calls a module that internally does bus.publish(...),
orca will see it as an event during the run but as a root from orca's
perspective: new traceId, depth = 0. It does not mix with the current run's
trace. That is option A of the v0-kernel; option B (intercepting
bus.publish during a run to attribute a perfect emittedByAction) is now
implemented in v1 as a diagnostic bonus in environments with AsyncLocalStorage
— see "Bus interception" in the Roadmap v1 section.
Reentrancy and Events During a Run
Any event a module publishes on the bus during a run is queued, not run inline.
The same applies to derived events via ctx.emit(). The engine keeps a FIFO
queue per event and never runs actions recursively within the same stack.
main(action A)
-> ctx.emit(B) -> child envelope B queued
-> does NOT run pipeline B inside action A
Reentry guards
createEngineOrca accepts reentry: OrcaReentryOptions:
interface OrcaReentryOptions {
readonly maxDepth?: number; // default 16
readonly maxEventsPerTrace?: number; // default 128
readonly repeatedEventLimit?: number; // default 2
readonly repeatedEventPolicy?:
| typeof ORCA_REENTRY_SKIP // default
| typeof ORCA_REENTRY_ABORT_TRACE
| typeof ORCA_REENTRY_ERROR;
}
When a derived envelope (via ctx.emit()) exceeds one of the limits, the
engine applies the configured policy:
skip— the envelope is not queued; the trace continues for other events. Diagnosticorca.reentry.blockedwith the appropriatereason.abort-trace— the trace is marked as aborted; any event already queued for that trace is discarded when its turn comes; the in-flight runs of that trace finish, but the following ones recordORCA_RUN_INTERRUPTED. Diagnosticorca.trace.aborted.error—OrcaReentryErroris thrown synchronously fromctx.emit(). The action can catch it or let it escalate.
Reasons (OrcaReentryReason):
max-depth—depth > maxDepthmax-events-per-trace—eventCount + 1 > maxEventsPerTracerepeated-event— the same name exceedsrepeatedEventLimitdeduped— thededupeKeyalready exists in the tracetrace-aborted— the trace was marked as aborted beforerun-aborted— the run was aborted byORCA_ON_ERROR_ABORT_RUNdisposed— the engine is disposed
Important: direct publishes on the bus from module code do NOT enter these
counters. Each bus.publish(event, payload) generates a root envelope with a
fresh traceId and depth = 0. The counters are born and die with each trace.
bus keeps local delivery. orca decides when it consumes and starts runs. If a
new event requires immediate execution, it must be modeled as a token of the
current run, not as a reentrant event.
Result
An action's result must be explicit.
type OrcaResult<TValue = unknown> =
| OrcaSuccess<TValue>
| OrcaSkipped
| OrcaInterrupted
| OrcaError
| OrcaTimeout
| OrcaFatal;
Preliminary form:
interface OrcaSuccess<TValue = unknown> {
readonly ok: true;
readonly status: typeof ORCA_RESULT_SUCCESS;
readonly value?: TValue;
readonly emits?: readonly OrcaToken[];
}
interface OrcaSkipped {
readonly ok: true;
readonly status: typeof ORCA_RESULT_SKIPPED;
readonly reason?: string;
readonly emits?: readonly OrcaToken[];
}
interface OrcaInterrupted {
readonly ok: false;
readonly status: typeof ORCA_RESULT_INTERRUPTED;
readonly reason?: string;
readonly emits?: readonly OrcaToken[];
}
interface OrcaError {
readonly ok: false;
readonly status: typeof ORCA_RESULT_ERROR;
readonly error: unknown;
readonly recoverable?: boolean;
readonly emits?: readonly OrcaToken[];
}
interface OrcaTimeout {
readonly ok: false;
readonly status: typeof ORCA_RESULT_TIMEOUT;
readonly timeoutMs: number;
readonly emits?: readonly OrcaToken[];
}
interface OrcaFatal {
readonly ok: false;
readonly status: typeof ORCA_RESULT_FATAL;
readonly error: unknown;
readonly emits?: readonly OrcaToken[];
}
Intended helpers:
orcaSuccess({ emits?: tokens, value?: data });
orcaSkipped(reason, { emits?: tokens });
orcaInterrupted(reason, { emits?: tokens });
orcaError(error, { emits?: tokens, recoverable?: boolean });
orcaTimeout(timeoutMs, { emits?: tokens });
orcaFatal(error, { emits?: tokens });
Exceptions must not be the normal flow. If an action throws, orca catches it
and converts it into ORCA_RESULT_ERROR or ORCA_RESULT_FATAL according to the
declared policy.
ORCA_RESULT_INTERRUPTED represents controlled cancellation/interruption, not a
business failure. It matters for replace, dispose and future cancellation
policies: aborting a previous run because a new intent arrived must not look the
same as a cache error or an unexpected exception.
Failure Policies
An action declares what happens if it fails.
export const ORCA_ON_ERROR_CONTINUE = 'continue' as const;
export const ORCA_ON_ERROR_ABORT_ACTION = 'abort-action' as const;
export const ORCA_ON_ERROR_ABORT_STAGE = 'abort-stage' as const;
export const ORCA_ON_ERROR_ABORT_RUN = 'abort-run' as const;
ABORT_ACTION and ABORT_STAGE are accepted for compat but today behave like
CONTINUE; only CONTINUE (default) and ABORT_RUN are distinct.
An unrecoverable failure has no policy constant: the action returns
OrcaFatal (orcaFatal(error)) and the engine always aborts the run — it
precedes any other state (timeout, aborted, partial) and ignores the action's
onError. There are no ORCA_ON_FATAL_*.
Rollback and Compensation
rollback must not be part of v0 as a generic promise. In frontend, an action
can mutate cache, reactive stores, IndexedDB, cookies or connections; there is no
universal rollback like in a database.
For v0, policies must speak of aborting, continuing, blocking or cleaning up. State repair is done with:
- the
cleanupstage - idempotent actions
- explicit compensation actions
Compensations (implemented):
interface OrcaAction<TPayload = unknown, TValue = unknown> {
readonly action: OrcaActionFn<TPayload, TValue>;
readonly compensate?: OrcaActionFn<TPayload, void>;
}
type OrcaActionFn<TPayload, TValue> = (
payload: TPayload,
context: OrcaActionContext
) => OrcaResult<TValue> | Promise<OrcaResult<TValue>>;
The compensator receives the same OrcaActionContext as the action (its
ctx.emit() returns null — rollback is not the place for fan-out).
Compensation is not magic rollback. It is an inverse or sanitizing action
declared by the application. orca can invoke it in reverse order when a run
aborts, but only for actions that declared it.
Timeouts
The only implemented timeout is actionTimeoutMs per action. The engine runs
the action's promise against a timer of the injected TimerScheduler; if the
timer expires first, it produces OrcaTimeout ({ ok: false, status: ORCA_RESULT_TIMEOUT, timeoutMs }) and aborts that action's signal. Sibling
actions are not affected — each has its own controller. actionTimeoutMs <= 0
(or omitted) runs without mediation.
A timeout is a result (OrcaTimeout), not a policy: there is no onTimeout
field or ORCA_ON_TIMEOUT_* constants. The higher-level timeouts (runTimeoutMs
/ stageTimeoutMs / idleTimeoutMs) are not implemented — they are roadmap.
orca never uses Date.now() or setTimeout() directly: always the
TimerScheduler of timer.
Timers
timer coordinates time. orca only registers deadlines and cancels its timers
when the run finishes or on dispose().
const Orca = createEngineOrca({
bus: App.bus,
timers: App.timers,
logger: App.logger
});
Internally the engine mints a timer per action with actionTimeoutMs (tied to
runId / stage / actionId) and cancels it when the action settles, when the
run finishes or on dispose(). There is no public timer-key helper: management is
internal to the engine.
If orca creates timers on an injected scheduler, it does not own the scheduler.
Orca.dispose() cancels the timers registered by orca, but does not destroy
App.timers.
Transactions
A transaction is an atomic group of actions that share the same tag
transaction: string on the same event (they can span several stages). If any
member ends in ERROR or FATAL, the engine compensates immediately the
members that had already succeeded — in LIFO order of completion — and aborts
the run. A member's onError is ignored within a transaction: the transactional
semantics always abort.
Orca.onEvent('checkout.submit', {
id: ORCA_ACTION_RESERVE_STOCK,
stage: ORCA_STAGE_MAIN,
transaction: 'checkout',
action: reserveStock,
compensate: releaseStock // invoked in rollback if another member fails
});
Tagging a member with transaction does not force declaring compensate — a
member without a compensator simply has nothing to undo, and validate() emits a
warning when no member of the transaction declares it (it would have no
rollback effect). Transaction compensations are appended to
OrcaRunResult.compensations[] just like the standard ones, and each compensator
runs at most once per run.
There is no OrcaTransactionPort or ORCA_TX_* constants: the transaction is the
grouping tag + compensate, not an external port or required / requires-new
modes.
Run Result
Each event publication can produce a complete summary:
interface OrcaRunResult {
readonly id: OrcaRunId;
readonly event: string;
readonly status:
| typeof ORCA_RUN_SUCCESS
| typeof ORCA_RUN_PARTIAL
| typeof ORCA_RUN_ABORTED
| typeof ORCA_RUN_FATAL
| typeof ORCA_RUN_TIMEOUT;
readonly startedAt: number;
readonly endedAt?: number;
readonly durationMs?: number;
readonly tokens: readonly OrcaToken[];
readonly actions: readonly OrcaActionRun[];
}
Each executed action must be traced:
interface OrcaActionRun {
readonly id: OrcaActionId;
readonly stage: OrcaStage;
readonly status:
| typeof ORCA_ACTION_STATUS_SUCCESS
| typeof ORCA_ACTION_STATUS_SKIPPED
| typeof ORCA_ACTION_STATUS_BLOCKED
| typeof ORCA_ACTION_STATUS_ERROR
| typeof ORCA_ACTION_STATUS_TIMEOUT
| typeof ORCA_ACTION_STATUS_FATAL;
readonly startedAt?: number;
readonly endedAt?: number;
readonly durationMs?: number;
readonly waitedFor: readonly OrcaToken[];
readonly emitted: readonly OrcaToken[];
readonly error?: unknown;
}
This is key for composite tests:
expect(run.status).toBe(ORCA_RUN_ABORTED);
expect(run.actions.find((a) => a.id === ORCA_ACTION_REAUTH_CONNECTIONS)?.status).toBe(
ORCA_ACTION_STATUS_BLOCKED
);
expect(run.tokens).toContain(ORCA_TOKEN_CACHE_ERROR);
Active Layer
createActiveOrca() would exist only for reactive inspection and test/devtools
pages.
It must have a history limit. A long session cannot accumulate all the runs in memory.
Possible properties:
ActiveOrca.running;
ActiveOrca.lastRun;
ActiveOrca.runs;
ActiveOrca.registeredEvents;
ActiveOrca.registeredActions;
ActiveOrca.failedRuns;
ActiveOrca.dispose();
Intended option:
const ActiveOrca = createActiveOrca({
maxRuns: 200
});
The execution logic must live in createEngineOrca(). The active layer must not
be necessary for core tests or for a future server runtime.
App Integration
orca must be present in active-app, but inert until the application registers
actions.
Recommended name in App:
App.Orchestration;
orca remains the name of the artifact, alias/import and constant prefix
($orca, ORCA_*). The application field uses a semantic name because it is read
in product code:
App.Orchestration.onEvent(...);
active-app can create Bus, Timers, Logger and Orchestration, but must
not hide the orchestration policy.
Expected inertia:
- with no registered actions, there are no runs
- with no registered actions for an event, the event is an O(1) no-op
orcasubscribes to a bus event only when registering the first action for that event- if the last action of an event is removed,
orcaunsubscribes from that event dispose()is a no-op if it was never used- the active history is not allocated expensively until the first run
v0 bundle decision: App.Orchestration can be a real engine included in the base
bundle. Dynamic import will not be used for the v0 core; the complexity of an
async proxy is not worth it if the inert engine is small.
Bus must also be an always-present, inert resource. If an app has
App.Orchestration, it must have App.bus.
Explicit use:
App.Orchestration.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_RESET_PRIVATE_STATE,
stage: ORCA_STAGE_MAIN,
onError: ORCA_ON_ERROR_ABORT_RUN,
action: async (payload) => {
await App.cache.clearActorScope(payload.previousActorId);
App.perm.invalidate();
await App.connections.reauthenticateAll();
return orcaSuccess();
}
});
A future preset may exist, but it must be a function that registers visible actions:
registerStandardAppOrchestration(App, App.Orchestration);
There must be no implicit magic where createActiveApp() activates destructive
side-effects without the developer being able to see which actions were
registered.
Dynamic Registration
orca must allow registering actions dynamically, for example when a
lazy-loaded feature is activated.
Rule:
actions registered during a run do not participate in that run
Each run uses a snapshot of actions taken at the start. New actions only participate in future runs. This prevents the working set from changing mid- pipeline.
Fan-In Between Events
v0 keeps orca as a single-event-trigger engine: one event fires a pipeline
of actions. There is no onEvents([A, B]) in v0.
If an application needs fan-in such as:
run when auth.ready and perm.loaded have both occurred
it must synthesize a composite event outside orca:
APP_EVENT_AUTH_READY
APP_EVENT_PERMISSIONS_LOADED
-> module/combiner publishes APP_EVENT_SECURITY_CONTEXT_READY
-> orca listens to APP_EVENT_SECURITY_CONTEXT_READY
This keeps the core simple and avoids opening big questions in v0:
- what happens if A arrives twice before B
- how long the join lives
- whether the join resets the timeout
- how joins are cancelled when the user changes
- whether payloads are combined or replaced
onEvents() can be a future extension, but it must not block the core.
Complete Example
Identity change:
Orca.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_CANCEL_PRIVATE_HTTP,
stage: ORCA_STAGE_PRE,
provides: [ORCA_TOKEN_HTTP_PRIVATE_CANCELLED],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
await App.http.cancelPrivateRequests();
return orcaSuccess({ emits: [ORCA_TOKEN_HTTP_PRIVATE_CANCELLED] });
}
});
Orca.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_CLEAR_PRIVATE_CACHE,
stage: ORCA_STAGE_MAIN,
after: [ORCA_TOKEN_HTTP_PRIVATE_CANCELLED],
provides: [ORCA_TOKEN_CACHE_OK, ORCA_TOKEN_CACHE_ERROR],
actionTimeoutMs: 2_000,
onError: ORCA_ON_ERROR_ABORT_RUN,
action: async (payload) => {
const result = await App.cache.clearActorScope(payload.previousActorId);
if (!result.ok) return orcaError(result.error, { emits: [ORCA_TOKEN_CACHE_ERROR] });
return orcaSuccess({ emits: [ORCA_TOKEN_CACHE_OK] });
}
});
Orca.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_INVALIDATE_PERMISSIONS,
stage: ORCA_STAGE_POST,
after: [ORCA_TOKEN_CACHE_OK],
abortOn: [ORCA_TOKEN_CACHE_ERROR],
provides: [ORCA_TOKEN_PERMISSIONS_INVALIDATED],
onError: ORCA_ON_ERROR_ABORT_STAGE,
action: async () => {
await App.perm.invalidate();
return orcaSuccess({ emits: [ORCA_TOKEN_PERMISSIONS_INVALIDATED] });
}
});
Orca.onEvent(APP_EVENT_USER_IDENTITY_CHANGED, {
id: ORCA_ACTION_REAUTH_CONNECTIONS,
stage: ORCA_STAGE_POST,
after: [ORCA_TOKEN_CACHE_OK, ORCA_TOKEN_PERMISSIONS_INVALIDATED],
abortOn: [ORCA_TOKEN_CACHE_ERROR],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
await App.connections.reauthenticateAll();
return orcaSuccess({ emits: [ORCA_TOKEN_CONNECTIONS_REAUTHENTICATED] });
}
});
Preliminary Algorithm
on bus event:
resolve event concurrency policy
queue/drop/replace/parallel according to policy
create run
collect actions for event
validate graph if not already validated
sort stages in canonical order
for each stage:
start stage timer
while stage has runnable actions:
find actions whose after tokens are satisfied
skip actions whose unless tokens are present
block/abort actions whose abortOn tokens are present
run ready actions in registration order (parallel waves via Promise.all)
collect Result
add emitted tokens
apply error/fatal/timeout policies
cancel timers for completed actions
mark remaining waiting actions as blocked or skipped
stop stage timer
run finally actions if allowed
run compensations if configured and policy requires it
finish run result
emit diagnostics
Mandatory protections:
- detect token/configuration cycles
- detect actions blocked by tokens nobody produces
- isolate tokens per run
- apply the per-event concurrency policy
- do not run events published during a run inline
- do not run actions after
dispose() - cancel own timers on abort or finish
- catch action exceptions
- do not publish secrets in diagnostics
- do not leave dangling promises without a final status
Diagnostics
orca uses the common Logger from libs/logger with a cataloged diagnostics
layer. The live keys (ORCA_DIAGNOSTIC_EVENTS in consts.ts):
'orca.run.started';
'orca.run.completed';
'orca.run.aborted';
'orca.action.started';
'orca.action.completed';
'orca.action.failed';
'orca.action.fatal';
'orca.action.timeout';
'orca.action.skipped';
'orca.action.blocked';
'orca.action.interrupted';
'orca.compensation.started';
'orca.compensation.completed';
'orca.compensation.failed';
'orca.event.emitted';
'orca.reentry.blocked';
'orca.trace.aborted';
'orca.queue.dropped';
'orca.configuration.invalid';
Each event carries meta with runId / eventId / traceId / depth when
applicable, plus specific fields (durationMs, error, reason, etc). The
messages and levels live in diagnostics.ts — never hardcoded in the runtime.
Tests
Unit coverage of the engine in src/arts/orca/test/engine-orca.test.ts
(registration, stages, error policies, run trace, dispose, envelope/context,
reentry guards, diagnostics, actionTimeoutMs, OrcaFatal, gates
after/unless/abortOn/fanIn, validate(), compensate, commit(),
parallel waves, transaction, tokens with payload, queue policies, bus
interception). Coverage of the reactive wrapper in
src/arts/orca/test/active-orca.svelte.test.ts (reactive snapshots via
engine.onChange).
Composite test of the live ecosystem in
src/arts/active-app/test/ecosystem-orca.test.ts: a user change A → B that
validates in a single orca trace the cache cleanup + perm invalidation +
connections reauth; on revoke it runs cache-clear-on-revoke +
connections-close-on-revoke; verifies that onError: continue does not block
sibling presets when one fails; and checks that detaching applyStandardOrca
unregisters everything.
Invariants
orcadoes not import concrete artifacts except common contracts.orcadoes not know business modules.- Artifacts do not consume
orca; they only publish events onbus. - The application registers actions in
orca. App.orcaalways exists, but runs nothing without actions.App.busmust exist ifApp.orcaexists.- All public strings live in constants.
- Events are constants, not inline strings.
- Tokens are constants, not inline strings.
- Logs use the common
Logger. - Diagnostics are cataloged.
- Timers are run by
timer. - An action's result is always represented.
- Event payloads do not contain credentials.
- Destructive actions are explicit.
- Modules never receive
ctx. Only theOrcaActions receive it, andctx.emit()is the path that preserves traceability.
Roadmap v1
Accepted in the public contract and honored by the engine — v1 100% closed.
Already in the engine (from v1)
- ✅
actionTimeoutMs— the engine runs the action against a timer of the injected scheduler; on expiry it aborts thesignaland producesORCA_RESULT_TIMEOUT. Sibling actions carry on. - ✅
OrcaFatal—orcaFatal(error)producesORCA_ACTION_STATUS_FATALand aborts the run immediately without consultingonError. TheFINALLYstage runs anyway. Run statusORCA_RUN_FATALwith precedence over any other. - ✅ Gates
after/unless/abortOn— the engine evaluates the tokens declared in each action against those emitted up to that point in the run.unlessis tested first (idempotency: skip if any is present), thenabortOn(halt: blocked if any is present), thenafter(dependency: skip if any is missing). The reason is serialized inOrcaActionRun.reasonasunless-triggered:<token>/abort-on-triggered:<token>/after-not-met:<tok1>,<tok2>,…. TheFINALLYstage bypasses them always. - ✅
validate()— static analysis that walks the actions registered per event, in canonical order(stage, registeredAt), accumulating the upstreamprovides. For each action it reports:unsatisfiable-after(error: token produced by nobody in an earlier stage nor higher up in the same stage),orphan-unless/orphan-abort-on(warnings: useless gate),dependency-cycle(error: A.after needs what B.provides and vice versa). Returns{ ok, issues }; never throws. The application decides whether to treat the issues as blocking — the engine does not freeze registrations or runs based on the result. - ✅
commit()— freezes the action graph. Aftercommit(), anyonEvent()throwsOrcaFrozenError. It is idempotent and does not runvalidate()implicitly: the caller decides whether to validate first and treat the issues as blocking. Thedetachfunctions returned before the freeze keep working —commit()closes new registrations, not the existing ones.dispose()operates normally on an already-committed engine and takes precedence over the frozen guard. - ✅
parallel— opt-in flag per action. Consecutive actions withparallel: trueand the same stage form a wave that runs withPromise.all; the sequential ones break the wave (each is a wave of one). The gates (after/unless/abortOn) are evaluated at the start of the wave against the tokens accumulated by previous waves; those emitted during the wave are merged when the wave settles, so two sibling parallels never see each other. Errors andOrcaFatalare evaluated after settle: the wave completes, then the run aborts if someone threw FATAL or ERROR+ABORT_RUN. In-flight actions are not cancelled mid- execution (each keeps its ownactionTimeoutMs). The parallels that succeeded and declarecompensateenter the LIFO stack and are compensated in reverse order of their registration.validate()retainsprovidesuntil the end of the wave: two sibling parallels with crossedafter/providesare reported asunsatisfiable-after. - ✅
transaction— atomic groups per event (any stage). If a tx member ends inERRORorFATAL, the engine compensates immediately the members of the same tx that already succeeded in LIFO order of completion, marks the run as aborted, and lets the standard pre-FINALLY flow compensate the rest. The tx semantics override theonErrorof the failing member (the tx always aborts the run; there is no need to declareabort-run). Compensators are invoked at most once: the tx marks its members ascompensatedand the global pass skips them. EachOrcaActionRuncarriestransactionId?for trace navigation.validate()emits warningtransaction-without-compensatewhen all members of a tx do not declarecompensate(no-op rollback). - ✅ Tokens with payload — the
emitsarray accepts entries{ token, payload }besides loose strings; both formats mix in the same array.ctx.tokenPayloads(mapReadonlyMap<OrcaToken, unknown>) exposes the payloads by name — the tokens emitted as a loose string do not appear in the map, soctx.tokens.has('x')andctx.tokenPayloads.has('x')can diverge (presence vs. payload). The gates (after/unless/abortOn/provides) still compare only names. ThetokenPayloadssnapshot per wave is independent: sibling parallels never see the payloads emitted by their peers mid-flight. Last-write-wins on name collisions.OrcaActionRun.emittedPayloads?carries the map per action (absent if the action only emitted strings).OrcaRunResult.tokenPayloadsis the run's final union (empty Map when no token carried a payload). - ✅
fan-in— quorum gate. The action declaresfanIn: { tokens, min }and fires when at leastminof the listed tokens are present in the wave's token snapshot.mindefaults totokens.length(AND semantics equivalent toafter); withmin: 1you get "first-to-finish wins". Gate order:unless→abortOn→fanIn→after. If the quorum is not met, the action is marked SKIPPED withreason = fan-in-not-met:<count>/<min>:<tokens-present>.fanInandaftercan coexist; both must pass.validate()reports errorunsatisfiable-fan-inwhen fewer thanmintokens of the set have an upstream provider — the gate would never fire. - ✅ Queue policies —
orca.configureEvent(event, { queuePolicy })selects how the concurrent / pending runs are managed per event. Important note: the engine implements a single global lane for all non-parallel events — at most onefifo/replace-queued/drop-latestrun is in flight at a time, regardless of which event it belongs to. This preserves the v0 invariant and makes cross-event composition predictable (cache-invalidate-then-refresh-perm, identity-change, etc). Per-event concurrency for non-parallel policies is in Roadmap v2.'fifo'(default): each event queues a run; the runs are serialized in the non-parallel global lane.'replace-queued': when a new event of the same name arrives and one is already queued, the queued one is discarded (it emitsorca.queue.droppedwithreason: 'replaced'). The in-flight one is NOT aborted; at most "1 in-flight + 1 queued" per event. The strong takeLatest variant (abort in-flight + queue new) is reserved as'replace-current'for v2.'drop-latest': if there is a run of the same event in-flight or queued, the incoming one is discarded (reason: 'drop-latest').'parallel': the runs are launched concurrently viaPromise; parallel events do not take the global lock, so they can run at the same time as non-parallel events.dispose()aborts all in-flightAbortControllers at once.configureEventis idempotent with the same policy and throws if you try to change it; throwsOrcaFrozenErroraftercommit().
- ✅
compensate— each action can declare a compensating function. When the run aborts (FATAL / ABORT_RUN onError / trace-aborted), the engine walks the LIFO stack of actions that already completedsuccesswith a compensator and invokes them in reverse order. Each compensation runs with its ownAbortController(the run's is already aborted) and actxwhoseemit()returnsnull— compensations do not fan-out events, they are pure rollback. Best-effort: if a compensation throws, it is recorded and the chain continues with the next one. Compensations appear inOrcaRunResult.compensations[]separate fromactions[]. TheFINALLYstages are not compensable (finally is the cleanup itself) and run after the compensations. - ✅ Bus interception (diagnostic bonus, not contract) — when
a module calls
bus.publish('e', payload)from within the body of an action, if the environment exposesAsyncLocalStorage, the engine attributes the event to the active run/action: the resulting envelope is a child (depth+1, sametraceId,parentEventIdpointing to the current run,emittedByActionequal to the action's id). Implementation: sync detection ofglobalThis.AsyncLocalStorage, fallback tonode:async_hooksvia dynamic import in Node/Bun. In browsers withoutAsyncContextthe field staysnulland the semantics revert to root-event.Hard rule: actions must use
ctx.emit()whenever they need trace attribution / chaining. The interception is a diagnostic bonus that improves the trace in environments with ALS, not a cross-env contract. An action that emits viabus.publishgets different results in Node/SSR vs. browser without AsyncContext, and the reentry guards do not apply the same way when the event enters as root. For deterministic fan-out usectx.emit()and leavebus.publishfor genuinely root events (UI input, server message, etc). - ✅
createActiveOrca()— reactive Svelte 5 wrapper overEngineOrca. Exposes the same engine methods and adds$state-backed snapshots:runningSnapshot,recentRunsSnapshot,latestRun,committedSnapshot,disposedSnapshot. The cells are updated in the listener ofengine.onChange()(which notifies on register/detach, run-start, run-complete,commit(),dispose()) — without$effectto avoideffect_update_depth_exceeded. The wrapper has no orchestration logic of its own; only the reactive layer. The non- reactive APIs (running,committed,disposed,recentRuns()) remain available for sync programmatic readers.
Roadmap v2
List of pending improvements and features, ordered by category. They are not accepted in the v1 public contract — they may change freely before landing.
Explicitly deferred from v1
'replace-current'policy — the "strong" variant of'replace-queued'(takeLateststyle) that aborts the in-flight run in addition to discarding the queued ones. Today'replace-queued'only affects the queue.- Per-event concurrency lane for non-parallel policies — today
all
fifo/replace-queued/drop-latestevents share a single serialized global "lane" (at least one non-parallel run at a time in the whole engine). For apps with many independent flows that do not want a parallel-policy on each one, a lane per event would be an upgrade. It requires extendingcanStartRunwith per-event tracking and reviewing the cross-event sequencing invariants. - Cross-event transactions — a
transactiontoday lives in a single event; spanning across events needs to reconcile tracing and compensation order. - Nested transactions — nested txs with hierarchical compensation (partial rollback vs. propagation to the parent tx).
Original roadmap (README v0/v1, untouched)
- Retry policies per action (with backoff: linear, exponential,
jitter; max
retryCount). - Concurrency limits — a maximum of N simultaneous runs at engine
level or per event (bounds
paralleland works withactionTimeoutMs). - Tokens with typed payload — the
payloadfield today isunknown; authors cast it. v2 could introduce a typed registryOrcaTokenSchema<Token, Payload>for end-to-end inference. - Per-event graph visualization — a tool that draws the
graph
(stages, actions, after, provides, fanIn, transaction)and highlightsvalidate()issues. - Active inspector — devtools/UI at runtime (list of runs, token trace, wave timeline, queue state).
- Visible App presets — a catalog of predefined orchestrations (cache-clear-on-revoke, etc.) discoverable from the app; today they are loose helpers.
- Composite tests with
session,perm,cache,connection,http— end-to-end scenarios that validate the composition of arts via orca.
Future active-server
- The same conceptual model ported to Go/Rust on the server.
- Server actions with DB tx / outbox.
- A bridge of server → client events.
- Replay/resume by
runId/eventId. - Durable audit.