Adding a Workflow Step
Workflow steps are a builder DSL over a sealed internal step contract. There is no public WorkflowStep interface for external implementation. Decide first which of the three changes you are making:
| Change | Mechanism |
|---|---|
| A new built-in step | New sealed subclass of InternalWorkflowStep<S> + a DSL function on AbstractWorkflowBuilder<S> + exhaustive-when updates |
| A new external step | Register an ExternalStepExecutor with ExternalStepExecutorRegistry |
| Reuse an existing step shape | No code change; compose with branchStep, parallelStep, or pluginStep |
The Extension Points
Runtime step contract — InternalWorkflowStep<S> (sealed), tramai-orchestration/src/main/kotlin/dev/tramai/orchestration/WorkflowStepExecution.kt:80-89:
val namesuspensionMode(defaults toNONE)suspend fun execute(request: WorkflowStepExecutionRequest<S>): WorkflowStepExecutionResult<S>
The result is sealed: Completed(state) or Suspended (WorkflowStepExecution.kt:26-32).
Builder DSL — WorkflowBuilder.kt:16 extends AbstractWorkflowBuilder<S> (:71, which owns the step list at :72, appendStep at :377, and stepsSnapshot at :384). Step functions: localStep :75, aiStep (4 overloads) :96-161, httpStep :180, shellStep :190, hermesStep :206/:220, codexStep :235/:249, mcpStep :264/:280, pluginStep :297/:310, gateStep :324, delayStep :334, branchStep :349, parallelStep :363.
External step SPI — ExternalStepExecutorRegistry.register(factory) (WorkflowStepExecution.kt:195), with ExternalStepExecutorFactory :173, ExternalStepExecutor :178, ExternalStepExecutorResolver :186-190. A missing executor raises ExternalStepExecutorNotRegisteredException (:182).
Files That Change
- New step class — a sealed subclass of
InternalWorkflowStep<S>intramai-orchestration/src/main/kotlin/dev/tramai/orchestration/. Templates:LocalWorkflowStep:93,AiWorkflowStep:104,GateWorkflowStep:123,PluginWorkflowStep:140;DelayWorkflowSteplives inWorkflowDelayCoordinator.kt:23. - DSL function — added to
AbstractWorkflowBuilderinWorkflowBuilder.kt. - Exhaustive-
whenwiring — compiler-enforced, and the only reason a "new step" is more than a class:- replay descriptor
when—Workflow.kt:120-136 - definition digest —
WorkflowDefinitionCompatibility.kt:19-46(SHA-256 over the canonical definition) - worker binding —
WorkflowBindingRegistry.bind(workflow, persistence)(WorkflowBindingRegistry.kt:82) - suspension rule —
WorkflowBuilder.kt:484-496(nested suspension rejected)
- replay descriptor
- Tests in
tramai-orchestration/src/test/kotlin/. docs/workflow-api-stability-boundary.md— a new public DSL addition must be classified there.
Mandatory Contract Tests
| Contract | Test |
|---|---|
| Step-level behaviour | Follow the WorkflowStepFailureBoundaryTest / WorkflowReplayDecisionPolicyTest patterns |
| Definition digest | WorkflowDefinitionDigestGoldenTest — a digest change must be deliberate and updated in the same commit |
| Binary compatibility fixture | BinaryCompatibilityFixtureTest against src/test/resources/binary-compat/BinaryCompatFixture.kt |
| Checkpoint migration | WorkflowCheckpointLegacyMigrationContractTest |
| Suspension mode guard | WorkflowStepExecutionArchitectureTest — ASM check that the suspensionMode getter returns the constant directly |
| Cancellation | verifyCancellationSafety (cancellation scanner) plus WorkflowCancellationContractTest |
| Public DSL classification | verifyWorkflowApiStabilityBoundary |
If you also add a store, add its TCK too: WorkflowCheckpointStoreTck.kt, WorkflowLeaseStoreTck.kt, WorkflowLeaseCheckpointFenceTck.kt in tramai-testing, plus their enrolment guards. See Adding a Store.
Compatibility
- Definition digest changes are breaking. The SHA-256 canonical digest drives recovery and fencing, so a new step with different digest semantics invalidates in-flight checkpoints. Update the golden test deliberately.
- Public DSL is public API. New DSL functions are covered by
docs/workflow-api-stability-boundary.md, enforced byverifyWorkflowApiStabilityBoundary, andapiCheckenforces the Binary Compatibility Validator dump. - Replay must round-trip. The
replayDescriptorwhenmust handle the new step, or replay of in-flight workflows fails. - A new suspending step is a contract change. Suspension semantics are frozen by the ASM guard; only
suspensionMode = TOP_LEVEL_CHECKPOINTsteps may suspend, and only at top level.
Verification
./gradlew :tramai-orchestration:test
./gradlew verifyWorkflowApiStabilityBoundary
./gradlew verifyCancellationSafety
./gradlew apiCheck
./gradlew verifyPr
./gradlew verifyChangePolicy -PchangeClass=runtime-behaviour # or public-api
What Not To Change In This Pull Request
WorkflowExecutionSupervisor,WorkerLifecycleController,WorkerShutdownCoordinator— steps execute through the runner and never touch these directly.- Store SPIs (
WorkflowCheckpointStore,WorkflowLeaseStore,StepAttemptRecordStore) — they change only when you add a store implementation. config/quality/0.6.0-baseline.json— never edited in the same pull request.
Every step routes through WorkflowStepExecutor.executeStep, which owns the step counter budget, observer events, failure sanitisation (safe public errors, no internal exception leakage), and CancellationException passthrough.
Common Mistakes
| Symptom | Cause |
|---|---|
| Build fails after adding the step class | An exhaustive-when wiring point was not updated |
verifyWorkflowApiStabilityBoundary fails | The new DSL function was not classified in docs/workflow-api-stability-boundary.md |
WorkflowDefinitionDigestGoldenTest fails | Expected for a digest change — update the golden deliberately, do not delete the assertion |
verify060Architecture fails | New dependency edge violates config/quality/module-boundaries.yml (published → internal, published → applications-examples, published → excluded, self-edges, cycles) |
WorkflowStepExecutionArchitectureTest fails | A suspending step was added, or suspensionMode no longer returns the constant directly |
