# Capability Spec Delta: workflow (enterprise-workflow) **Package**: `@suaveplan/workflow` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Fluent workflow builder The package SHALL expose `createWorkflow(name, id?)` and a `WorkflowBuilder` class that fluently composes steps via `addStep(name, execute, id?) -> StepBuilder`, supports per-step configuration through `StepBuilder` (`withCompensation`, `withGuard`, `withRetry`, `withTimeout`), and emits a typed `WorkflowConfig` via `buildConfig()` or a ready-to-run `WorkflowEngine` via `build()`. #### Scenario: builder produces a config with the configured steps and error strategy - **WHEN** a caller invokes `createWorkflow("orders").addStep("validate", run).onError("compensate").buildConfig()` - **THEN** the resulting `WorkflowConfig` has `steps.length === 1`, `onError === "compensate"`, and `name === "orders"` #### Scenario: `StepBuilder.withRetry` applies defaults for omitted fields - **WHEN** a caller invokes `addStep(...).withRetry({ maxAttempts: 5 }).build()` - **THEN** the returned `StepDefinition.retry` has `maxAttempts: 5`, `strategy: "fixed"`, `delayMs: 1000` **Verified by:** `src/core/workflow-builder/workflow-builder.test.ts::"builds a config with steps and error strategy"` AND `src/core/workflow-builder/workflow-builder.test.ts::"applies retry defaults for omitted fields"` --- ### Requirement: Sequential step execution with onError strategies The `WorkflowEngine` SHALL execute steps sequentially in declaration order and SHALL honour the `onError` strategy on the first failed step: `"stop"` halts execution and returns a failed result; `"compensate"` halts execution, runs registered compensations in reverse order, and returns a failed result with status `"failed"`; `"continue"` records the failure and proceeds to the next step. #### Scenario: onError stop short-circuits remaining steps - **WHEN** a workflow with three steps fails on step two under `onError: "stop"` - **THEN** step three is never invoked and the result `status === "failed"` #### Scenario: onError compensate rolls back completed steps in reverse order - **WHEN** a workflow has two steps with compensations and the second fails under `onError: "compensate"` - **THEN** the first step's compensation runs exactly once and the result `status === "failed"` #### Scenario: onError continue keeps executing after a failure - **WHEN** a three-step workflow under `onError: "continue"` fails on step two - **THEN** step three still executes and `result.steps` contains all three step results **Verified by:** `src/core/workflow-engine/workflow-engine.test.ts::"halts on first failure when onError is stop"` AND `src/core/workflow-engine/workflow-engine.test.ts::"runs reverse-order compensation when onError is compensate"` AND `src/core/workflow-engine/workflow-engine.test.ts::"continues past a failure when onError is continue"` --- ### Requirement: Step retry, timeout, and guard `Step` SHALL support optional `guard` (skips execution when the guard returns false), `retry` (re-executes on failure using fixed/linear/exponential strategy with `maxDelayMs` cap), `timeout` (rejects the in-flight execution after the configured milliseconds), and `compensate` (rolls back the side effect). #### Scenario: guard returning false yields a skipped step - **WHEN** `Step.shouldExecute(ctx)` returns false - **THEN** the engine records the step result with `status === "skipped"` and never invokes `execute` #### Scenario: retry escalates delay per strategy and caps at maxDelayMs - **WHEN** a step with `strategy: "exponential", delayMs: 100, backoffMultiplier: 2, maxDelayMs: 250` retries - **THEN** `calculateRetryDelay(3, ...)` returns `250` (capped) #### Scenario: timeout rejects when the step exceeds the budget - **WHEN** a step's `execute` resolves after 50ms but `timeout` is 10ms - **THEN** `Step.run` returns `status: "failed"` and the error message contains "timed out" **Verified by:** `src/steps/step/step.test.ts::"skips when guard returns false"` AND `src/utils/utils.test.ts::"caps exponential backoff at maxDelayMs"` AND `src/steps/step/step.test.ts::"fails with timeout error when execute exceeds budget"` --- ### Requirement: Parallel step execution with concurrency and fail-fast `ParallelStep` SHALL execute its registered child steps concurrently, SHALL respect `maxConcurrency` (default `Infinity`) by batching when finite, SHALL support `failFast: true` to skip subsequent batches once any error is observed, and SHALL aggregate per-step `StepResult` plus a collected `errors` array. #### Scenario: parallel run returns one result per child step - **WHEN** a parallel step with three children runs and all succeed - **THEN** `result.results.length === 3` and `result.status === "completed"` #### Scenario: maxConcurrency=1 serialises execution into batches - **WHEN** a parallel step with four children and `maxConcurrency: 1` runs - **THEN** four sequential batches are produced (verified by recording start order) #### Scenario: failFast halts subsequent batches after first error - **WHEN** a parallel step with `maxConcurrency: 1, failFast: true` and four children sees child two fail - **THEN** children three and four are not executed **Verified by:** `src/steps/parallel-step/parallel-step.test.ts::"returns one result per child step"` AND `src/steps/parallel-step/parallel-step.test.ts::"serialises into batches when maxConcurrency is 1"` AND `src/steps/parallel-step/parallel-step.test.ts::"halts subsequent batches when failFast is true"` --- ### Requirement: Conditional step with true/false branches `ConditionalStep` SHALL evaluate its `condition` against the context, run `onTrue` when the condition is truthy, run `onFalse` when falsy if present, and yield a skipped `StepResult` when falsy without a false branch. `compensate(ctx)` SHALL roll back only the branch that actually ran. #### Scenario: truthy branch runs onTrue and records lastBranch - **WHEN** the condition returns true - **THEN** `lastBranch === "true"` and the result comes from the true-branch step #### Scenario: falsy with no else yields a skipped result - **WHEN** the condition returns false and `onFalse` is undefined - **THEN** the result has `status === "skipped"` and `lastBranch === "skipped"` **Verified by:** `src/steps/conditional-step/conditional-step.test.ts::"runs the true branch and records lastBranch"` AND `src/steps/conditional-step/conditional-step.test.ts::"skips when condition is false and no false branch is provided"` --- ### Requirement: Saga orchestration with reverse-order compensation `Saga` SHALL execute its steps sequentially and on the first failure SHALL run registered compensations in reverse registration order via the internal `Compensator`. The result SHALL expose `stepResults`, `compensationRecords`, and a `WorkflowStatus` of `"completed"` or `"failed"`. #### Scenario: failure mid-saga compensates earlier completed steps - **WHEN** a three-step saga completes steps one and two, then step three fails - **THEN** `compensationRecords` contains entries for steps two then one (reverse order) and `status === "failed"` **Verified by:** `src/compensation/saga/saga.test.ts::"compensates earlier steps in reverse order on failure"` --- ### Requirement: Compensator registry `Compensator` SHALL allow `register(stepId, fn)`, SHALL execute `compensateAll(ctx)` in reverse registration order, SHALL execute `compensateSteps(ctx, ids)` filtered to the given ids in reverse order, SHALL record `CompensationRecord` for every attempt (success or failure), and SHALL stop on first error when `continueOnError: false`. #### Scenario: continueOnError defaults to true - **WHEN** three compensations are registered and the second throws - **THEN** all three records are present and the third still ran #### Scenario: continueOnError false breaks on first failure - **WHEN** three compensations are registered with `continueOnError: false` and the second throws - **THEN** only two records are present **Verified by:** `src/compensation/compensator/compensator.test.ts::"runs all compensations when continueOnError is true"` AND `src/compensation/compensator/compensator.test.ts::"stops at first failure when continueOnError is false"` --- ### Requirement: Hook registry lifecycle dispatch `HookRegistry` SHALL register listeners for `HookEvent` topics, SHALL invoke listeners sequentially in registration order on `emit`, SHALL support `once` semantics (auto-unregister after the first invocation), and SHALL allow removal via the unregister callback returned from `on` or via `off(event?)`. #### Scenario: listeners fire in registration order - **WHEN** two listeners are registered for `"workflow:start"` and `emit` is called - **THEN** they fire in registration order with the same payload `event` and `timestamp` #### Scenario: once listener auto-unregisters - **WHEN** a listener registered via `once("step:start", ...)` is emitted twice - **THEN** the listener is invoked exactly once and `listenerCount("step:start") === 0` **Verified by:** `src/middleware/hook/hook.test.ts::"invokes listeners in registration order"` AND `src/middleware/hook/hook.test.ts::"once listener auto-unregisters after first emit"` --- ### Requirement: Retry middleware (`withRetry`, `RetryPresets`) The package SHALL expose `withRetry(fn, options)` that retries `fn` per the resolved `RetryConfig`, SHALL invoke `onRetry(error, attempt)` between attempts, SHALL respect `shouldRetry(error, attempt)` to short-circuit, and SHALL expose `RetryPresets` (`none`, `quick`, `standard`, `aggressive`) returning ready-to-use configs. #### Scenario: withRetry exhausts attempts and throws the last error - **WHEN** a function always throws and `withRetry` runs with `maxAttempts: 3` - **THEN** the function is invoked exactly three times and the final rejection is the last thrown error #### Scenario: shouldRetry false short-circuits - **WHEN** `shouldRetry` returns false after attempt one - **THEN** `withRetry` rejects immediately with that error and the function is invoked exactly once **Verified by:** `src/middleware/retry/retry.test.ts::"exhausts attempts and rethrows the final error"` AND `src/middleware/retry/retry.test.ts::"short-circuits when shouldRetry returns false"` --- ### Requirement: Workflow context state container `WorkflowContext` SHALL hold mutable workflow data and status, SHALL accumulate `StepResult` entries via `addStepResult`, SHALL store metadata via `setMeta`/`getMeta`, SHALL produce immutable snapshots via `snapshot()`, and SHALL be resettable to initial data via `reset(data)`. #### Scenario: snapshot is a frozen point-in-time copy - **WHEN** a context with one step result and one metadata key is snapshotted, then mutated - **THEN** the snapshot reflects the original state and is not mutated by the post-snapshot changes **Verified by:** `src/core/context/context.test.ts::"snapshot reflects state at the moment of capture"` --- ### Requirement: Cancellation `WorkflowEngine.cancel()` SHALL mark the engine cancelled. When invoked between steps, the next loop iteration SHALL transition status to `"cancelled"`, emit `workflow:cancel`, and return a result with `status: "cancelled"`. #### Scenario: cancel between steps short-circuits remaining work - **WHEN** a workflow's first step calls `engine.cancel()` from within its `execute` - **THEN** subsequent steps are not invoked and the result has `status: "cancelled"` **Verified by:** `src/core/workflow-engine/workflow-engine.test.ts::"cancels between steps and returns cancelled status"` --- ### Requirement: Workflow-level timeout The `WorkflowEngine` SHALL apply `config.timeout` (defaulting to `DEFAULT_TIMEOUT_MS`) over the entire `runSteps` pass and SHALL produce a failed result when the timeout elapses, with the error message naming the workflow. #### Scenario: workflow timeout produces failed status - **WHEN** a workflow with a 10ms timeout runs a step that takes 100ms - **THEN** the result has `status: "failed"` and `error.message` includes the workflow name **Verified by:** `src/core/workflow-engine/workflow-engine.test.ts::"fails when workflow timeout elapses"` --- ### Requirement: Transition data-pipeline hook The engine SHALL expose `transitionHooks: Hooks>` and SHALL invoke `workflow:transitioning` between resolving `shouldExecute` and emitting `step:start`. Returned handlers may mutate the `context` field of the data, and the engine SHALL pass the resulting context to the step's `execute`. #### Scenario: transition hook can rewrite the context handed to the step - **WHEN** a `workflow:transitioning` handler returns `{ ..., context: rewritten }` - **THEN** the next step receives `rewritten` as its execute argument **Verified by:** `src/core/workflow-engine/workflow-engine-hooks.test.ts::"applies workflow transitioning hook context rewrite"` --- ### Requirement: Stable error codes via `defineCodes` The package SHALL expose `WORKFLOW_CODES` registered via `@suaveplan/error`'s `defineCodes("WORKFLOW", [...])` and SHALL export a `WorkflowError` class extending `SuaveplanError`. Codes are exactly `STEP_FAILED`, `TIMEOUT`, `CANCELLED`, `COMPENSATION_FAILED`, `GUARD_REJECTED`, `RETRY_EXHAUSTED`. Every code SHALL be reachable from a real production path. #### Scenario: WorkflowError carries the stable code - **WHEN** a workflow times out via `WorkflowEngine.run` - **THEN** the surfaced error is a `WorkflowError` with `code === WORKFLOW_CODES.TIMEOUT` **Verified by:** `src/errors/workflow-error.test.ts::"WorkflowError surfaces WORKFLOW.TIMEOUT when the workflow times out"` --- ### Requirement: OTel instrumentation The package SHALL acquire a meter named `@suaveplan/workflow`, SHALL increment counter `workflow.step.executed_total{outcome,step_id}` on every step terminal state (`completed`, `failed`, `skipped`), SHALL increment counter `workflow.step.compensated_total{outcome,step_id}` on every compensation attempt (`compensated`, `compensation-failed`), and SHALL record histogram `workflow.step.duration_ms{step_id}` on every executed step. #### Scenario: instruments are emitted from production paths - **WHEN** a workflow runs one completed step and one failed step under `onError: "compensate"` with a registered compensation - **THEN** the `executed_total` counter records `{outcome: "completed"}` once and `{outcome: "failed"}` once; the `compensated_total` counter records `{outcome: "compensated"}` at least once **Verified by:** `src/otel/instruments.test.ts::"emits step counters and histogram from production paths"` --- ### Requirement: Injectable Clock `Step`, `WorkflowEngine`, `Saga`, `Compensator`, `ConditionalStep`, and `ParallelStep` SHALL accept an optional `Clock` (from `@suaveplan/types`) defaulting to `Date.now`. All `now()` reads inside the package SHALL go through the injected clock so tests can pin timing deterministically. #### Scenario: injected clock controls recorded timestamps - **WHEN** a `Step` is constructed with a clock returning `1000` - **THEN** a successful run records `startedAt === 1000` and `completedAt === 1000` **Verified by:** `src/steps/step/step.test.ts::"uses the injected clock for timestamps"` --- ### Requirement: Subpath exports per feature The package's `package.json` `exports` map SHALL provide a subpath per feature module so consumers can tree-shake unused features (`./core/workflow-engine/workflow-engine`, `./core/workflow-builder/workflow-builder`, `./core/context/context`, `./steps/step/step`, `./steps/parallel-step/parallel-step`, `./steps/conditional-step/conditional-step`, `./compensation/saga/saga`, `./compensation/compensator/compensator`, `./middleware/hook/hook`, `./middleware/retry/retry`, `./errors`, `./otel`). #### Scenario: every feature module is independently importable - **WHEN** dependency tooling enumerates `package.json` `exports` - **THEN** at minimum twelve non-`.` subpaths are present (one per feature module + errors + otel) **Verified by:** `tests/dep-graph.test.ts::"package exports map covers every feature module"`