@usearazzo/runner 0.0.0-stage → 1.0.1-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +20 -0
  3. package/README.md +784 -2
  4. package/dist/arazzo-runner.browser.js +111247 -0
  5. package/dist/arazzo-runner.browser.min.js +1 -0
  6. package/package.json +88 -4
  7. package/src/action/ActionResolver.cjs +160 -0
  8. package/src/action/ActionResolver.mjs +150 -0
  9. package/src/assembler/OpenAPI2DocumentAssembler.cjs +111 -0
  10. package/src/assembler/OpenAPI2DocumentAssembler.mjs +104 -0
  11. package/src/assembler/OpenAPI30DocumentAssembler.cjs +110 -0
  12. package/src/assembler/OpenAPI30DocumentAssembler.mjs +103 -0
  13. package/src/assembler/OpenAPI31DocumentAssembler.cjs +111 -0
  14. package/src/assembler/OpenAPI31DocumentAssembler.mjs +104 -0
  15. package/src/assembler/OpenAPIDocumentAssembler.cjs +58 -0
  16. package/src/assembler/OpenAPIDocumentAssembler.mjs +50 -0
  17. package/src/client/HTTPClient.cjs +5 -0
  18. package/src/client/HTTPClient.mjs +1 -0
  19. package/src/client/HTTPClientFetch.cjs +16 -0
  20. package/src/client/HTTPClientFetch.mjs +10 -0
  21. package/src/client/OpenAPIOperationRequest.cjs +5 -0
  22. package/src/client/OpenAPIOperationRequest.mjs +1 -0
  23. package/src/client/OpenAPIOperationResponse.cjs +42 -0
  24. package/src/client/OpenAPIOperationResponse.mjs +36 -0
  25. package/src/client/OpenAPIOperationResponseNormalizer.cjs +48 -0
  26. package/src/client/OpenAPIOperationResponseNormalizer.mjs +40 -0
  27. package/src/client/ParameterDelivery.cjs +119 -0
  28. package/src/client/ParameterDelivery.mjs +113 -0
  29. package/src/constants.cjs +9 -0
  30. package/src/constants.mjs +3 -0
  31. package/src/criterion/CriterionEvaluator.cjs +205 -0
  32. package/src/criterion/CriterionEvaluator.mjs +196 -0
  33. package/src/criterion/JSONPathCriterionEvaluator.cjs +47 -0
  34. package/src/criterion/JSONPathCriterionEvaluator.mjs +40 -0
  35. package/src/criterion/RegexCriterionEvaluator.cjs +67 -0
  36. package/src/criterion/RegexCriterionEvaluator.mjs +60 -0
  37. package/src/criterion/SimpleCriterionEvaluator.cjs +50 -0
  38. package/src/criterion/SimpleCriterionEvaluator.mjs +43 -0
  39. package/src/criterion/XPathCriterionEvaluator.cjs +117 -0
  40. package/src/criterion/XPathCriterionEvaluator.mjs +109 -0
  41. package/src/document/APIDocument.cjs +35 -0
  42. package/src/document/APIDocument.mjs +30 -0
  43. package/src/document/ArazzoDocument.cjs +81 -0
  44. package/src/document/ArazzoDocument.mjs +74 -0
  45. package/src/document/ArazzoSourceDescriptionIndex.cjs +26 -0
  46. package/src/document/ArazzoSourceDescriptionIndex.mjs +20 -0
  47. package/src/document/ArazzoStepIndex.cjs +25 -0
  48. package/src/document/ArazzoStepIndex.mjs +19 -0
  49. package/src/document/ArazzoWorkflowIndex.cjs +17 -0
  50. package/src/document/ArazzoWorkflowIndex.mjs +11 -0
  51. package/src/document/OpenAPIDocument.cjs +28 -0
  52. package/src/document/OpenAPIDocument.mjs +21 -0
  53. package/src/document/OpenAPIOperationIndex.cjs +24 -0
  54. package/src/document/OpenAPIOperationIndex.mjs +18 -0
  55. package/src/document/ParameterIdentity.cjs +93 -0
  56. package/src/document/ParameterIdentity.mjs +88 -0
  57. package/src/document/openapi-types.cjs +5 -0
  58. package/src/document/openapi-types.mjs +1 -0
  59. package/src/errors/ArazzoRunnerError.cjs +10 -0
  60. package/src/errors/ArazzoRunnerError.mjs +5 -0
  61. package/src/errors/AssemblerError.cjs +11 -0
  62. package/src/errors/AssemblerError.mjs +4 -0
  63. package/src/errors/ClientError.cjs +11 -0
  64. package/src/errors/ClientError.mjs +4 -0
  65. package/src/errors/CriterionError.cjs +11 -0
  66. package/src/errors/CriterionError.mjs +4 -0
  67. package/src/errors/ExecutionError.cjs +11 -0
  68. package/src/errors/ExecutionError.mjs +4 -0
  69. package/src/errors/ExtractionError.cjs +11 -0
  70. package/src/errors/ExtractionError.mjs +4 -0
  71. package/src/errors/InvalidEntryDocumentError.cjs +11 -0
  72. package/src/errors/InvalidEntryDocumentError.mjs +4 -0
  73. package/src/errors/NormalizationError.cjs +11 -0
  74. package/src/errors/NormalizationError.mjs +4 -0
  75. package/src/errors/ResolverError.cjs +11 -0
  76. package/src/errors/ResolverError.mjs +4 -0
  77. package/src/errors/RuntimeExpressionError.cjs +11 -0
  78. package/src/errors/RuntimeExpressionError.mjs +4 -0
  79. package/src/errors/UnmatchedProviderError.cjs +11 -0
  80. package/src/errors/UnmatchedProviderError.mjs +4 -0
  81. package/src/executor/ArazzoWorkflowLocatorNormalizer.cjs +147 -0
  82. package/src/executor/ArazzoWorkflowLocatorNormalizer.mjs +137 -0
  83. package/src/executor/OpenAPIOperationExecutor.cjs +457 -0
  84. package/src/executor/OpenAPIOperationExecutor.mjs +446 -0
  85. package/src/executor/OpenAPIOperationLocatorNormalizer.cjs +197 -0
  86. package/src/executor/OpenAPIOperationLocatorNormalizer.mjs +189 -0
  87. package/src/executor/SourceDescriptionsLocatorNormalizer.cjs +44 -0
  88. package/src/executor/SourceDescriptionsLocatorNormalizer.mjs +38 -0
  89. package/src/executor/StepExecutor.cjs +333 -0
  90. package/src/executor/StepExecutor.mjs +326 -0
  91. package/src/executor/StepRetryRunner.cjs +226 -0
  92. package/src/executor/StepRetryRunner.mjs +215 -0
  93. package/src/executor/StepTransitionInterpreter.cjs +159 -0
  94. package/src/executor/StepTransitionInterpreter.mjs +151 -0
  95. package/src/executor/WorkflowCallStack.cjs +116 -0
  96. package/src/executor/WorkflowCallStack.mjs +106 -0
  97. package/src/executor/WorkflowExecutor.cjs +1091 -0
  98. package/src/executor/WorkflowExecutor.mjs +1077 -0
  99. package/src/executor/abort.cjs +70 -0
  100. package/src/executor/abort.mjs +60 -0
  101. package/src/expression/RuntimeExpressionContext.cjs +5 -0
  102. package/src/expression/RuntimeExpressionContext.mjs +1 -0
  103. package/src/expression/RuntimeExpressionEvaluator.cjs +337 -0
  104. package/src/expression/RuntimeExpressionEvaluator.mjs +328 -0
  105. package/src/extractor/ArazzoStepExtractor.cjs +48 -0
  106. package/src/extractor/ArazzoStepExtractor.mjs +41 -0
  107. package/src/extractor/ArazzoWorkflowExtractor.cjs +50 -0
  108. package/src/extractor/ArazzoWorkflowExtractor.mjs +43 -0
  109. package/src/extractor/OpenAPIOperationExtractor.cjs +84 -0
  110. package/src/extractor/OpenAPIOperationExtractor.mjs +77 -0
  111. package/src/index.cjs +419 -0
  112. package/src/index.mjs +73 -0
  113. package/src/normalizer/ArazzoStepNormalizer.cjs +23 -0
  114. package/src/normalizer/ArazzoStepNormalizer.mjs +17 -0
  115. package/src/normalizer/ArazzoWorkflowNormalizer.cjs +214 -0
  116. package/src/normalizer/ArazzoWorkflowNormalizer.mjs +206 -0
  117. package/src/normalizer/OpenAPI2OperationNormalizer.cjs +128 -0
  118. package/src/normalizer/OpenAPI2OperationNormalizer.mjs +120 -0
  119. package/src/normalizer/OpenAPI30OperationNormalizer.cjs +129 -0
  120. package/src/normalizer/OpenAPI30OperationNormalizer.mjs +121 -0
  121. package/src/normalizer/OpenAPI31OperationNormalizer.cjs +131 -0
  122. package/src/normalizer/OpenAPI31OperationNormalizer.mjs +123 -0
  123. package/src/normalizer/OpenAPIOperationNormalizer.cjs +58 -0
  124. package/src/normalizer/OpenAPIOperationNormalizer.mjs +50 -0
  125. package/src/registry/DocumentRegistry.cjs +160 -0
  126. package/src/registry/DocumentRegistry.mjs +151 -0
  127. package/src/registry/providers/ArazzoDocumentRegistryProvider.cjs +154 -0
  128. package/src/registry/providers/ArazzoDocumentRegistryProvider.mjs +146 -0
  129. package/src/registry/providers/DocumentRegistryProvider.cjs +31 -0
  130. package/src/registry/providers/DocumentRegistryProvider.mjs +25 -0
  131. package/src/registry/providers/OpenAPIDocumentRegistryProvider.cjs +122 -0
  132. package/src/registry/providers/OpenAPIDocumentRegistryProvider.mjs +114 -0
  133. package/src/resolver/ArazzoValueResolver.cjs +42 -0
  134. package/src/resolver/ArazzoValueResolver.mjs +37 -0
  135. package/src/resolver/OpenAPIOperationParameterResolver.cjs +117 -0
  136. package/src/resolver/OpenAPIOperationParameterResolver.mjs +110 -0
  137. package/src/resolver/OutputResolver.cjs +71 -0
  138. package/src/resolver/OutputResolver.mjs +64 -0
  139. package/src/resolver/RequestBodyResolver.cjs +227 -0
  140. package/src/resolver/RequestBodyResolver.mjs +220 -0
  141. package/src/resolver/ResolverScope.cjs +5 -0
  142. package/src/resolver/ResolverScope.mjs +1 -0
  143. package/src/resolver/StepParameterResolver.cjs +22 -0
  144. package/src/resolver/StepParameterResolver.mjs +15 -0
  145. package/src/resolver/WorkflowParameterResolver.cjs +73 -0
  146. package/src/resolver/WorkflowParameterResolver.mjs +66 -0
  147. package/src/state/WorkflowExecutionState.cjs +99 -0
  148. package/src/state/WorkflowExecutionState.mjs +93 -0
  149. package/src/vendor/swagger-client.cjs +1 -0
  150. package/src/vendor/swagger-client.mjs +1 -0
  151. package/types/runner.d.ts +2341 -0
package/README.md CHANGED
@@ -1,3 +1,785 @@
1
- # Temporary Holding Version
1
+ # @usearazzo/runner
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > [!NOTE]
4
+ > This package is published under the `alpha` channel: APIs may still change before 1.0.0.
5
+
6
+ `@usearazzo/runner` executes [Arazzo Specification](https://spec.openapis.org/arazzo/latest.html)
7
+ workflows against live APIs described by
8
+ [OpenAPI Specification](https://spec.openapis.org/oas/latest.html) source descriptions. It builds on
9
+ [SpecLynx ApiDOM](https://github.com/speclynx/apidom) data models and reuses the loading,
10
+ resolution, and normalization primitives shared across
11
+ [`@usearazzo/parser`](https://www.npmjs.com/package/@usearazzo/parser) and
12
+ [`@usearazzo/resolver`](https://www.npmjs.com/package/@usearazzo/resolver).
13
+
14
+ **Supported Arazzo versions:**
15
+
16
+ - [Arazzo 1.0.0](https://spec.openapis.org/arazzo/v1.0.0)
17
+ - [Arazzo 1.0.1](https://spec.openapis.org/arazzo/v1.0.1)
18
+ - [Arazzo 1.1.0](https://spec.openapis.org/arazzo/v1.1.0)
19
+
20
+ **Supported OpenAPI versions (for source descriptions):**
21
+
22
+ - [OpenAPI 2.0](https://spec.openapis.org/oas/v2.0)
23
+ - [OpenAPI 3.0.x](https://spec.openapis.org/oas/v3.0.4)
24
+ - [OpenAPI 3.1.x](https://spec.openapis.org/oas/v3.1.2)
25
+
26
+ ## Architecture
27
+
28
+ Running an Arazzo workflow is a pipeline of small, single-responsibility building blocks organized
29
+ into four main components:
30
+
31
+ - **`DocumentRegistry`**: loads and caches the Arazzo entry document and its OpenAPI source
32
+ descriptions, so each is fetched and parsed once.
33
+ - **`WorkflowExecutor`**: iterates a workflow's steps, owns the run state, and interprets
34
+ control-flow actions (`goto`, `retry`, `end`).
35
+ - **`StepExecutor`**: runs a single operation-shaped Arazzo step (`operationId` /
36
+ `operationPath`): locates its operation, resolves inputs, evaluates criteria and outputs, and
37
+ selects the next action. A `workflowId` step is run by `WorkflowExecutor` instead, which borrows
38
+ this component's criteria evaluation and action selection for it.
39
+ - **`OpenAPIOperationExecutor`**: runs a single OpenAPI operation: builds the HTTP request
40
+ (parameter serialization, securities, server resolution), sends it through the pluggable
41
+ `HTTPClient` transport, and normalizes the raw response into the runner's response model.
42
+
43
+ ```mermaid
44
+ flowchart TD
45
+ Registry[("DocumentRegistry<br/><i>load & cache documents</i>")]
46
+
47
+ WF["WorkflowExecutor<br/>iterate steps · own state · control flow"]
48
+ Step["StepExecutor<br/>run one Arazzo step"]
49
+ Op["OpenAPIOperationExecutor<br/>build request · normalize response"]
50
+ HTTP["HTTPClient<br/>send the built request (fetch by default)"]
51
+
52
+ WF -->|"execute step"| Step
53
+ Step -->|"execute OpenAPI operation"| Op
54
+ Op -->|"send HTTP request"| HTTP
55
+
56
+ Registry -.->|documents| WF
57
+ Registry -.->|documents| Step
58
+ Registry -.->|documents| Op
59
+
60
+ %% brand colors: Arazzo green, OpenAPI green, neutral registry grey
61
+ classDef arazzo fill:#94C83D,stroke:#6BA543,stroke-width:1px,color:#231F20;
62
+ classDef openapi fill:#6BA543,stroke:#4D5A31,color:#fff;
63
+ classDef neutral fill:#424143,stroke:#231F20,color:#fff;
64
+
65
+ class WF,Step arazzo;
66
+ class Op,HTTP openapi;
67
+ class Registry neutral;
68
+ ```
69
+
70
+ Each layer reads run state but never mutates it. The `WorkflowExecutor` is the single writer that
71
+ records outputs and interprets the returned control-flow action.
72
+
73
+ `$sourceDescriptions.{name}.{reference}` locators are resolved by two normalizers that extend the
74
+ abstract `SourceDescriptionsLocatorNormalizer`: `OpenAPIOperationLocatorNormalizer` resolves a
75
+ step's `operationId` / `operationPath` to an OpenAPI operation, and
76
+ `ArazzoWorkflowLocatorNormalizer` resolves a step's `workflowId` to an Arazzo workflow.
77
+
78
+ ## `DocumentRegistry`
79
+
80
+ Loads and caches Arazzo and OpenAPI documents, so a source description referenced by many steps is
81
+ fetched and parsed once.
82
+
83
+ ```js
84
+ import { DocumentRegistry } from '@usearazzo/runner';
85
+
86
+ const registry = new DocumentRegistry();
87
+
88
+ // the entry Arazzo document.
89
+ const arazzoDoc = await registry.acquireEntryDocument(
90
+ 'https://ui.usearazzo.com/petstore-order-workflow.arazzo.yaml',
91
+ );
92
+
93
+ // a source description, resolved by name to an absolute URI, then acquired.
94
+ const uri = arazzoDoc.resolveSourceDescriptionURI('petstoreAPI');
95
+ const openapiDoc = await registry.acquire(uri);
96
+
97
+ registry.clear(); // drop cached documents to reclaim memory
98
+ ```
99
+
100
+ ## `WorkflowExecutor`
101
+
102
+ `WorkflowExecutor` is the stateful orchestrator that runs a whole workflow. It iterates a workflow's
103
+ steps in list order, calling `StepExecutor` per step, and owns the run state (a
104
+ `WorkflowExecutionState`) that accumulates each step's outputs so later steps can read
105
+ `$steps.*.outputs`. It interprets the control-flow actions `StepExecutor` only _selects_ (advancing
106
+ to the next step, jumping via `goto`, transferring one-way to another workflow via a `goto` naming a
107
+ `workflowId`, or stopping on `end` or the failure break-default), and resolves the workflow's
108
+ `outputs` against the final state — unless the run transferred, in which case it never reaches its
109
+ own end and `outputs` is always `{}`.
110
+
111
+ Give it the entry document, registry, and a `StepExecutor` once; call `execute` per run with a
112
+ `workflowId` and its `inputs`:
113
+
114
+ ```js
115
+ import {
116
+ DocumentRegistry,
117
+ OpenAPIOperationExecutor,
118
+ StepExecutor,
119
+ WorkflowExecutor,
120
+ } from '@usearazzo/runner';
121
+
122
+ const registry = new DocumentRegistry();
123
+ const arazzoDoc = await registry.acquireEntryDocument(
124
+ 'https://ui.usearazzo.com/petstore-order-workflow.arazzo.yaml',
125
+ );
126
+
127
+ // compose the engines bottom-up: operation executor → step executor → workflow
128
+ // executor. Each takes its collaborator rather than building one, so a stub
129
+ // drops in for tests at any layer. The operation executor sends requests
130
+ // through global fetch by default; pass { httpClient } to swap the transport.
131
+ const operationExecutor = new OpenAPIOperationExecutor();
132
+ const stepExecutor = new StepExecutor({ document: arazzoDoc, registry, operationExecutor });
133
+ const executor = new WorkflowExecutor({ document: arazzoDoc, registry, stepExecutor });
134
+
135
+ const result = await executor.execute('authenticateAndOrderPet', {
136
+ inputs: {
137
+ username: 'user1',
138
+ password: 'secret',
139
+ preferredPetStatus: 'available',
140
+ },
141
+ });
142
+
143
+ console.log(result.status); // 'completed' | 'ended' | 'failed' | 'transferred'
144
+ console.log(result.settledStatus); // 'completed' | 'ended' | 'failed' — the actual verdict; see Control flow
145
+ console.log(result.outputs); // workflow $outputs, resolved against final state
146
+ console.log(result.steps); // trace: each step's id, success, action, attempts, durationMs
147
+ console.log(result.durationMs); // elapsed time for the whole run
148
+ ```
149
+
150
+ `execute` takes the workflowId and an options bag:
151
+
152
+ | option | meaning |
153
+ | ------------------ | ------------------------------------------------------------------------------------ |
154
+ | `inputs` | the workflow's inputs, read via `$inputs` |
155
+ | `executeOptions` | opaque bag forwarded to every step's operation (e.g. `server`, `requestInterceptor`) |
156
+ | `dependencyInputs` | inputs for workflows run to satisfy `dependsOn`, keyed by the entry as written |
157
+ | `runDependencies` | run this workflow's `dependsOn` workflows first (default `true`) |
158
+ | `signal` | an `AbortSignal` cancelling the run (see [Cancellation](#cancellation)) |
159
+
160
+ The run state is created fresh per `execute` call and owned internally; the returned `result` is
161
+ read-only. Every layer takes its collaborator rather than building one: `WorkflowExecutor` takes a
162
+ `StepExecutor`, which takes an `OpenAPIOperationExecutor`, which takes the `httpClient` transport.
163
+ Each therefore stays agnostic to how the layer beneath it reaches the live API, and a deterministic
164
+ stub drops in for tests at any level.
165
+
166
+ ### Control flow
167
+
168
+ After each step, the selected `onSuccess` / `onFailure` action determines what happens next:
169
+
170
+ - **no matching action**: success falls through to the next step; failure _breaks and returns_
171
+ (`status: 'failed'`);
172
+ - **`end`**: stops the run early with `status: 'ended'`, returning the outputs accumulated so far;
173
+ - **`goto` a `stepId`**: jumps to that step within the current workflow;
174
+ - **`goto` a `workflowId`**: transfers control to another workflow — see below;
175
+ - **`retry`**: re-runs the step's operation up to the action's `retryLimit` (default `1`), waiting
176
+ `retryAfter` seconds between attempts. Per spec, `retryLimit` is exhausted _before_ subsequent
177
+ failure actions run, so an exhausted `retry` falls through to the next matching failure action,
178
+ which may be another `retry` with its own independent budget or a terminal `end` or `goto`. If
179
+ none remains, the break-default applies. Each step's `attempts` count is surfaced in the result
180
+ trace.
181
+
182
+ A `retry` action may also carry a `stepId` or `workflowId` **reference** — a repair to run before
183
+ the next attempt (re-authenticate, reset a fixture). It fires once per attempt the action grants,
184
+ not once for the whole retry chain: after the `retryAfter` wait, before the step is re-run. A
185
+ `stepId` reference re-runs that step of the current workflow, charged against the same step budget
186
+ as any attempt; a `workflowId` reference runs that workflow to completion, with no inputs — the
187
+ specification gives a retry reference no input-mapping mechanism of its own, the same gap
188
+ `dependsOn` has — recording its outputs under `$workflows.<id>` the way a sub-workflow step does.
189
+ Neither kind's own success/failure actions are followed, and a failed reference does not break the
190
+ retry chain: the specification speaks of the reference's _completion_, not its success, and a
191
+ futile retry is still bounded by `retryLimit`. Reference runs are surfaced on the step's trace as
192
+ `retryReferences`, one entry per firing.
193
+
194
+ A `goto` naming a `workflowId` **transfers control one-way** to that workflow: unlike `end`, control
195
+ does not return. The specification calls `goto` "a one-way transfer of workflow control", and the
196
+ `stepId`/`workflowId` field descriptions condition "context transfers back upon completion" on
197
+ `retry` specifically — the scoping is the signal that a `goto`'s transfer does not return. The
198
+ calling workflow's own run ends at the `goto`, so its `outputs` declaration is never evaluated
199
+ (`result.outputs` is always `{}` on a transferred result) and `result.steps` keeps only the partial
200
+ trace up to the transfer. The target's own result is nested at `result.transferredTo`, with
201
+ `result.status` set to `'transferred'`; if the target itself transfers onward, the chain reads back
202
+ via `transferredTo.transferredTo`, recursively. Like a retry's `workflowId` reference, a transfer's
203
+ target runs with no inputs — a `goto` action carries no `parameters`, and the specification gives it
204
+ no input-mapping mechanism (the same gap issue `#62` tracks for retry references). A transfer joins
205
+ the same guarded call tree as everything else below — a self-transfer or a transfer cycle throws
206
+ `workflow-cycle`, and a legitimate transfer chain is bounded by `maxWorkflowDepth` — but, unlike a
207
+ retry's reference, entering the target is **not** charged against the step budget: a `goto` fires
208
+ once per step evaluation, with no way to re-fire from the same frame the way `retryLimit` lets a
209
+ reference do.
210
+
211
+ > [!NOTE]
212
+ > A transfer target is entered on the *transferring* workflow's own call-stack frame, which is why
213
+ > the cycle/depth rules above apply to it at all. This means a transfer chain does not behave like a
214
+ > `goto stepId` loop: since the transferring workflow's frame stays on the stack (there is no return
215
+ > to pop it), a workflow that transfers back to a workflow already on that chain — even indirectly,
216
+ > even once the loop's actual exit condition would have been met — throws `workflow-cycle` rather
217
+ > than looping, and a long acyclic chain of distinct workflows throws `workflow-depth` past
218
+ > `maxWorkflowDepth` (default `32`) rather than running indefinitely. A `goto stepId` loop bounded by
219
+ > ordinary data (poll until a status flips, say) has no such ceiling — only the shared step budget. A
220
+ > state machine expressed as workflows chained by `goto workflowId` should therefore be modeled as a
221
+ > bounded sequence, not a cycle that revisits a workflow already in progress.
222
+
223
+ Because a transferred result is not itself a verdict about success — only a record of where control
224
+ went — every `WorkflowExecutionResult` carries a second field, `settledStatus`, alongside `status`:
225
+ `'completed' | 'ended' | 'failed'`, computed eagerly (when the result is built, not when read) by
226
+ following a `'transferred'` result's `transferredTo` chain to its terminal, non-`transferred` result;
227
+ for a result that isn't `'transferred'`, it simply repeats `status`. Anywhere this library reads a
228
+ nested run's outcome — a `dependsOn` prerequisite, a sub-workflow step's own success, a retry
229
+ reference's recorded `successful` — it compares `settledStatus`, not `status`: a dependency,
230
+ sub-workflow step, or reference whose transfer chain settles `'completed'` or `'ended'` is treated as
231
+ satisfied; one that settles `'failed'` is treated as failed. See
232
+ [Composing workflows](#composing-workflows) for how this interacts with `$workflows.<id>.outputs`.
233
+
234
+ > [!IMPORTANT]
235
+ > `settledStatus` matters on the **top-level** result `execute()` returns, too, not only on nested
236
+ > ones. A root run that transfers reports `result.status === 'transferred'`, not `'failed'`, even when
237
+ > its target's own chain fails — `result.settledStatus === 'failed'` is the check that reflects where
238
+ > the run actually landed:
239
+ >
240
+ > ```js
241
+ > const result = await executor.execute('myWorkflow');
242
+ > if (result.settledStatus === 'failed') {
243
+ > // handles both an ordinary failed run and one that transferred into a failing chain
244
+ > }
245
+ > ```
246
+ >
247
+ > A bare `result.status === 'failed'` check silently misses the latter case. `settledStatus` is
248
+ > always present — comparing it is exactly as cheap as comparing `status`, no extra call required.
249
+ >
250
+ > For the terminal *result itself* — its `outputs`, `steps`, `workflowId` — rather than just its
251
+ > status, the exported `settledResult(result)` helper follows the same chain and returns the object it
252
+ > ends on (itself, unchanged, when `result.status` isn't `'transferred'`):
253
+ >
254
+ > ```js
255
+ > import { settledResult } from '@usearazzo/runner';
256
+ >
257
+ > const landedOn = settledResult(result);
258
+ > console.log(landedOn.workflowId, landedOn.outputs);
259
+ > ```
260
+
261
+ A runaway `goto` loop, a runaway `retry`, **or** a runaway tree of sub-workflow calls is bounded by
262
+ `maxSteps` (default `1000`), which counts every step attempt and throws `ExecutionError`
263
+ (`reason: 'step-budget'`) when exceeded. The budget is shared by the whole call tree rather than
264
+ granted afresh per workflow, so a sub-workflow cannot escape the ceiling the caller set; the error
265
+ carries the offending `path` (the chain of workflowIds in progress). The `retryAfter` delay uses an
266
+ injectable `sleep` (`WorkflowExecutorOptions.sleep`, default a real timer) so tests can run without
267
+ waiting, and `durationMs` uses an injectable `now` (default the monotonic `performance.now`) so
268
+ they can assert timings.
269
+
270
+ ### Composing workflows
271
+
272
+ A step targeting a `workflowId` calls another workflow. Its `parameters` become that workflow's
273
+ inputs (mapped by `name`, and including any the calling workflow passed down), the sub-run is
274
+ recorded under
275
+ `$workflows.<id>`, and the step's own `outputs` and `successCriteria` are then resolved against that
276
+ updated state. A sub-run that ends `failed` puts the step on its failure path, so `onFailure` —
277
+ including `retry`, which re-runs the whole sub-workflow — applies exactly as for an operation step.
278
+
279
+ The called workflow is the step's result. The specification does not say what `$outputs` or the
280
+ HTTP-exchange expressions mean in such a step, and the runner reads them as follows:
281
+
282
+ - **`$outputs.<name>` names the called workflow's outputs** in the step's `successCriteria`, `outputs`
283
+ and action `criteria` — the reading every specification example that uses it relies on. The
284
+ enclosing workflow's own outputs do not exist until it ends, so that reading could never resolve
285
+ inside a step. Elsewhere `$outputs` keeps its usual meaning, and `$workflows.<id>.outputs.<name>`
286
+ works everywhere.
287
+ - **`$statusCode`, `$url`, `$method`, `$request.*` and `$response.*` throw** a
288
+ `RuntimeExpressionError` (`reason: 'unavailable-expression'`) naming the expression and the step
289
+ when used in its `successCriteria` or `outputs`: they describe one HTTP exchange, and a step that
290
+ calls a workflow made none. In an `onSuccess` / `onFailure` action's `criteria` they make the
291
+ action **not match** instead — actions are shared (inherited from the workflow's
292
+ `successActions` / `failureActions`, reused from `components`), so a default written for the
293
+ operation steps must not abort the run at a step that calls a workflow. The step's `parameters`
294
+ are resolved before the call, so there these expressions stay ordinary unresolved references.
295
+
296
+ ```yaml
297
+ - stepId: place-order
298
+ workflowId: place-order
299
+ successCriteria:
300
+ - condition: $outputs.workflow_order_id != null # not $statusCode == 200
301
+ outputs:
302
+ my_order_id: $outputs.workflow_order_id
303
+ ```
304
+
305
+ A workflow's `dependsOn` workflows are run to completion before its own steps. Each one's outputs
306
+ become readable as `$workflows.<id>.outputs`; they are not merged into the dependent's own outputs.
307
+ A dependency already completed in the same run is not run again, so a diamond does not duplicate its
308
+ side effects. Because the specification gives `dependsOn` no input-mapping mechanism, a dependency
309
+ that needs inputs takes them from the caller's `dependencyInputs` map; pass `runDependencies: false`
310
+ to assert they were already satisfied out-of-band. That assertion covers the workflow you name and
311
+ no more — a sub-workflow it calls still runs its own prerequisites, which the caller may not know
312
+ exist. A dependency that fails makes the run `failed` without executing any of its own steps.
313
+
314
+ Both recurse through one guarded call tree. Re-entering a workflow already in progress throws
315
+ (`reason: 'workflow-cycle'`, or `'dependsOn-cycle'` when every edge closing the loop is a dependency
316
+ edge), carrying the offending `path`; nesting past `maxWorkflowDepth` (default `32`, counting the
317
+ workflow `execute` was called with) throws `reason: 'workflow-depth'`. A diamond is not a cycle: a
318
+ workflow that completed and unwound may be entered again.
319
+
320
+ The result mirrors this structure. A step record carries `subWorkflows` — one nested
321
+ `WorkflowExecutionResult` per attempt, so a retried sub-workflow step keeps every attempt's trace —
322
+ and a result carries `dependencies`, the prerequisite runs in declaration order. A result whose
323
+ `status` is `'transferred'` (see [Control flow](#control-flow)) additionally carries `transferredTo`,
324
+ nesting the transfer target's own result — a workflow that ran `dependsOn` prerequisites before
325
+ transferring still reports them under `dependencies`; the two fields are independent, not exclusive.
326
+
327
+ A nested run reached this way — a sub-workflow step's target, a `dependsOn` prerequisite, a retry
328
+ reference — is judged by its `settledStatus`, not by a raw `'transferred'` status: a sub-workflow
329
+ step that targets a workflow which transfers onward to a completed run still takes
330
+ the success path, and a `dependsOn` prerequisite that transfers onward to a failed run still fails
331
+ the dependent. Either way, the nested run's own recorded identity and `$workflows.<id>.outputs`
332
+ reflect the workflow that was actually *called*, not the one control ended up in — a transferred
333
+ call always records `outputs: {}` there, for the same reason its own top-level `outputs` is `{}`
334
+ (see [Control flow](#control-flow)). The terminal outputs of a transfer chain are reachable, just
335
+ not under that key: follow `transferredTo.outputs` (or `transferredTo.transferredTo.outputs`, for a
336
+ chain) on the nested result itself, found on the calling step's `subWorkflows` entry or the result's
337
+ `dependencies` entry.
338
+
339
+ ### Cross-document workflow references
340
+
341
+ Anywhere a workflow is referenced — a sub-workflow step's `workflowId`, a `dependsOn` entry, a
342
+ `retry` action's `workflowId` reference, a `goto`'s transfer target — the reference may name a
343
+ workflow of **another Arazzo document**, written as the runtime expression
344
+ `$sourceDescriptions.<name>.<workflowId>`. The named source description must resolve to an Arazzo
345
+ document; its declared `type` is not trusted — the source is classified by what it actually parses
346
+ to, the same way operation sources are. Only that dotted spec form is a workflow reference: the
347
+ resolver package's `$sourceDescriptions.<name>#/json/pointer` form is a `$ref`-time construct, not
348
+ a runtime expression, and is rejected (`reason: 'invalid-workflow-reference'`).
349
+
350
+ A foreign workflow runs **against its own document**: its plain `operationId`s resolve against
351
+ *that* document's source descriptions, its `$components` / `$sourceDescriptions` expressions
352
+ against that document, and its own plain workflow references (a nested `workflowId` step, its own
353
+ `dependsOn`) within that document. Source description *names* are likewise scoped to the document
354
+ they are written in — two documents may both name a source `petstoreAPI` and mean different
355
+ documents.
356
+
357
+ Two keying rules follow from the runtime-expression grammar and are worth knowing:
358
+
359
+ - **`$workflows.<id>` state stays keyed by the bare workflowId** — the only form an expression can
360
+ read it back by. If the entry document and a foreign document both define a workflow of one id
361
+ and one run records both, the later write wins under that key (the same last-write-wins two
362
+ same-document calls of one workflow already have). The cycle/depth guards, dependency
363
+ memoization, and caches are *not* affected — internally every workflow is identified by its
364
+ document URI together with its id (which is also how a foreign workflow displays in an error
365
+ `path`: `<documentURI>#<workflowId>`).
366
+ - **`dependencyInputs` is keyed by the `dependsOn` entry as written** — the bare id for a
367
+ same-document dependency, the whole `$sourceDescriptions.<name>.<workflowId>` expression for a
368
+ cross-document one.
369
+
370
+ A bad reference throws `ExecutionError`: `invalid-workflow-reference` (a `$`-prefixed reference
371
+ that is not a `$sourceDescriptions` expression), `source-description-not-found` (the named source
372
+ is absent), `source-description-not-arazzo` (it resolves to a non-Arazzo document — e.g. a workflow
373
+ reference into an OpenAPI source), or `workflow-not-found` (the document does not define the id;
374
+ the message names which document was searched).
375
+
376
+ ### Workflow-level default actions
377
+
378
+ A workflow's `successActions` / `failureActions` apply to every step as a **default**. A step that
379
+ declares its own `onSuccess` / `onFailure` **overrides** the corresponding workflow list wholesale.
380
+ There is no per-action merge, and success and failure fall back independently (a step may override
381
+ only its failure actions and still inherit the workflow's success actions).
382
+
383
+ Like the parameters below, this is inheritance rather than dispatch, and happens in
384
+ `ArazzoWorkflowNormalizer`: a normalized workflow's steps already carry the defaults they inherit,
385
+ so the executors only ever read a step's own `onSuccess` / `onFailure`. Presence is what the
386
+ override keys off, not content — a step declaring `onSuccess: []` has overridden the default with an
387
+ empty set of actions and inherits nothing, whereas a step omitting the key entirely inherits.
388
+
389
+ Both kinds of step take the defaults, including one targeting a `workflowId`.
390
+
391
+ ### Workflow-level parameters
392
+
393
+ A workflow's `parameters` apply to every step it contains. Unlike the actions above these **merge**
394
+ rather than replace: the specification lets a step override an inherited parameter but says it "can
395
+ never remove" one, so each step ends up with the union of the two lists, its own declaration
396
+ winning.
397
+
398
+ This happens in `ArazzoWorkflowNormalizer` too — a normalized workflow's steps already carry what
399
+ they inherit, exactly as a normalized OpenAPI operation already carries the parameters it inherits
400
+ from its Path Item. Neither executor knows about it.
401
+
402
+ A parameter's identity is the **`(name, in)` pair**, not `name` alone — the rule ApiDOM applies for
403
+ that OpenAPI-side inheritance ("a unique parameter is defined by a combination of a name and
404
+ location"). So a step declaring a `trace` query parameter overrides an inherited `trace` query
405
+ parameter but leaves an inherited `trace` *header* in place; they are two parameters bound for two
406
+ places. Names are case-sensitive, per the specification. A step's own parameters lead the merged
407
+ list, again matching the OpenAPI side.
408
+
409
+ Inheritance copies the **declarations**, not resolved values, so a workflow-level `value` that is a
410
+ runtime expression is still evaluated **once per step, in that step's own context**. A workflow
411
+ parameter reading `$steps.login.outputs.token` therefore means what it looks like it means: each
412
+ step sees the state as it was entered, not a value frozen when the workflow began.
413
+
414
+ A step targeting a `workflowId` inherits them too. Such a step's parameters are workflow inputs
415
+ rather than parts of a request and carry no `in`, so an absent `in` forms its own identity and name
416
+ alone decides what overrides what. An inherited parameter that *does* carry an `in` still applies
417
+ there, per the specification's "when the step in context specifies a `workflowId`, then all
418
+ parameters map to workflow inputs" — it arrives as an input under its bare name.
419
+
420
+ > [!NOTE]
421
+ > Resolved values are keyed by `name` alone when they are handed to the client, so two parameters
422
+ > differing only in `in` collapse into one at that boundary — the **step's own** survives, since the
423
+ > merged list is read most-specific-first. That collapse predates workflow-level parameters (a
424
+ > single step can declare both) and is tracked separately; what matters here is that a step can
425
+ > never lose to something it inherited.
426
+
427
+ ### Cancellation
428
+
429
+ Pass an `AbortSignal` as `signal` to cancel a run:
430
+
431
+ ```js
432
+ const controller = new AbortController();
433
+ setTimeout(() => controller.abort(), 5_000);
434
+
435
+ const result = await executor.execute('authenticateAndOrderPet', {
436
+ inputs: { username: 'user1', password: 'secret' },
437
+ signal: controller.signal,
438
+ });
439
+ ```
440
+
441
+ The signal is observed at every boundary in the call tree — before each step, before each **retry
442
+ attempt** of a step, before entering a sub-workflow or a `dependsOn` prerequisite — and during the
443
+ `retryAfter` waits between attempts, which the default timer cuts short rather than sitting out. It
444
+ is also forwarded to the transport, so the request in flight when the abort lands is cancelled
445
+ rather than merely awaited (the `HTTPClient` contract asks every transport to honor
446
+ `request.signal` — global fetch does it for free, since the request doubles as fetch init; an axios
447
+ or undici client passes `request.signal` through in one line). A transport that ignores it costs
448
+ one in-flight request: the run still stops at the next boundary.
449
+
450
+ An aborted run throws `ExecutionError` with `reason: 'aborted'`, naming the boundary it stopped at
451
+ (`workflowId`, `stepId`, and the `path` of workflows in progress) and carrying the signal's own
452
+ `reason` as the error's `cause`. One shape covers every case: a transport that honors the signal
453
+ rejects the request it was told to drop, and that rejection is reported as the cancellation it is
454
+ rather than as the `ClientError` a request failing on its own terms would raise — so a run
455
+ cancelled mid-flight reads the same as one cancelled between two steps, whichever transport is
456
+ plugged in. It does **not** resolve as a `failed` result: cancellation is not
457
+ something the steps did, and the steps that never ran were not decided against by any criteria.
458
+
459
+ A `signal` passed through `executeOptions` instead — the only channel before this option existed —
460
+ cancels the run just the same; the first-class option wins when both are given. `StepExecutor`
461
+ honors a `signal` in its execute options when used on its own too, throwing `aborted` instead of
462
+ dispatching a request that would be cancelled on the wire.
463
+
464
+ ### Authoring errors vs. failed runs
465
+
466
+ Same split as `StepExecutor`: a step whose `successCriteria` go unmet with no redirecting action is
467
+ a normal `status: 'failed'` result, **not** a throw — as is a run whose `dependsOn` workflow failed.
468
+ Authoring errors throw `ExecutionError` — as does a cancelled run (`aborted`, above): an unknown
469
+ `workflowId` (`workflow-not-found`), a `goto` to a step that does not exist
470
+ (`goto-target-not-found`), a `goto` naming neither `stepId` nor `workflowId`
471
+ (`goto-target-missing`), a `retry` reference to a `stepId` this workflow does not declare
472
+ (`retry-target-not-found`), an action of an unknown `type` (`unknown-action-type`), a
473
+ present but malformed `steps` or `dependsOn` (`malformed-steps`, `malformed-dependsOn`), a step, a
474
+ `goto`, or a `retry` reference naming more than one target (`ambiguous-target`), a cycle or
475
+ over-deep nesting (`workflow-cycle`, `dependsOn-cycle`, `workflow-depth`), the step-budget
476
+ overflow above (`step-budget`), or a bad cross-document reference (`invalid-workflow-reference`,
477
+ `source-description-not-found`, `source-description-not-arazzo` — see
478
+ [Cross-document workflow references](#cross-document-workflow-references)).
479
+
480
+ A workflow's own `steps` and `dependsOn` lists are validated before any of its prerequisites run, so
481
+ those two mistakes never fire live requests on the way to throwing. Errors belonging to an
482
+ individual step — a step naming two targets, a `goto` to a step that does not exist, an unknown
483
+ action `type` — are raised when the run reaches that step, which means earlier steps have already
484
+ executed. That is deliberate: a bad step the run never reaches should not fail an otherwise valid
485
+ run.
486
+
487
+ ## `StepExecutor`
488
+
489
+ Executes a single operation-shaped Arazzo step — one that invokes an OpenAPI operation — returning
490
+ its outcome. It orchestrates the full per-step pipeline:
491
+
492
+ 1. locate the step's operation (`operationId` or `operationPath`);
493
+ 2. resolve the step's `parameters` and `requestBody` against the pre-request context (`$inputs`,
494
+ `$steps.*.outputs`, …);
495
+ 3. delegate the call to `OpenAPIOperationExecutor`;
496
+ 4. evaluate `successCriteria`, resolve `outputs`, and select the `onSuccess` / `onFailure` action
497
+ against the post-request context (`$statusCode`, `$response.*`, `$request.*`, `$url`, `$method`).
498
+
499
+ `StepExecutor` **reads run state and mutates nothing**: it returns the resolved outputs and the
500
+ selected action for the caller to record and interpret. A step targeting a `workflowId` (a
501
+ sub-workflow) is not an operation step and throws; running sub-workflows is the `WorkflowExecutor`'s
502
+ concern — which still evaluates such a step's `successCriteria` and selects its actions through
503
+ this component's public `evaluateCriteria` / `selectActions`, so step semantics live in one place.
504
+
505
+ A step executor is bound to the document its steps belong to (the `document` option): plain
506
+ `operationId`s resolve against that document's source descriptions, and expressions resolve their
507
+ `$components` / `$sourceDescriptions` against it. `forDocument(document)` derives a sibling
508
+ executor bound to another document, sharing the registry and operation executor — how
509
+ `WorkflowExecutor` runs the steps of a
510
+ [cross-document workflow](#cross-document-workflow-references).
511
+
512
+ It delegates the located operation to an injected `OpenAPIOperationExecutor` (below) rather than
513
+ building one, so it stays agnostic to the operation pipeline and HTTP stack:
514
+
515
+ ```js
516
+ import {
517
+ DocumentRegistry,
518
+ ArazzoWorkflowExtractor,
519
+ ArazzoStepExtractor,
520
+ WorkflowExecutionState,
521
+ OpenAPIOperationExecutor,
522
+ StepExecutor,
523
+ } from '@usearazzo/runner';
524
+
525
+ const registry = new DocumentRegistry();
526
+ const arazzoDoc = await registry.acquireEntryDocument(
527
+ 'https://ui.usearazzo.com/petstore-order-workflow.arazzo.yaml',
528
+ );
529
+
530
+ // pull a step out of the loaded Arazzo document by workflow + step id.
531
+ const workflow = new ArazzoWorkflowExtractor().extract(arazzoDoc, 'authenticateAndOrderPet');
532
+ const step = new ArazzoStepExtractor().extract(workflow, 'findAvailablePets');
533
+
534
+ const operationExecutor = new OpenAPIOperationExecutor();
535
+ const executor = new StepExecutor({ document: arazzoDoc, registry, operationExecutor });
536
+
537
+ // run state carries $inputs and accumulates $steps.*.outputs across a run.
538
+ const state = new WorkflowExecutionState({ inputs: { preferredPetStatus: 'available' } });
539
+
540
+ const outcome = await executor.execute(step, state);
541
+
542
+ console.log(outcome.successful); // true when every successCriterion passed
543
+ console.log(outcome.outputs); // resolved step outputs, keyed by name
544
+ console.log(outcome.action); // the selected onSuccess / onFailure action, or undefined
545
+
546
+ // a caller records the outputs so a later step can read $steps.findAvailablePets.outputs.*
547
+ state.setStepOutputs(outcome.stepId, outcome.outputs);
548
+ ```
549
+
550
+ ### Request body payloads
551
+
552
+ A parameter `value` or an `outputs` entry is either a literal or one whole runtime expression. A
553
+ `requestBody` payload is different: it "can contain" runtime expressions, so it is walked. Every
554
+ string in it — the payload itself, or any value nested in its objects and arrays — resolves by one
555
+ rule:
556
+
557
+ - a whole runtime expression (`$inputs.petId`) resolves to its **typed** value;
558
+ - any other string has its embedded `{expression}` occurrences interpolated into a **string**
559
+ (`"Order for {$inputs.name}"`, `"{$inputs.petId}"` → `"7"`). Interpolation is tolerant, so
560
+ literal braces stay as written — a JSON or XML template string works as-is.
561
+
562
+ Object keys are never evaluated, and neither are resolved values: a value read from `$inputs` that
563
+ happens to contain `{$...}` is data, not a template. Interpolated values are inserted raw — escaping
564
+ them for the payload's content type is up to the author — with `null` rendered as an empty string
565
+ and objects and arrays as JSON text. A `replacements` entry's `value` is not part of the walk: like a
566
+ parameter value, it is a literal or one whole expression, so `'{$inputs.petId}'` there stays literal.
567
+
568
+ ```yaml
569
+ requestBody:
570
+ contentType: application/json
571
+ payload:
572
+ petId: $inputs.pet_id # typed: 10
573
+ couponCode: $inputs.coupon_code # omitted when coupon_code is not provided
574
+ note: 'Order for {$inputs.name}' # string
575
+ ```
576
+
577
+ An expression that does not resolve reads as **not provided**. Where leaving it out is possible it
578
+ is left out: an object member is omitted, and a `replacements` entry whose `value` does not resolve
579
+ is skipped. Where it is not — a whole-expression payload, an array element (dropping it would shift
580
+ the indices), or an embedded expression (it would render as an empty string) — resolution throws a
581
+ `ResolverError` with `reason: 'unresolved-expression'` and the location as `target`
582
+ (`requestBody.payload#/order/note`). A `{$` always starts an expression attempt, so one that does
583
+ not parse — `{$inputs.}`, `{$foo.bar}`, an unterminated `{$url` — throws with
584
+ `reason: 'invalid-expression'` rather than being sent as text. A value that resolves to `null` is
585
+ provided and is kept.
586
+
587
+ ### Authoring errors vs. failed steps
588
+
589
+ The two are deliberately distinct:
590
+
591
+ - A **received response with unmet criteria** is a normal outcome: `successful: false`, no throw.
592
+ - **Malformed input** throws an `ExecutionError` (a step with no operation target, more than one
593
+ mutually-exclusive target, a `workflowId` step, or an operation that cannot be located).
594
+ - A **field the specification types as `{expression}`** must hold a valid runtime expression, or the
595
+ step throws instead of carrying the text into later requests: a step or workflow `outputs` value
596
+ that is not one (`$response.body.id` is not Arazzo syntax; `$response.body#/id` is) raises a
597
+ `ResolverError` with `reason: 'invalid-expression'`, and a criterion's `context` that is not one,
598
+ whatever the criterion `type`, raises a `CriterionError` with `reason: 'invalid-context'`.
599
+ Literals stay valid where the specification types a field `Any` (parameter and payload values).
600
+ - A **cancelled run** throws too (`reason: 'aborted'`): an `AbortSignal` passed as `signal` in the
601
+ execute options is checked before the request is dispatched, so an already-aborted run does not
602
+ issue a request nobody is waiting for — and a request the transport drops mid-flight is reported
603
+ as the cancellation rather than as the `ClientError` a request failing on its own terms raises.
604
+
605
+ ## `OpenAPIOperationExecutor`
606
+
607
+ Executes a single OpenAPI operation and returns its normalized response. It is **Arazzo-agnostic**:
608
+ it neither locates the operation nor resolves runtime expressions. Given a canonical locator
609
+ (`{ document, jsonPointer }`) it runs a three-stage pipeline:
610
+
611
+ 1. **build**: extracts the operation from its owning document, normalizes it, assembles a minimal
612
+ standalone OpenAPI document containing just that operation, and builds the HTTP request from it:
613
+ parameter serialization (style/explode), security application, and server resolution, across
614
+ OpenAPI 2.0 through 3.1;
615
+ 2. **send**: hands the built request to the pluggable `httpClient` transport (global fetch by
616
+ default);
617
+ 3. **normalize**: deserializes the raw WHATWG `Response` by content type into the runner's
618
+ `OpenAPIOperationResponse`, the model runtime expressions and criteria see. Header values stay
619
+ strings as the server sent them (`$response.header.Date` is `Thu, 22 Oct 2026 11:45:22 GMT`),
620
+ and a repeated field, `Set-Cookie` included, is combined into one `", "`-joined string, as
621
+ `Headers.get` returns it.
622
+
623
+ Because the executor builds the request explicitly and holds it, the response carries the request
624
+ **as actually sent** (`response.request`): final URL, method, headers, and serialized body. That way
625
+ `$url` / `$method` / `$request.*` are evaluated against reality, not intent.
626
+
627
+ A step's `requestBody` works the same against every supported version, including Swagger 2.0, which
628
+ has no request-body concept and carries the payload as a declared `body` or `formData` parameter
629
+ instead. Its `contentType` is matched ignoring case and media-type parameters, and a declared range
630
+ counts as covering a concrete type, so `application/json; charset=utf-8` names a declared
631
+ `application/json` and either names a declared `*/*`. The form media types are the exception, since
632
+ they decide how the body is encoded and so must be declared exactly. A `contentType` matching
633
+ nothing raises a `ClientError` rather than sending an empty or wrongly encoded body.
634
+
635
+ ### Choosing the server
636
+
637
+ By default the request goes to the first server the operation declares (for Swagger 2.0, the
638
+ document's `schemes` + `host` + `basePath`), with a relative server resolved against the URL the
639
+ document itself was loaded from. That covers the common case with no options at all.
640
+
641
+ **`server`** changes where it goes, and does double duty:
642
+
643
+ ```js
644
+ // selection: the value matches a declared server, so its variable defaults
645
+ // come along and only the ones you name are overridden
646
+ await executor.execute(locator, {
647
+ server: 'https://{region}.example.com/{basePath}', // the raw template, as declared
648
+ serverVariables: { region: 'us' }, // basePath keeps its declared default
649
+ });
650
+
651
+ // override: the value matches nothing declared, so it simply is the base URL
652
+ await executor.execute(locator, { server: 'https://staging.internal.test/api' });
653
+ ```
654
+
655
+ There is deliberately **no silent fallback**: a `server` matching nothing declared is never quietly
656
+ swapped for the first declared one. The URL you name is the URL that gets called, so a typo fails
657
+ loudly at the network rather than succeeding against the wrong host. An override replaces the
658
+ declared base path along with the host, so include it (`…/api/v3`) if the API expects one.
659
+ Overriding works on Swagger 2.0 too; selection and `serverVariables` are 3.x-only, since 2.0 has no
660
+ server list and no URL templates.
661
+
662
+ Relative servers resolve against the URL the document was loaded from, which the executor already
663
+ knows, so a document fetched from `https://petstore3.swagger.io/api/v3/openapi.json` declaring
664
+ `servers: [{ url: '/api/v3' }]` needs no configuration at all. For a document loaded from disk, give
665
+ `server` an absolute URL.
666
+
667
+ ### The `HTTPClient` seam
668
+
669
+ The transport is the extension point, and its contract is deliberately minimal: a function from the
670
+ built request to a WHATWG `Response`. The default, exported as `httpClientFetch`, is global fetch in
671
+ one line (`(request) => fetch(request.url, request)`) and is used when no `httpClient` is given:
672
+
673
+ ```js
674
+ import { fetch, Agent } from 'undici';
675
+ import { OpenAPIOperationExecutor } from '@usearazzo/runner';
676
+
677
+ // the default transport, no configuration needed
678
+ const executor = new OpenAPIOperationExecutor();
679
+
680
+ // any HTTP stack drops in with a few lines: here undici with a dispatcher the
681
+ // global fetch cannot take (connection-pool tuning, a proxy, client certificates)
682
+ const dispatcher = new Agent({ connections: 128, keepAliveTimeout: 60_000 });
683
+
684
+ const pooledExecutor = new OpenAPIOperationExecutor({
685
+ httpClient: (request) => fetch(request.url, { ...request, dispatcher }),
686
+ });
687
+
688
+ // or serve canned responses in a test: no network, no interception hooks
689
+ const offlineExecutor = new OpenAPIOperationExecutor({
690
+ httpClient: async () =>
691
+ new Response('[{"id":1,"status":"available"}]', {
692
+ status: 200,
693
+ headers: { 'content-type': 'application/json' },
694
+ }),
695
+ });
696
+ ```
697
+
698
+ `httpClientFetch` is exported so a transport that only wants to add behavior around the network call
699
+ can delegate to it rather than re-implement fetch.
700
+
701
+ The contract:
702
+
703
+ - resolve with a `Response` for **every** HTTP status: a non-2xx is a valid Arazzo outcome judged by
704
+ a step's `successCriteria`, never an error;
705
+ - throw only on genuine transport failure: the executor wraps it as a `ClientError` carrying the
706
+ original error as `cause`;
707
+ - honor `request.signal` when present.
708
+
709
+ Response normalization runs on the executor's side of the seam for every client, so `$response.body`
710
+ semantics stay identical no matter which transport is plugged in.
711
+
712
+ ### Request decoration (and authentication)
713
+
714
+ Arazzo is auth-agnostic (a workflow document says nothing about credentials), so the runner does not
715
+ model authentication either. Credentials are applied by decorating the outgoing request with a
716
+ `requestInterceptor`, which runs after the request is built and before it is sent, may be
717
+ synchronous or asynchronous, and may mutate the request or return (or resolve with) a replacement:
718
+
719
+ ```js
720
+ const executor = new OpenAPIOperationExecutor({
721
+ requestInterceptor: async (request) => {
722
+ request.headers.Authorization = `Bearer ${await acquireToken()}`;
723
+ },
724
+ });
725
+ ```
726
+
727
+ A per-call `requestInterceptor` can also ride in the execute options bag; it runs after the
728
+ executor-level one.
729
+
730
+ Because the interceptor sets headers directly, it works regardless of how (or whether) the OpenAPI
731
+ document declares its security schemes. If you'd rather have credentials applied _from_ those
732
+ declarations, placed in the header, query, or cookie each scheme prescribes, `buildRequest`'s
733
+ `securities` option still passes through the execute options bag untouched; it is simply not part of
734
+ the typed surface.
735
+
736
+ Decoration via the interceptor, rather than inside a custom `httpClient`, is what makes the edits
737
+ part of the request record: the executor captures `response.request` (which feeds `$request.*` and
738
+ the trace) after the interceptors run and before the transport is called, so transport-side changes
739
+ are deliberately invisible to it.
740
+
741
+ ### Standalone use
742
+
743
+ Because it is Arazzo-agnostic, the executor can be used **standalone**: with only an OpenAPI
744
+ document and an `operationId`, no Arazzo workflow involved. The operation index on the loaded
745
+ document maps an `operationId` to its JSON Pointer, which is all a locator needs:
746
+
747
+ ```js
748
+ import { DocumentRegistry, OpenAPIOperationExecutor } from '@usearazzo/runner';
749
+
750
+ const registry = new DocumentRegistry();
751
+ const openapiDoc = await registry.acquire('https://petstore3.swagger.io/api/v3/openapi.json');
752
+
753
+ // build a canonical { document, jsonPointer } locator straight from the OpenAPI
754
+ // document; the operation index resolves an operationId to its JSON Pointer.
755
+ const locator = {
756
+ document: openapiDoc,
757
+ jsonPointer: openapiDoc.operationIndex.get('findPetsByStatus'),
758
+ };
759
+
760
+ const executor = new OpenAPIOperationExecutor();
761
+
762
+ // the operation's relative server resolves against the URL the document was
763
+ // loaded from, so there is nothing to configure.
764
+ const response = await executor.execute(locator, { parameters: { status: 'available' } });
765
+
766
+ console.log(response.status, response.body);
767
+ console.log(response.request.url); // the URL as actually sent
768
+ ```
769
+
770
+ A non-2xx response is returned as data, not thrown; whether it counts as success is judged one level
771
+ up, by a step's `successCriteria`. Everything else throws with a named reason: malformed input (an
772
+ unlocatable operation, an unsupported OpenAPI version), a request that cannot be built (e.g. a
773
+ missing required parameter), and transport failures. The latter two arrive as `ClientError`.
774
+
775
+ ## End-to-end tests
776
+
777
+ `npm run test:e2e` runs `authenticateAndOrderPet` (the toolkit's own petstore workflow) and
778
+ `loginUserRetrievePet` (the Arazzo Specification's `LoginAndRetrievePets` example) over real HTTP
779
+ against a live petstore. It is separate from `npm test`, which stays deterministic and makes no
780
+ network calls.
781
+
782
+ By default the suite starts a `swaggerapi/petstore3:1.0.28` container with Docker, on a free port,
783
+ and stops it afterwards, so Docker must be available. To run against an existing petstore instead,
784
+ set `PETSTORE_URL` to its API base, e.g. `PETSTORE_URL=http://localhost:8080/api/v3 npm run test:e2e`;
785
+ the suite then starts no container of its own.