Pipeline stages
A stage-by-stage walkthrough of an action invocation, from row creation to event append.
This is the deep version of the mutation pipeline. Each stage is described with its responsibility, where the code lives, and what it produces.
Stage 0 — Resolution
resolveAction(actionId) looks up the ActionDefinition in the platform's action registry. If it is missing, the call throws — there is no implicit default action.
The ActionDefinition carries:
interface ActionDefinition<TDb> {
actionId: ActionId; // "lending.accept_offer"
namespace: string; // "lending"
version: number;
schema: SchemaValidator; // for the handler's params
handler?: ActionHandler<TDb>;
requiredRoles?: string[];
requiredPermissions?: string[];
policies?: `${string}.v${number}`[];
emitsEvents: string[];
idempotent: boolean;
mutatesDomain: boolean;
stateMachine?: ActionStateMachineBinding;
adapterSteps?: AdapterStep[];
kind?: "atomic" | "saga";
}Stage 1 — Module entitlement & permission
| Check | Applies to | Source |
|---|---|---|
| Module entitlement | Always | checkModuleEntitlement(tenantId, action.namespace) |
| Member permission | natural_person only | Member.role + requiredPermissions / requiredRoles |
Other actor types (agent, system, external_system) skip the permission check — they are pre-authorized at the call site (see triggers).
Stage 2 — Persist ActionInvocation
A row is created with status: "pending", the full parameters JSON, and a fresh correlationId (or the caller's, if supplied). The row exists before policy evaluation, so attempted-but-blocked actions remain auditable.
ID prefixes are sortable Crockford ULIDs (packages/platform/ids/index.ts):
act_01HYZ... ActionInvocation
evt_01HYZ... AssetEvent
pol_01HYZ... PolicyEvaluation
adp_01HYZ... AdapterInvocationStage 3 — Policy evaluation
Each policy ID on the action is dispatched through evaluatePolicyDefinitions (packages/platform/policies/index.ts), which supports three kinds:
code— a registeredPolicyEvaluatorruns.data— a declarative condition tree is evaluated against parameters and a small allowlisted context.hybrid— data first; on a configured trigger, fall back to a code evaluator.
Each outcome carries result, reason, and a dispatchEvidence object detailing how the decision was reached (which path, which conditions, which evaluator). Aggregated outcomes:
- Any
block→ invocation halts, status becomesblocked_by_policy. - Else any
warn→ continues, warning persisted. - Else
pass→ continues silently.
Stage 4 — Resolve action kind
if (actionDef.kind === "saga") {
await runSagaActionWorkflow(...);
} else {
await executeActionHandler(...);
}Saga actions are orchestrations of child action invocations. See saga design.
Stage 5 — State-machine validation (atomic)
If the action declares a stateMachine binding, the engine:
- Loads the entity by
getEntityId(params). - Reads its current state.
- Computes target state from
targetState(literal or function of params). - Calls
validateTransition(entityType, from, to, actionId).
A mismatch throws before the handler runs.
Stage 6 — Handler
type ActionHandler<TDb> = (
ctx: ActionContext<TDb>,
params: unknown,
) => Promise<ActionResult>;The handler:
- Parses
paramswith its declared schema (handlers honor their schema contract — there is no pre-parse in the procedure). - Performs domain reads + writes on
ctx.dbinside a transaction. - Returns
{ success: boolean, data?, error? }.
A ZodError thrown from the schema parse is caught by the worker and classified as validation_failed (status), not failed. See validation.
Stage 7 — Append events
The handler emits one or more AssetEvents, in the same transaction as the domain writes. Events are not optional for mutating actions — registerAction rejects an ActionDefinition with mutatesDomain: true and an empty emitsEvents. See events.
Stage 8 — Adapter steps
For each adapterStep:
interface AdapterStep {
adapterType: string;
operation: string;
retryPolicy?: AdapterStepRetryPolicy;
getInput: (params, handlerResult) => Record<string, unknown> | undefined;
}The runtime resolves the adapter from the AdapterRegistry and calls executeWithAdapterRetry. The retry policy is conservative by default:
- Non-idempotent →
maxAttempts: 1. - Idempotent → up to 5 attempts with exponential backoff.
A circuit breaker can be attached. In monitor mode it records failure rates; in enforce mode it short-circuits calls when the breaker opens.
Stage 9 — Complete
The invocation row is updated to completed or failed, telemetry emits, and the workflow ends. The caller (which has been polling or awaiting the workflow result) sees the final status.
See also
- Validation — the
validation_faileddistinction in detail. - Events — what actually gets appended.
- Projections — what gets built from those events.