FabricFabricPlatform
Platform referenceAction pipeline

Validation, idempotency, and status semantics

How the runtime distinguishes input validation failures from execution failures, and why idempotency is opt-in.

Validation and status semantics

ActionStatus carries seven values (packages/platform/actions/index.ts):

type ActionStatus =
  | "pending"
  | "running"
  | "completed"
  | "failed"
  | "blocked_by_policy"
  | "waiting_for_approval"
  | "validation_failed";

This page is about why validation_failed is separate from failed, why blocked_by_policy is separate from both, and how idempotency interacts.

validation_failed vs failed

A handler parses its params with a Zod schema as the first thing it does. If the schema rejects the input, the worker classifies the invocation as validation_failed. Any other thrown error is failed.

StatusMeaningOperator response
validation_failedInput did not match the schema.The caller sent malformed data. Don't page on-call. Surface the error to the UI.
failedExecution error during the handler.Something blew up — DB error, adapter exhaustion, unhandled throw. Page on-call.

Why this matters: ops dashboards filter on failed for alerts. Conflating the two pages on-call every time a user submits a bad form.

Why parameter validation lives in the handler

The platform.actions.invoke oRPC procedure intentionally does not pre-parse action parameters. The action's schema is the single source of truth, and the handler is the only thing required to honor it. This avoids:

  • Two places where the schema can drift apart.
  • A pre-parse that runs before the ActionInvocation row exists, losing audit fidelity.
  • A class of bugs where the API surface validates but a worker re-invocation skips validation.

Practically, this means: write your handler's first line as const parsed = schema.parse(params); and you are done.

blocked_by_policy

Distinct from validation failure. The input was valid; the policy decided the action wasn't allowed in this context. The caller should be told why — the dispatchEvidence from policy outcomes is persisted on the row.

waiting_for_approval

Used when an action requires a human in the loop. The runtime parks the invocation in this state and an approval surface (UI for managers, a notification for compliance) drives it forward by emitting an approval event.

Idempotency

Idempotency in Fabric is declared at the action level:

interface ActionDefinition {
  ...
  idempotent: boolean;
  adapterSteps?: AdapterStep[];   // each step has its own retry policy
}

Three observations:

  1. Action-level idempotency. An action declared idempotent: true is safe for the runtime to retry on transient failure (e.g. workflow restarts).
  2. Adapter-step idempotency is independent. Each AdapterStep has a retryPolicy.idempotent flag that controls retry of the external call. Non-idempotent adapter calls are forced to maxAttempts: 1 (packages/platform/adapters/index.ts).
  3. Default to non-idempotent. The runtime treats unknown idempotency as non-retryable. Opting in is a deliberate decision.

Retry envelope (idempotent adapters)

attempt 1 → fail → wait 100 ms
attempt 2 → fail → wait 200 ms
attempt 3 → fail → wait 400 ms (capped at maxDelayMs)

Defaults: 3 attempts, 100 ms initial, ×2 multiplier, 1 s cap, max 5 attempts. Tunable per step.

See also

  • Stages — where validation fits in the pipeline.
  • Events — what validation_failed does not emit (it's pre-handler, no event).
  • Audit trail — why every status is auditable.

On this page