FabricFabricPlatform
Platform referenceAction pipeline

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

CheckApplies toSource
Module entitlementAlwayscheckModuleEntitlement(tenantId, action.namespace)
Member permissionnatural_person onlyMember.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...     AdapterInvocation

Stage 3 — Policy evaluation

Each policy ID on the action is dispatched through evaluatePolicyDefinitions (packages/platform/policies/index.ts), which supports three kinds:

  • code — a registered PolicyEvaluator runs.
  • 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 becomes blocked_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:

  1. Loads the entity by getEntityId(params).
  2. Reads its current state.
  3. Computes target state from targetState (literal or function of params).
  4. 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:

  1. Parses params with its declared schema (handlers honor their schema contract — there is no pre-parse in the procedure).
  2. Performs domain reads + writes on ctx.db inside a transaction.
  3. 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_failed distinction in detail.
  • Events — what actually gets appended.
  • Projections — what gets built from those events.

On this page