1. What does a step return to the next step when it returns an object, a primitive, undefined, or a promise?
- Object (
Record<string, any>): The returned keys are shallow-merged into the execution context as clean plain JavaScript objects (POJOs). Properties are directly accessible without wrappers or getter closures, allowing native object destructuring. - Primitive (
string,number,boolean,symbol,bigint, etc.): Primitive values cannot be keyed directly into an execution context object. They are wrapped as context accessors or mapped through custom reducers (step.reduce('field')). When executed as a single primitive step, the direct primitive value is returned. undefined: If a step action returnsundefined(or no return statement), the existing context passes through intact to the next step without modifying or removing existing properties.Promise: If a step returns aPromise(or is declaredasync), the orchestrator awaits the promise resolution. Rejections trigger the step’s failure lifecycle (onFail), while resolutions are handled according to the object/primitive/undefined rules above.
2. Does context accumulate across steps, or does each step receive only the preceding step’s output?
Context accumulates across sequential steps within the same execution flow:- Each step receives an aggregated parameter containing all previously accumulated context values.
- In addition,
$stepsand$historyaccessors provide indexed and named snapshots of the output of preceding steps (e.g.param.$steps('stepName'),param.$steps.stepName, orparam.$steps[0]). - If step writes a key
fooand step writes a keybar, step receives bothfooandbar. If step writesfoo, it overwrites the value offoofor downstream steps.
3. How do multiple steps with the same name behave?
- Every step instance is assigned an internal, immutable UUID (
$stepId). - Steps sharing the same descriptive name (e.g. two steps named
'processChunk') execute independently according to their sequence position. - In
$steps/$history, named lookups return the output of the most recent step with that name. - Cache keys incorporate the step’s unique identifier and hashed inputs, preventing name collisions unless explicitly shared via custom
cacheOptions.keySerializer.
4. What happens when a step throws synchronously vs returning a rejected promise?
- Both synchronous exceptions (via
throw) and asynchronous rejections (viaPromise.rejector rejected async functions) are intercepted identically. - Synchronous exceptions are caught immediately, tagged with
phase: 'action'and the correspondingstepName, and routed to theonFaillifecycle hook. - Promises that reject are intercepted via promise rejection handlers, tagged with
phase: 'action'andstepName, and routed to theonFaillifecycle hook. - In both cases, timeouts are immediately cleared to avoid orphaned timer callbacks.
5. What happens when a step hook throws?
Step execution progresses through 6 explicit lifecycle phases:validation/spec: Schema validation or specification checks. If a spec validation fails, aValidationExceptionis created withphase: 'validation'.before(onBefore/ pluginbefore): Lifecycle hooks that execute prior to the step action. Failures inonBeforeare caught withphase: 'before'.action(Step.createaction / step action): The primary business logic of the step.after(onAfter/ pluginafter): Lifecycle hooks that execute after action completion. Failures inonAfterare caught withphase: 'after'.checkpoint: State serialization or storage persistence operations. Failures are caught withphase: 'checkpoint'.retry: Inter-attempt delay calculation or backoff operations.
onFail. If onFail does not recover, the flow terminates and rejects.
6. Are hook failures retryable?
- By default, failures occurring in
phase: 'action'are considered retryable (retryable: true). - Failures occurring in
phase: 'validation',phase: 'before', orphase: 'after'are non-retryable by default unless explicitly configured via customretryIfpredicate inonFail({ retryIf: ... }). - If a custom
retryIf(err, attempt)predicate returnstrue, retry occurs regardless of default phase classification.
7. When is a flow execution considered started, completed, failed, cancelled, or waiting?
started: Whenflow(...).run()(orStep.start(...)) allocates a stack UUID, initializes the execution state, registers the active timeout/cancellation listeners, and invokes the first step.completed: When the final step in the sequence resolves successfully (including itsonAfterhooks) and state is deleted from memory or checkpointed withstatus: 'completed'.failed: When a step or hook error is not recovered byonFail, or when a flow deadline expires (FlowTimeoutException). The flow status is set to'failed'.cancelled: When anAbortSignalpassed viaoptions.signalemits an abort event. The flow halts immediately withCancellationExceptionand status'cancelled'.waiting: When execution encounters a durable wait combinator (waitForEventorwait) with{ durable: true }. Execution halts cleanly, records a wait record in storage, snapshots state, and yields status'waiting'without rejecting.
8. What is the lifecycle of state in memory when executions run concurrently?
- Each execution of a flow allocates a unique execution stack (
stackId) inStorageManager. - Contexts, stack metadata, step caches, and revision counters are isolated per
stackId. - Concurrent executions of identical or different flows never share or overwrite each other’s execution stack or in-memory caches.
- When an execution concludes (
completed,failed, orcancelled),deleteStack(stackId)purges the stack’s in-memory contexts and temporary cache entries. - Cross-execution persistence is achieved exclusively through configured
StorageAdapterimplementations.
9. Does a retry re-run only the failing step, the failed hook, or the entire flow from that step forward?
- A retry re-runs only the failing step (including its
onBefore,action, andonAfterhooks). - Upstream steps that have already succeeded are never re-executed.
- Downstream steps continue sequentially once the retried step successfully completes.
- Each retry attempt receives incremented
$attemptand$retryDelaycontext metadata.
10. How does network egress policy enforcement behave across steps, concurrent branches, and asynchronous operations?
- Policy Activation & Default Scope: Unconfigured flows have no network policy enforcement (outbound traffic is unrestricted). When a network policy is declared on a flow (
flow.networkPolicy()) or step (step.allowNetwork(),step.networkPolicy()), the policy activates and defaults to denying undeclared external network egress. - Propagation via
AsyncLocalStorage(ASL): When a step action begins, its effective network policy is bound to Node’sAsyncLocalStorage(networkPolicyStorage.run(...)). All nested async calls—promises, timeouts, microtasks, or third-party libraries (e.g.axios,got,undici, AWS SDK)—automatically inherit the enclosing step’s policy store across asynchronous context transitions. - Concurrent Execution Isolation: In concurrent execution constructs (
parallel,allSettled,map), each parallel step executes within its own isolated ASL store on the single Node event loop. A network restriction or allowlist in one branch never leaks into or interferes with concurrent sibling branches. Operations running outside step execution (e.g. host server healthchecks, database connection pools, background log flushers) encounter no active policy store and proceed unrestricted. - Outer Perimeter Enforcement (“Flow Supersedes Step”): The flow’s network policy defines the outer permissible envelope. An individual step can only narrow permissions within what the flow permits (by specifying a subset of hosts or calling
.denyNetwork()). Any attempt by a step to permit a host outside the flow’s allowlist is rejected. - Localhost and Loopback Configuration: When network policy is active,
allowLocalhostdefaults tofalse. When enabled (allowLocalhost: true), loopback hosts (localhost,127.0.0.1,::1,0.0.0.0) and local Unix domain socket IPC (allowUnixSockets) bypass network policy blocks, preserving local inter-process communication, local mock services, and container-local sidecars. - Multi-Layer Egress Interception: Outbound traffic is governed across three runtime interception points:
globalThis.fetch: Validates input URLs prior to request initiation.node:httpandnode:https: Patchesrequestandgetmethods (covering legacy Node clients and libraries).node:net.Socket.prototype.connect: Enforces policy at the raw TCP/TLS socket layer.
- Security Violation Classification: When an unauthorized egress attempt is detected, execution aborts immediately with a
NetworkPolicyViolationException. This error is tagged withphase: 'action'andretryable: false, ensuring security/policy violations fail fast without blind automated retries.
11. How do Saga compensations execute upon workflow failure?
- LIFO Order: Compensations execute in exact reverse order (Last-In, First-Out) of step completion. Only steps that completed successfully prior to the point of failure are compensated.
- Execution Modes:
'strict'(default): Rollback aborts immediately upon the first failing compensation handler. Remaining compensations are halted.'best-effort': Siglo attempts every registered compensation even if one or more fail, aggregating all failures intoCompensationException.
- Payload & Input Preservation: Handlers receive
(output, input, ctx)snapshots captured at step completion time. - Named vs Inline Registrations: For multi-process or durable workflows, handlers registered via
registerCompensationHandler(name, fn)are resolved by name, surviving process restarts. Missing handlers throwUnresolvableCompensationException.
12. How does a resumed workflow continue without re-executing completed steps?
- When a workflow suspends at
waitForEventorwait, Siglo storescontinuationStepIndexin the wait record. - Upon
flow.resume(correlationKey, payload)orresumeWorkflow(...):- State and
$stepshistory snapshots are re-hydrated from the storage checkpoint. - The external event payload is merged into the context.
- Execution resumes directly at
continuationStepIndex. Preceding steps are never re-evaluated.
- State and
- Duplicate resumption calls throw
WorkflowAlreadyResumedException(storage) orDuplicateEventException(in-memory).
13. What are the lifecycle semantics of idempotency claims?
- Claim Phase: An idempotency key is claimed before step execution begins (
store.claim(key, ttlMs)).- If
'claimed': Step execution proceeds. - If
'completed': Cached output is returned immediately without executing step action or hooks. - If
'in-progress': ThrowsIdempotencyConflictException(or spin-waits ifsettleTimeoutMsis configured).
- If
- Atomic Commitment: A step’s claim transitions to
'completed'atomically only after allonAfterhooks succeed. - Transient vs Permanent Failures: If step action or hooks fail, the claim is released via
store.fail(key)unless classified as a permanent business error viaisPermanentBusinessError(err).
