You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/orca/README.md

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 engine
  • createActiveOrca({ ... }) — Svelte 5 wrapper with reactive snapshots (runningSnapshot, recentRunsSnapshot, latestRun, committedSnapshot, disposedSnapshot)
  • onEvent(event, action) → detach
  • configureEvent(event, { queuePolicy }) — fifo (default) / replace / drop-latest / parallel
  • validate() — static analysis of the graph (issues with severity error or warn)
  • commit() — freezes the graph; a later onEvent throws OrcaFrozenError
  • onChange(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 + OrcaEnvelope with runtime metadata (eventId, traceId, parentEventId, depth, stack)
  • OrcaActionContext: eventId, traceId, depth, signal, emit(), tokens, tokenPayloads, logger
  • OrcaResult: success / skipped / error / interrupted / timeout / fatal
  • OrcaRunResult with tokens, tokenPayloads, actions[] and compensations[]
  • Reentry guards: maxDepth, maxEventsPerTrace, repeatedEventLimit, dedupeKey with policies skip / abort-trace / error
  • actionTimeoutMs per action — race against timer + per-action abort, isolated from siblings
  • OrcaFatal with precedence FATAL > TIMEOUT > ABORTED > INTERRUPTED > PARTIAL > SUCCESS
  • Gates unless → abortOn → fanIn → after (evaluation order); fanIn: { tokens, min } for k-of-n quorum
  • parallel: true per action — concurrent waves with Promise.all
  • transaction: 'tx-id' — atomic groups with immediate LIFO rollback when a member fails
  • Tokens with payload (emits: [{ token, payload }]) + ctx.tokenPayloads
  • compensate per action — LIFO rollback before FINALLY, best-effort, run-once
  • Bus interception — bus.publish from within an action is attributed to the active run/action via AsyncLocalStorage (Node/Bun; root-event fallback in browsers without AsyncContext)
  • Structured diagnostics (orca.run.*, orca.action.*, orca.event.emitted, orca.reentry.blocked, orca.trace.aborted, orca.compensation.*, orca.queue.dropped)
  • applyStandardOrca(App) preset aggregator in arts/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:

  • bus transports local events.
  • orca runs actions associated with bus events.
  • timer coordinates timers, timeouts and clocks.
  • logger receives 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 the OrcaActions receive ctx.

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, logger or active-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 abortOn or unless with obvious typos
  • detect actions duplicated by id within the same event
  • detect provides that 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. Diagnostic orca.reentry.blocked with the appropriate reason.
  • 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 record ORCA_RUN_INTERRUPTED. Diagnostic orca.trace.aborted.
  • error — OrcaReentryError is thrown synchronously from ctx.emit(). The action can catch it or let it escalate.

Reasons (OrcaReentryReason):

  • max-depth — depth > maxDepth
  • max-events-per-trace — eventCount + 1 > maxEventsPerTrace
  • repeated-event — the same name exceeds repeatedEventLimit
  • deduped — the dedupeKey already exists in the trace
  • trace-aborted — the trace was marked as aborted before
  • run-aborted — the run was aborted by ORCA_ON_ERROR_ABORT_RUN
  • disposed — 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 cleanup stage
  • 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
  • orca subscribes to a bus event only when registering the first action for that event
  • if the last action of an event is removed, orca unsubscribes 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

  • orca does not import concrete artifacts except common contracts.
  • orca does not know business modules.
  • Artifacts do not consume orca; they only publish events on bus.
  • The application registers actions in orca.
  • App.orca always exists, but runs nothing without actions.
  • App.bus must exist if App.orca exists.
  • 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 the OrcaActions receive it, and ctx.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 the signal and produces ORCA_RESULT_TIMEOUT. Sibling actions carry on.
  • ✅ OrcaFatal — orcaFatal(error) produces ORCA_ACTION_STATUS_FATAL and aborts the run immediately without consulting onError. The FINALLY stage runs anyway. Run status ORCA_RUN_FATAL with 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. unless is tested first (idempotency: skip if any is present), then abortOn (halt: blocked if any is present), then after (dependency: skip if any is missing). The reason is serialized in OrcaActionRun.reason as unless-triggered:<token> / abort-on-triggered:<token> / after-not-met:<tok1>,<tok2>,…. The FINALLY stage bypasses them always.
  • ✅ validate() — static analysis that walks the actions registered per event, in canonical order (stage, registeredAt), accumulating the upstream provides. 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. After commit(), any onEvent() throws OrcaFrozenError. It is idempotent and does not run validate() implicitly: the caller decides whether to validate first and treat the issues as blocking. The detach functions 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 with parallel: true and the same stage form a wave that runs with Promise.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 and OrcaFatal are 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 own actionTimeoutMs). The parallels that succeeded and declare compensate enter the LIFO stack and are compensated in reverse order of their registration. validate() retains provides until the end of the wave: two sibling parallels with crossed after/provides are reported as unsatisfiable-after.
  • ✅ transaction — atomic groups per event (any stage). If a tx member ends in ERROR or FATAL, 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 the onError of the failing member (the tx always aborts the run; there is no need to declare abort-run). Compensators are invoked at most once: the tx marks its members as compensated and the global pass skips them. Each OrcaActionRun carries transactionId? for trace navigation. validate() emits warning transaction-without-compensate when all members of a tx do not declare compensate (no-op rollback).
  • ✅ Tokens with payload — the emits array accepts entries { token, payload } besides loose strings; both formats mix in the same array. ctx.tokenPayloads (map ReadonlyMap<OrcaToken, unknown>) exposes the payloads by name — the 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 (after/unless/abortOn/provides) still compare only names. The tokenPayloads snapshot 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.tokenPayloads is the run's final union (empty Map when no token carried a payload).
  • ✅ fan-in — quorum gate. The action declares fanIn: { tokens, min } and fires when at least min of the listed tokens are present in the wave's token snapshot. min defaults to tokens.length (AND semantics equivalent to after); with min: 1 you get "first-to-finish wins". Gate order: unless → abortOn → fanIn → after. If the quorum is not met, the action is marked SKIPPED with reason = fan-in-not-met:<count>/<min>:<tokens-present>. fanIn and after can coexist; both must pass. validate() reports error unsatisfiable-fan-in when fewer than min tokens 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 one fifo / replace-queued / drop-latest run 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 emits orca.queue.dropped with reason: '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 via Promise; parallel events do not take the global lock, so they can run at the same time as non-parallel events. dispose() aborts all in-flight AbortControllers at once. configureEvent is idempotent with the same policy and throws if you try to change it; throws OrcaFrozenError after commit().
  • ✅ 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 completed success with a compensator and invokes them in reverse order. Each compensation runs with its own AbortController (the run's is already aborted) and a ctx whose emit() returns null — 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 in OrcaRunResult.compensations[] separate from actions[]. The FINALLY stages 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 exposes AsyncLocalStorage, the engine attributes the event to the active run/action: the resulting envelope is a child (depth+1, same traceId, parentEventId pointing to the current run, emittedByAction equal to the action's id). Implementation: sync detection of globalThis.AsyncLocalStorage, fallback to node:async_hooks via dynamic import in Node/Bun. In browsers without AsyncContext the field stays null and 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 via bus.publish gets 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 use ctx.emit() and leave bus.publish for genuinely root events (UI input, server message, etc).

  • ✅ createActiveOrca() — reactive Svelte 5 wrapper over EngineOrca. Exposes the same engine methods and adds $state-backed snapshots: runningSnapshot, recentRunsSnapshot, latestRun, committedSnapshot, disposedSnapshot. The cells are updated in the listener of engine.onChange() (which notifies on register/detach, run-start, run-complete, commit(), dispose()) — without $effect to avoid effect_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' (takeLatest style) 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-latest events 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 extending canStartRun with per-event tracking and reviewing the cross-event sequencing invariants.
  • Cross-event transactions — a transaction today 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 parallel and works with actionTimeoutMs).
  • Tokens with typed payload — the payload field today is unknown; authors cast it. v2 could introduce a typed registry OrcaTokenSchema<Token, Payload> for end-to-end inference.
  • Per-event graph visualization — a tool that draws the graph (stages, actions, after, provides, fanIn, transaction) and highlights validate() 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.

Powered by TurnKey Linux.