Files
2026-05-27 23:29:53 +01:00

16 KiB

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<TData>(name, id?) and a WorkflowBuilder<TData> class that fluently composes steps via addStep(name, execute, id?) -> StepBuilder<TData>, supports per-step configuration through StepBuilder (withCompensation, withGuard, withRetry, withTimeout), and emits a typed WorkflowConfig<TData> via buildConfig() or a ready-to-run WorkflowEngine<TData> 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<TData> 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<TCtx> 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<TCtx> 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<TCtx> 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<TCtx> 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<TCtx> 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<TCtx> 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<TData> 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<TData>.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<WorkflowTransitionHookMap<TData>> 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<WorkflowCode>. 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"