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.
| Status | Meaning | Operator response |
|---|---|---|
validation_failed | Input did not match the schema. | The caller sent malformed data. Don't page on-call. Surface the error to the UI. |
failed | Execution 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
ActionInvocationrow 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:
- Action-level idempotency. An action declared
idempotent: trueis safe for the runtime to retry on transient failure (e.g. workflow restarts). - Adapter-step idempotency is independent. Each
AdapterStephas aretryPolicy.idempotentflag that controls retry of the external call. Non-idempotent adapter calls are forced tomaxAttempts: 1(packages/platform/adapters/index.ts). - 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_faileddoes not emit (it's pre-handler, no event). - Audit trail — why every status is auditable.