@ontrails/core 1.0.0-beta.13 → 1.0.0-beta.15

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 (287) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +19 -14
  4. package/dist/branded.d.ts +1 -1
  5. package/dist/branded.d.ts.map +1 -1
  6. package/dist/branded.js +1 -1
  7. package/dist/branded.js.map +1 -1
  8. package/dist/context.d.ts +13 -1
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +19 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/contour.d.ts +81 -0
  13. package/dist/contour.d.ts.map +1 -0
  14. package/dist/contour.js +176 -0
  15. package/dist/contour.js.map +1 -0
  16. package/dist/cross-schema.d.ts +18 -0
  17. package/dist/cross-schema.d.ts.map +1 -0
  18. package/dist/cross-schema.js +29 -0
  19. package/dist/cross-schema.js.map +1 -0
  20. package/dist/derive.d.ts +6 -0
  21. package/dist/derive.d.ts.map +1 -1
  22. package/dist/derive.js +29 -5
  23. package/dist/derive.js.map +1 -1
  24. package/dist/draft.d.ts +28 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +157 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/errors.d.ts +62 -4
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +44 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/execute.d.ts +26 -9
  33. package/dist/execute.d.ts.map +1 -1
  34. package/dist/execute.js +441 -20
  35. package/dist/execute.js.map +1 -1
  36. package/dist/fire.d.ts +43 -0
  37. package/dist/fire.d.ts.map +1 -0
  38. package/dist/fire.js +185 -0
  39. package/dist/fire.js.map +1 -0
  40. package/dist/index.d.ts +33 -17
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +26 -12
  43. package/dist/index.js.map +1 -1
  44. package/dist/internal/cross-batch.d.ts +44 -0
  45. package/dist/internal/cross-batch.d.ts.map +1 -0
  46. package/dist/internal/cross-batch.js +58 -0
  47. package/dist/internal/cross-batch.js.map +1 -0
  48. package/dist/internal/fork-ctx.d.ts +41 -0
  49. package/dist/internal/fork-ctx.d.ts.map +1 -0
  50. package/dist/internal/fork-ctx.js +47 -0
  51. package/dist/internal/fork-ctx.js.map +1 -0
  52. package/dist/internal/signal-ref.d.ts +15 -0
  53. package/dist/internal/signal-ref.d.ts.map +1 -0
  54. package/dist/internal/signal-ref.js +41 -0
  55. package/dist/internal/signal-ref.js.map +1 -0
  56. package/dist/internal/topo-saves.d.ts +53 -0
  57. package/dist/internal/topo-saves.d.ts.map +1 -0
  58. package/dist/internal/topo-saves.js +449 -0
  59. package/dist/internal/topo-saves.js.map +1 -0
  60. package/dist/internal/topo-snapshots.d.ts +57 -0
  61. package/dist/internal/topo-snapshots.d.ts.map +1 -0
  62. package/dist/internal/topo-snapshots.js +372 -0
  63. package/dist/internal/topo-snapshots.js.map +1 -0
  64. package/dist/internal/topo-store-read.d.ts +70 -0
  65. package/dist/internal/topo-store-read.d.ts.map +1 -0
  66. package/dist/internal/topo-store-read.js +221 -0
  67. package/dist/internal/topo-store-read.js.map +1 -0
  68. package/dist/internal/topo-store.d.ts +26 -0
  69. package/dist/internal/topo-store.d.ts.map +1 -0
  70. package/dist/internal/topo-store.js +621 -0
  71. package/dist/internal/topo-store.js.map +1 -0
  72. package/dist/internal/tracing.d.ts +98 -0
  73. package/dist/internal/tracing.d.ts.map +1 -0
  74. package/dist/internal/tracing.js +102 -0
  75. package/dist/internal/tracing.js.map +1 -0
  76. package/dist/internal/trails-db.d.ts +16 -0
  77. package/dist/internal/trails-db.d.ts.map +1 -0
  78. package/dist/internal/trails-db.js +118 -0
  79. package/dist/internal/trails-db.js.map +1 -0
  80. package/dist/internal/zod-wrappers.d.ts +40 -0
  81. package/dist/internal/zod-wrappers.d.ts.map +1 -0
  82. package/dist/internal/zod-wrappers.js +59 -0
  83. package/dist/internal/zod-wrappers.js.map +1 -0
  84. package/dist/layer.d.ts +22 -5
  85. package/dist/layer.d.ts.map +1 -1
  86. package/dist/layer.js +20 -3
  87. package/dist/layer.js.map +1 -1
  88. package/dist/path-security.d.ts +1 -1
  89. package/dist/path-security.d.ts.map +1 -1
  90. package/dist/path-security.js +1 -1
  91. package/dist/path-security.js.map +1 -1
  92. package/dist/resilience.d.ts +1 -1
  93. package/dist/resilience.d.ts.map +1 -1
  94. package/dist/resilience.js +3 -3
  95. package/dist/resilience.js.map +1 -1
  96. package/dist/resource-config.d.ts +22 -0
  97. package/dist/resource-config.d.ts.map +1 -0
  98. package/dist/resource-config.js +210 -0
  99. package/dist/resource-config.js.map +1 -0
  100. package/dist/resource.d.ts +74 -0
  101. package/dist/resource.d.ts.map +1 -0
  102. package/dist/resource.js +61 -0
  103. package/dist/resource.js.map +1 -0
  104. package/dist/run.d.ts +4 -3
  105. package/dist/run.d.ts.map +1 -1
  106. package/dist/run.js +5 -4
  107. package/dist/run.js.map +1 -1
  108. package/dist/serialization.d.ts.map +1 -1
  109. package/dist/serialization.js +2 -1
  110. package/dist/serialization.js.map +1 -1
  111. package/dist/store/accessor-protocol.d.ts +51 -0
  112. package/dist/store/accessor-protocol.d.ts.map +1 -0
  113. package/dist/store/accessor-protocol.js +2 -0
  114. package/dist/store/accessor-protocol.js.map +1 -0
  115. package/dist/store/index.d.ts +2 -0
  116. package/dist/store/index.d.ts.map +1 -0
  117. package/dist/store/index.js +2 -0
  118. package/dist/store/index.js.map +1 -0
  119. package/dist/surface-filter.d.ts +13 -0
  120. package/dist/surface-filter.d.ts.map +1 -0
  121. package/dist/surface-filter.js +76 -0
  122. package/dist/surface-filter.js.map +1 -0
  123. package/dist/topo-store.d.ts +62 -0
  124. package/dist/topo-store.d.ts.map +1 -0
  125. package/dist/topo-store.js +319 -0
  126. package/dist/topo-store.js.map +1 -0
  127. package/dist/topo.d.ts +23 -9
  128. package/dist/topo.d.ts.map +1 -1
  129. package/dist/topo.js +175 -46
  130. package/dist/topo.js.map +1 -1
  131. package/dist/trail.d.ts +86 -19
  132. package/dist/trail.d.ts.map +1 -1
  133. package/dist/trail.js +59 -4
  134. package/dist/trail.js.map +1 -1
  135. package/dist/trails/derive-trail.d.ts +66 -0
  136. package/dist/trails/derive-trail.d.ts.map +1 -0
  137. package/dist/trails/derive-trail.js +378 -0
  138. package/dist/trails/derive-trail.js.map +1 -0
  139. package/dist/trails/index.d.ts +5 -0
  140. package/dist/trails/index.d.ts.map +1 -0
  141. package/dist/trails/index.js +3 -0
  142. package/dist/trails/index.js.map +1 -0
  143. package/dist/trails/ingest.d.ts +24 -0
  144. package/dist/trails/ingest.d.ts.map +1 -0
  145. package/dist/trails/ingest.js +63 -0
  146. package/dist/trails/ingest.js.map +1 -0
  147. package/dist/transport-error-map.d.ts +106 -0
  148. package/dist/transport-error-map.d.ts.map +1 -0
  149. package/dist/transport-error-map.js +24 -0
  150. package/dist/transport-error-map.js.map +1 -0
  151. package/dist/type-checks.test-d.d.ts +71 -0
  152. package/dist/type-checks.test-d.d.ts.map +1 -0
  153. package/dist/type-checks.test-d.js +12 -0
  154. package/dist/type-checks.test-d.js.map +1 -0
  155. package/dist/type-utils.d.ts +11 -2
  156. package/dist/type-utils.d.ts.map +1 -1
  157. package/dist/type-utils.js.map +1 -1
  158. package/dist/types.d.ts +113 -7
  159. package/dist/types.d.ts.map +1 -1
  160. package/dist/types.js.map +1 -1
  161. package/dist/validate-established-topo.d.ts +76 -0
  162. package/dist/validate-established-topo.d.ts.map +1 -0
  163. package/dist/validate-established-topo.js +43 -0
  164. package/dist/validate-established-topo.js.map +1 -0
  165. package/dist/validate-topo.d.ts.map +1 -1
  166. package/dist/validate-topo.js +27 -8
  167. package/dist/validate-topo.js.map +1 -1
  168. package/dist/workspace.d.ts +1 -1
  169. package/dist/workspace.d.ts.map +1 -1
  170. package/dist/workspace.js +1 -1
  171. package/dist/workspace.js.map +1 -1
  172. package/package.json +10 -1
  173. package/src/__tests__/branded.test.ts +5 -5
  174. package/src/__tests__/context.test.ts +3 -3
  175. package/src/__tests__/contour.test.ts +263 -0
  176. package/src/__tests__/derive-trail.test.ts +668 -0
  177. package/src/__tests__/derive.test.ts +58 -1
  178. package/src/__tests__/errors.test.ts +118 -0
  179. package/src/__tests__/execute.test.ts +1179 -141
  180. package/src/__tests__/fire.test.ts +1056 -0
  181. package/src/__tests__/fork-ctx.test.ts +83 -0
  182. package/src/__tests__/ingest.test.ts +191 -0
  183. package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
  184. package/src/__tests__/path-security.test.ts +7 -7
  185. package/src/__tests__/resilience.test.ts +8 -8
  186. package/src/__tests__/resource.test.ts +204 -0
  187. package/src/__tests__/run.test.ts +13 -13
  188. package/src/__tests__/service-config.test.ts +28 -32
  189. package/src/__tests__/surface-filter.test.ts +196 -0
  190. package/src/__tests__/topo-store-read.test.ts +256 -0
  191. package/src/__tests__/topo-store.test.ts +869 -0
  192. package/src/__tests__/topo.test.ts +350 -54
  193. package/src/__tests__/trail.test.ts +189 -24
  194. package/src/__tests__/trails-db.test.ts +198 -0
  195. package/src/__tests__/transport-error-map.test.ts +82 -0
  196. package/src/__tests__/type-utils.test.ts +29 -1
  197. package/src/__tests__/validate-topo.test.ts +305 -16
  198. package/src/__tests__/workspace.test.ts +6 -6
  199. package/src/__tests__/zod-wrappers.test.ts +62 -0
  200. package/src/branded.ts +1 -1
  201. package/src/context.ts +24 -4
  202. package/src/contour.ts +344 -0
  203. package/src/cross-schema.ts +36 -0
  204. package/src/derive.ts +39 -8
  205. package/src/draft.ts +338 -0
  206. package/src/errors.ts +62 -18
  207. package/src/execute.ts +840 -29
  208. package/src/fire.ts +295 -0
  209. package/src/index.ts +142 -26
  210. package/src/internal/cross-batch.ts +77 -0
  211. package/src/internal/fork-ctx.ts +69 -0
  212. package/src/internal/signal-ref.ts +87 -0
  213. package/src/internal/topo-snapshots.ts +517 -0
  214. package/src/internal/topo-store-read.ts +481 -0
  215. package/src/internal/topo-store.ts +1192 -0
  216. package/src/internal/tracing.ts +187 -0
  217. package/src/internal/trails-db.ts +187 -0
  218. package/src/internal/zod-wrappers.ts +78 -0
  219. package/src/layer.ts +50 -0
  220. package/src/path-security.ts +1 -1
  221. package/src/resilience.ts +3 -3
  222. package/src/{provision-config.ts → resource-config.ts} +109 -123
  223. package/src/resource.ts +155 -0
  224. package/src/run.ts +5 -4
  225. package/src/serialization.ts +2 -0
  226. package/src/store/accessor-protocol.ts +56 -0
  227. package/src/store/index.ts +4 -0
  228. package/src/surface-filter.ts +182 -0
  229. package/src/topo-store.ts +510 -0
  230. package/src/topo.ts +328 -57
  231. package/src/trail.ts +199 -31
  232. package/src/trails/derive-trail.ts +842 -0
  233. package/src/trails/index.ts +9 -0
  234. package/src/trails/ingest.ts +146 -0
  235. package/src/transport-error-map.ts +56 -0
  236. package/src/type-checks.test-d.ts +106 -0
  237. package/src/type-utils.ts +17 -2
  238. package/src/types.ts +148 -11
  239. package/src/validate-established-topo.ts +63 -0
  240. package/src/validate-topo.ts +37 -9
  241. package/src/workspace.ts +1 -1
  242. package/tsconfig.tests.json +10 -0
  243. package/tsconfig.tsbuildinfo +1 -1
  244. package/dist/adapters.d.ts +0 -39
  245. package/dist/adapters.d.ts.map +0 -1
  246. package/dist/adapters.js +0 -2
  247. package/dist/adapters.js.map +0 -1
  248. package/dist/dispatch.d.ts +0 -27
  249. package/dist/dispatch.d.ts.map +0 -1
  250. package/dist/dispatch.js +0 -34
  251. package/dist/dispatch.js.map +0 -1
  252. package/dist/event.d.ts +0 -8
  253. package/dist/event.d.ts.map +0 -1
  254. package/dist/event.js +0 -7
  255. package/dist/event.js.map +0 -1
  256. package/dist/gate.d.ts +0 -17
  257. package/dist/gate.d.ts.map +0 -1
  258. package/dist/gate.js +0 -21
  259. package/dist/gate.js.map +0 -1
  260. package/dist/health.d.ts +0 -18
  261. package/dist/health.d.ts.map +0 -1
  262. package/dist/health.js +0 -5
  263. package/dist/health.js.map +0 -1
  264. package/dist/job.d.ts +0 -24
  265. package/dist/job.d.ts.map +0 -1
  266. package/dist/job.js +0 -17
  267. package/dist/job.js.map +0 -1
  268. package/dist/provision-config.d.ts +0 -22
  269. package/dist/provision-config.d.ts.map +0 -1
  270. package/dist/provision-config.js +0 -210
  271. package/dist/provision-config.js.map +0 -1
  272. package/dist/provision.d.ts +0 -71
  273. package/dist/provision.d.ts.map +0 -1
  274. package/dist/provision.js +0 -56
  275. package/dist/provision.js.map +0 -1
  276. package/dist/service-config.d.ts +0 -22
  277. package/dist/service-config.d.ts.map +0 -1
  278. package/dist/service-config.js +0 -210
  279. package/dist/service-config.js.map +0 -1
  280. package/dist/service.d.ts +0 -71
  281. package/dist/service.d.ts.map +0 -1
  282. package/dist/service.js +0 -56
  283. package/dist/service.js.map +0 -1
  284. package/src/__tests__/service.test.ts +0 -197
  285. package/src/event.ts +0 -15
  286. package/src/gate.ts +0 -44
  287. package/src/provision.ts +0 -148
package/src/execute.ts CHANGED
@@ -1,22 +1,58 @@
1
1
  /**
2
2
  * Centralized trail execution pipeline.
3
3
  *
4
- * Validates input, builds context, composes gates, and runs the
4
+ * Validates input, builds context, composes layers, and runs the
5
5
  * implementation. Surfaces (CLI, MCP, HTTP) delegate here instead
6
6
  * of reimplementing the pipeline.
7
7
  */
8
8
 
9
+ import type { z } from 'zod';
10
+
9
11
  import type { AnyTrail } from './trail.js';
10
- import type { Gate } from './gate.js';
11
- import type { ProvisionOverrideMap } from './provision.js';
12
- import type { TrailContext, TrailContextInit } from './types.js';
12
+ import type { Layer } from './layer.js';
13
+ import type { ResourceOverrideMap } from './resource.js';
14
+ import type { TraceContext, TraceRecord } from './internal/tracing.js';
15
+ import type { Topo } from './topo.js';
16
+
17
+ import { createFireFn } from './fire.js';
18
+ import type {
19
+ CrossBatchOptions,
20
+ CrossFn,
21
+ Detour,
22
+ Implementation,
23
+ TraceFn,
24
+ TrailContext,
25
+ TrailContextInit,
26
+ } from './types.js';
13
27
 
14
- import { composeGates } from './gate.js';
15
- import { createTrailContext } from './context.js';
16
- import { InternalError } from './errors.js';
28
+ import { createTrailContext, passthroughTrace } from './context.js';
29
+ import { buildCrossValidationSchema } from './cross-schema.js';
30
+ import {
31
+ CancelledError,
32
+ InternalError,
33
+ NotFoundError,
34
+ RetryExhaustedError,
35
+ TrailsError,
36
+ } from './errors.js';
37
+ import {
38
+ claimNextCrossBatchIndex,
39
+ createCrossBatchValidationResults,
40
+ normalizeCrossBatchConcurrency,
41
+ } from './internal/cross-batch.js';
42
+ import { forkCtx } from './internal/fork-ctx.js';
43
+ import {
44
+ TRACE_CONTEXT_KEY,
45
+ completeRecord,
46
+ createSpanRecord,
47
+ createTraceRecord,
48
+ getTraceSink,
49
+ isTracingDisabled,
50
+ writeToSink,
51
+ } from './internal/tracing.js';
17
52
  import { Result } from './result.js';
18
- import { createProvisionLookup } from './provision.js';
19
- import { resolveProvisions } from './provision-config.js';
53
+ import { createResourceLookup } from './resource.js';
54
+ import { createResources } from './resource-config.js';
55
+ import { TRAILHEAD_KEY } from './types.js';
20
56
  import { validateInput } from './validation.js';
21
57
 
22
58
  type MutableTrailContext = {
@@ -33,18 +69,33 @@ export interface ExecuteTrailOptions {
33
69
  readonly ctx?: Partial<TrailContextInit> | undefined;
34
70
  /** AbortSignal override (takes final precedence over ctx and factory). */
35
71
  readonly abortSignal?: AbortSignal | undefined;
36
- /** Gates to compose around the implementation. */
37
- readonly gates?: readonly Gate[] | undefined;
72
+ /** Layers to compose around the implementation. */
73
+ readonly layers?: readonly Layer[] | undefined;
38
74
  /** Factory that produces a base TrailContext (takes precedence over defaults). */
39
75
  readonly createContext?:
40
76
  | (() => TrailContextInit | Promise<TrailContextInit>)
41
77
  | undefined;
42
- /** Explicit provision instance overrides keyed by provision ID. */
43
- readonly provisions?: ProvisionOverrideMap | undefined;
44
- /** Config values for provisions that declare a `config` schema, keyed by provision ID. */
78
+ /** Explicit resource instance overrides keyed by resource ID. */
79
+ readonly resources?: ResourceOverrideMap | undefined;
80
+ /** Config values for resources that declare a `config` schema, keyed by resource ID. */
45
81
  readonly configValues?:
46
82
  | Readonly<Record<string, Record<string, unknown>>>
47
83
  | undefined;
84
+ /** Topo used for signal-driven activation; required for `ctx.fire()` to work. */
85
+ readonly topo?: Topo | undefined;
86
+ /**
87
+ * Override the validation schema used for input validation.
88
+ *
89
+ * When a trail is invoked via `ctx.cross()` and the target declares
90
+ * `crossInput`, the cross function merges `trail.input` with
91
+ * `trail.crossInput` and passes the merged schema here so validation
92
+ * accepts both public and composition-only fields.
93
+ *
94
+ * Used by the cross execution path; not part of the public API.
95
+ *
96
+ * @internal
97
+ */
98
+ readonly validationSchema?: z.ZodType | undefined;
48
99
  }
49
100
 
50
101
  // ---------------------------------------------------------------------------
@@ -68,20 +119,20 @@ const applyContextOverrides = (
68
119
  : withOverrides;
69
120
  };
70
121
 
71
- const bindProvisionLookup = (
122
+ const bindResourceLookup = (
72
123
  resolved: TrailContextInit,
73
124
  options?: ExecuteTrailOptions
74
125
  ): TrailContext => {
75
126
  if (
76
127
  options?.ctx?.extensions === undefined &&
77
- resolved.provision !== undefined
128
+ resolved.resource !== undefined
78
129
  ) {
79
130
  return resolved as TrailContext;
80
131
  }
81
132
 
82
133
  const bound = { ...resolved } as MutableTrailContext;
83
- const lookup = createProvisionLookup(() => bound);
84
- bound.provision = lookup;
134
+ const lookup = createResourceLookup(() => bound);
135
+ bound.resource = lookup;
85
136
  return bound;
86
137
  };
87
138
 
@@ -98,10 +149,10 @@ const resolveContext = async (
98
149
  ): Promise<TrailContext> => {
99
150
  const seed = options?.createContext
100
151
  ? await options.createContext()
101
- : createTrailContext();
102
- const base = seed.provision ? seed : createTrailContext(seed);
152
+ : undefined;
153
+ const base = createTrailContext(seed);
103
154
  const resolved = applyContextOverrides(base, options);
104
- return bindProvisionLookup(resolved, options);
155
+ return bindResourceLookup(resolved, options);
105
156
  };
106
157
 
107
158
  const prepareContext = async (
@@ -109,22 +160,777 @@ const prepareContext = async (
109
160
  options?: ExecuteTrailOptions
110
161
  ): Promise<Result<TrailContext, Error>> => {
111
162
  const baseCtx = await resolveContext(options);
112
- return await resolveProvisions(
163
+ return await createResources(
113
164
  trail,
114
165
  baseCtx,
115
- options?.provisions,
166
+ options?.resources,
116
167
  options?.configValues
117
168
  );
118
169
  };
119
170
 
171
+ // ---------------------------------------------------------------------------
172
+ // Intrinsic tracing
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** Derive the status + error category fields from a trail result. */
176
+ const deriveResultErrorCategory = (error: Error): string => {
177
+ if (error instanceof TrailsError) {
178
+ return error.category;
179
+ }
180
+ return 'internal';
181
+ };
182
+
183
+ const deriveOutcome = (
184
+ result: Result<unknown, Error>
185
+ ): {
186
+ readonly status: TraceRecord['status'];
187
+ readonly errorCategory: string | undefined;
188
+ } =>
189
+ result.match<{
190
+ readonly status: TraceRecord['status'];
191
+ readonly errorCategory: string | undefined;
192
+ }>({
193
+ err: (error) => ({
194
+ errorCategory: deriveResultErrorCategory(error),
195
+ status: error instanceof CancelledError ? 'cancelled' : 'err',
196
+ }),
197
+ ok: () => ({ errorCategory: undefined, status: 'ok' }),
198
+ });
199
+
200
+ /**
201
+ * Best-effort error category for a thrown (not Result.err) value.
202
+ *
203
+ * Unknown/non-Error throws normalize to `'internal'` so the trace record
204
+ * always carries a category when the trail unexpectedly throws.
205
+ */
206
+ const categorizeSpanError = (error: unknown): string => {
207
+ if (error instanceof TrailsError) {
208
+ return error.category;
209
+ }
210
+ return 'internal';
211
+ };
212
+
213
+ /** Extract the permit identity fields for the trace record. */
214
+ const extractPermit = (
215
+ ctx: TrailContext
216
+ ): { readonly id: string; readonly tenantId?: string } | undefined => {
217
+ if (ctx.permit === undefined) {
218
+ return undefined;
219
+ }
220
+ const tenantId =
221
+ 'tenantId' in ctx.permit
222
+ ? (ctx.permit as { tenantId?: string }).tenantId
223
+ : undefined;
224
+ return tenantId === undefined
225
+ ? { id: ctx.permit.id }
226
+ : { id: ctx.permit.id, tenantId };
227
+ };
228
+
229
+ /**
230
+ * Build a `ctx.trace` function bound to a parent trace context.
231
+ *
232
+ * Each call creates a child span under the parent, times the callback,
233
+ * records success/failure with the appropriate error category, writes the
234
+ * completed span to the sink, and returns the callback result. Errors
235
+ * thrown by the callback are recorded and then rethrown.
236
+ *
237
+ * The returned function reads the *current* trace context from its captured
238
+ * parent. That means direct nesting (`ctx.trace('a', () => ctx.trace('b',
239
+ * ...))`) produces siblings under `a`'s parent, not children of `a`. For
240
+ * true child nesting, callers should cross into another trail (which gets
241
+ * its own root record parented by this one) — full cross-trail parenting
242
+ * is implemented in a later phase. For Phase 1, sibling spans under the
243
+ * trail's root are the supported shape.
244
+ */
245
+ const buildTraceFn =
246
+ (parent: TraceContext, sink: ReturnType<typeof getTraceSink>): TraceFn =>
247
+ async <T>(label: string, fn: () => T | Promise<T>): Promise<T> => {
248
+ const record = createSpanRecord(parent, label);
249
+ try {
250
+ const value = await fn();
251
+ await writeToSink(sink, completeRecord(record, 'ok'));
252
+ return value;
253
+ } catch (error: unknown) {
254
+ const errorCategory = categorizeSpanError(error);
255
+ const status: TraceRecord['status'] =
256
+ error instanceof CancelledError ? 'cancelled' : 'err';
257
+ await writeToSink(sink, completeRecord(record, status, errorCategory));
258
+ throw error;
259
+ }
260
+ };
261
+
262
+ /** Build the root trace record + trace-enriched context for a trail run. */
263
+ const buildTracedContext = (
264
+ trail: AnyTrail,
265
+ ctx: TrailContext,
266
+ sink: ReturnType<typeof getTraceSink>
267
+ ): { readonly record: TraceRecord; readonly tracedCtx: TrailContext } => {
268
+ // If a parent trace context is present (set by an outer executeTrail when
269
+ // the current trail was invoked via ctx.cross or ctx.fire), inherit its
270
+ // traceId/rootId so the trace tree spans trail boundaries. Otherwise this
271
+ // execution becomes a fresh root.
272
+ const parent = ctx.extensions?.[TRACE_CONTEXT_KEY] as
273
+ | TraceContext
274
+ | undefined;
275
+
276
+ const record = createTraceRecord({
277
+ intent: trail.intent,
278
+ parentId: parent?.spanId,
279
+ permit: extractPermit(ctx),
280
+ rootId: parent?.rootId,
281
+ traceId: parent?.traceId,
282
+ trailId: trail.id,
283
+ trailhead: ctx.extensions?.[TRAILHEAD_KEY] as TraceRecord['trailhead'],
284
+ });
285
+
286
+ // Root trace context for this trail's span. When inheriting a parent, the
287
+ // traceId/rootId carry forward and only spanId advances to the new record.
288
+ const rootTrace: TraceContext = {
289
+ rootId: parent?.rootId ?? record.id,
290
+ sampled: true,
291
+ spanId: record.id,
292
+ traceId: record.traceId,
293
+ };
294
+
295
+ const tracedCtx: TrailContext = {
296
+ ...ctx,
297
+ extensions: {
298
+ ...ctx.extensions,
299
+ [TRACE_CONTEXT_KEY]: rootTrace,
300
+ },
301
+ trace: buildTraceFn(rootTrace, sink),
302
+ };
303
+
304
+ return { record, tracedCtx };
305
+ };
306
+
307
+ const buildUntracedContext = (ctx: TrailContext): TrailContext => {
308
+ const { [TRACE_CONTEXT_KEY]: _traceContext, ...extensions } =
309
+ ctx.extensions ?? {};
310
+ const hasExtensions = Object.keys(extensions).length > 0;
311
+
312
+ return {
313
+ ...ctx,
314
+ extensions: hasExtensions ? extensions : undefined,
315
+ trace: passthroughTrace,
316
+ };
317
+ };
318
+
319
+ /** Run the composed implementation and write the root record on any outcome. */
320
+ const runImplWithRootRecord = async (
321
+ impl: Implementation<unknown, unknown>,
322
+ input: unknown,
323
+ tracedCtx: TrailContext,
324
+ record: TraceRecord,
325
+ sink: ReturnType<typeof getTraceSink>
326
+ ): Promise<Result<unknown, Error>> => {
327
+ try {
328
+ const result = await impl(input, tracedCtx);
329
+ const outcome = deriveOutcome(result);
330
+ await writeToSink(
331
+ sink,
332
+ completeRecord(record, outcome.status, outcome.errorCategory)
333
+ );
334
+ return result;
335
+ } catch (error: unknown) {
336
+ // Normalize unexpected throws so the root record still reflects the error
337
+ // outcome. The outer executeTrail try/catch converts the thrown value into
338
+ // a Result.err(InternalError) for the caller.
339
+ const status: TraceRecord['status'] =
340
+ error instanceof CancelledError ? 'cancelled' : 'err';
341
+ const errorCategory = categorizeSpanError(error);
342
+ await writeToSink(sink, completeRecord(record, status, errorCategory));
343
+ throw error;
344
+ }
345
+ };
346
+
347
+ const resolveCrossTarget = (
348
+ trailOrId: AnyTrail | string,
349
+ topo: Topo | undefined
350
+ ): Result<AnyTrail, Error> => {
351
+ if (typeof trailOrId !== 'string') {
352
+ return Result.ok(trailOrId);
353
+ }
354
+
355
+ if (topo === undefined) {
356
+ return Result.err(
357
+ new NotFoundError(
358
+ `Trail "${trailOrId}" cannot be crossed without topo access`
359
+ )
360
+ );
361
+ }
362
+
363
+ const target = topo.get(trailOrId);
364
+ return target
365
+ ? Result.ok(target)
366
+ : Result.err(
367
+ new NotFoundError(
368
+ `Trail "${trailOrId}" not found in topo "${topo.name}"`
369
+ )
370
+ );
371
+ };
372
+
373
+ const collectConcurrentBranchResourceIds = (
374
+ target: AnyTrail,
375
+ topo: Topo | undefined
376
+ ): Set<string> =>
377
+ new Set(
378
+ topo?.resourceIds() ?? target.resources.map((resource) => resource.id)
379
+ );
380
+
381
+ const stripInheritedResourceExtensions = (
382
+ ctx: TrailContext,
383
+ target: AnyTrail,
384
+ topo: Topo | undefined
385
+ ): Record<string, unknown> => {
386
+ const resourceIds = collectConcurrentBranchResourceIds(target, topo);
387
+ const entries = Object.entries(ctx.extensions ?? {}).filter(
388
+ ([key]) => !resourceIds.has(key)
389
+ );
390
+ return Object.fromEntries(entries);
391
+ };
392
+
393
+ const deriveConcurrentBranchLogger = (
394
+ ctx: TrailContext,
395
+ target: AnyTrail,
396
+ branchIndex: number
397
+ ) =>
398
+ ctx.logger?.child?.({
399
+ branchIndex,
400
+ crossedTrailId: target.id,
401
+ }) ?? ctx.logger;
402
+
403
+ /**
404
+ * Build a child context for one concurrent crossing branch.
405
+ *
406
+ * Concurrent crossings should not inherit already-resolved resource instances
407
+ * from the parent execution scope. Stripping resource IDs from extensions
408
+ * forces each branch to resolve its own scope while still carrying forward
409
+ * request-scoped values like tracing, trailhead identity, permits, and the
410
+ * shared AbortSignal.
411
+ *
412
+ * `cross`, `fire`, and `resource` are cleared so the child execution can
413
+ * rebind them to the branch-local context instead of reusing closures that
414
+ * capture the parent scope.
415
+ */
416
+ const buildConcurrentBranchContext = (
417
+ ctx: TrailContext,
418
+ target: AnyTrail,
419
+ topo: Topo | undefined,
420
+ branchIndex: number
421
+ ): TrailContext =>
422
+ forkCtx(ctx, {
423
+ extensions: stripInheritedResourceExtensions(ctx, target, topo),
424
+ logger: deriveConcurrentBranchLogger(ctx, target, branchIndex),
425
+ });
426
+
427
+ const executeResolvedCrossTarget = async (
428
+ target: AnyTrail,
429
+ input: unknown,
430
+ ctx: TrailContext,
431
+ topo: Topo | undefined,
432
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
433
+ ): Promise<Result<unknown, Error>> =>
434
+ await // eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
435
+ executeTrail(target, input, {
436
+ ...forwarded,
437
+ ctx,
438
+ topo,
439
+ validationSchema: buildCrossValidationSchema(target),
440
+ });
441
+
442
+ const executeCrossTarget = async (
443
+ trailOrId: AnyTrail | string,
444
+ input: unknown,
445
+ ctx: TrailContext,
446
+ topo: Topo | undefined,
447
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
448
+ ): Promise<Result<unknown, Error>> => {
449
+ const target = resolveCrossTarget(trailOrId, topo);
450
+ if (target.isErr()) {
451
+ return target;
452
+ }
453
+
454
+ return await executeResolvedCrossTarget(
455
+ target.value,
456
+ input,
457
+ ctx,
458
+ topo,
459
+ forwarded
460
+ );
461
+ };
462
+
463
+ type CrossBatchCall = readonly [AnyTrail | string, unknown];
464
+
465
+ const executeConcurrentCrossBatchCall = async (
466
+ call: CrossBatchCall,
467
+ branchIndex: number,
468
+ ctx: TrailContext,
469
+ topo: Topo | undefined,
470
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
471
+ ): Promise<Result<unknown, Error>> => {
472
+ const [trailOrId, batchInput] = call;
473
+ const target = resolveCrossTarget(trailOrId, topo);
474
+ if (target.isErr()) {
475
+ return target;
476
+ }
477
+
478
+ return await executeResolvedCrossTarget(
479
+ target.value,
480
+ batchInput,
481
+ buildConcurrentBranchContext(ctx, target.value, topo, branchIndex),
482
+ topo,
483
+ forwarded
484
+ );
485
+ };
486
+
487
+ const executeUnlimitedCrossBatch = async (
488
+ calls: readonly CrossBatchCall[],
489
+ ctx: TrailContext,
490
+ topo: Topo | undefined,
491
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
492
+ ): Promise<Result<unknown, Error>[]> =>
493
+ await Promise.all(
494
+ calls.map((call, branchIndex) =>
495
+ executeConcurrentCrossBatchCall(call, branchIndex, ctx, topo, forwarded)
496
+ )
497
+ );
498
+
499
+ const createCrossBatchResults = (
500
+ calls: readonly CrossBatchCall[]
501
+ ): Result<unknown, Error>[] =>
502
+ Array.from<Result<unknown, Error>>({ length: calls.length });
503
+
504
+ const executeLimitedCrossBatch = async (
505
+ calls: readonly CrossBatchCall[],
506
+ ctx: TrailContext,
507
+ topo: Topo | undefined,
508
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
509
+ limit: number
510
+ ): Promise<Result<unknown, Error>[]> => {
511
+ const results = createCrossBatchResults(calls);
512
+ const nextIndex = { value: 0 };
513
+
514
+ const runWorker = async () => {
515
+ while (true) {
516
+ const branchIndex = claimNextCrossBatchIndex(nextIndex, calls);
517
+ if (branchIndex === undefined) {
518
+ return;
519
+ }
520
+
521
+ const call = calls[branchIndex];
522
+ if (call === undefined) {
523
+ // Defensive: `claimNextCrossBatchIndex` only returns indices within
524
+ // bounds, so this slot should always be populated. If it ever isn't,
525
+ // surface a clear InternalError in place of the missing slot and keep
526
+ // the worker loop running so sibling branches still get processed.
527
+ results[branchIndex] = Result.err(
528
+ new InternalError(
529
+ `unreachable: concurrent cross batch call missing at index ${branchIndex}`
530
+ )
531
+ );
532
+ continue;
533
+ }
534
+
535
+ results[branchIndex] = await executeConcurrentCrossBatchCall(
536
+ call,
537
+ branchIndex,
538
+ ctx,
539
+ topo,
540
+ forwarded
541
+ );
542
+ }
543
+ };
544
+
545
+ await Promise.all(Array.from({ length: limit }, runWorker));
546
+ return results;
547
+ };
548
+
549
+ const executeCrossBatch = async (
550
+ calls: readonly CrossBatchCall[],
551
+ ctx: TrailContext,
552
+ topo: Topo | undefined,
553
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
554
+ batchOptions?: CrossBatchOptions
555
+ ): Promise<Result<unknown, Error>[]> => {
556
+ if (calls.length === 0) {
557
+ return [];
558
+ }
559
+
560
+ const concurrency = normalizeCrossBatchConcurrency(batchOptions);
561
+ if (concurrency.isErr()) {
562
+ return createCrossBatchValidationResults(calls, concurrency.error);
563
+ }
564
+
565
+ const limit = concurrency.value ?? calls.length;
566
+ return limit >= calls.length
567
+ ? await executeUnlimitedCrossBatch(calls, ctx, topo, forwarded)
568
+ : await executeLimitedCrossBatch(calls, ctx, topo, forwarded, limit);
569
+ };
570
+
571
+ const bindCrossToCtx = (
572
+ ctx: TrailContext,
573
+ topo: Topo | undefined,
574
+ options: ExecuteTrailOptions | undefined
575
+ ): TrailContext => {
576
+ if (ctx.cross !== undefined) {
577
+ return ctx;
578
+ }
579
+
580
+ const {
581
+ createContext: _omit,
582
+ validationSchema: _omitSchema,
583
+ ...forwarded
584
+ } = options ?? {};
585
+ const cross = (async (
586
+ trailOrCalls:
587
+ | AnyTrail
588
+ | string
589
+ | readonly (readonly [AnyTrail | string, unknown])[],
590
+ inputOrOptions?: CrossBatchOptions | unknown
591
+ ) => {
592
+ if (Array.isArray(trailOrCalls)) {
593
+ return await executeCrossBatch(
594
+ trailOrCalls,
595
+ ctx,
596
+ topo,
597
+ forwarded,
598
+ inputOrOptions as CrossBatchOptions | undefined
599
+ );
600
+ }
601
+
602
+ return await executeCrossTarget(
603
+ trailOrCalls as AnyTrail | string,
604
+ inputOrOptions,
605
+ ctx,
606
+ topo,
607
+ forwarded
608
+ );
609
+ }) as CrossFn;
610
+
611
+ return {
612
+ ...ctx,
613
+ cross,
614
+ };
615
+ };
616
+
617
+ const bindFireToCtx = (
618
+ ctx: TrailContext,
619
+ topo: Topo | undefined,
620
+ options: ExecuteTrailOptions | undefined
621
+ ): TrailContext => {
622
+ if (topo === undefined) {
623
+ return ctx;
624
+ }
625
+ // Forward the producer's execution options to consumers so resources,
626
+ // layers, configValues, and abortSignal propagate through signal fan-out.
627
+ // `createContext` is intentionally stripped — consumers inherit the
628
+ // already-resolved ctx via `consumerCtx`, and re-running the factory would
629
+ // clobber that.
630
+ // Strip createContext (consumers inherit resolved ctx) and validationSchema
631
+ // (consumers validate against their own schema, not the producer's cross schema).
632
+ const {
633
+ createContext: _omit,
634
+ validationSchema: _omitSchema,
635
+ ...forwarded
636
+ } = options ?? {};
637
+ const fire = createFireFn(topo, ctx, (consumer, input, consumerCtx) =>
638
+ // eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
639
+ executeTrail(consumer, input, {
640
+ ...forwarded,
641
+ ctx: consumerCtx,
642
+ topo,
643
+ })
644
+ );
645
+ return { ...ctx, fire };
646
+ };
647
+
648
+ const bindCrossAtLayerBoundary =
649
+ <I, O>(
650
+ implementation: Implementation<I, O>,
651
+ topo: Topo | undefined,
652
+ options: ExecuteTrailOptions | undefined
653
+ ): Implementation<I, O> =>
654
+ (input, ctx) =>
655
+ implementation(input, bindCrossToCtx(ctx, topo, options));
656
+
657
+ const bindFireAtLayerBoundary = <I, O>(
658
+ implementation: Implementation<I, O>,
659
+ topo: Topo | undefined,
660
+ options: ExecuteTrailOptions | undefined
661
+ ): Implementation<I, O> => {
662
+ if (topo === undefined) {
663
+ return implementation;
664
+ }
665
+
666
+ return (input, ctx) =>
667
+ implementation(input, bindFireToCtx(ctx, topo, options));
668
+ };
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // Detour loop
672
+ // ---------------------------------------------------------------------------
673
+
674
+ const DETOUR_MAX_ATTEMPTS_CAP = 5;
675
+
676
+ /**
677
+ * Find the first detour whose `on` class matches the error via `instanceof`.
678
+ *
679
+ * Declaration order wins — no most-specific-first hierarchy walking.
680
+ */
681
+ const findMatchingDetour = (
682
+ /* oxlint-disable-next-line no-explicit-any -- existential detour array from AnyTrail */
683
+ detours: readonly Detour<any, any, TrailsError>[],
684
+ error: TrailsError
685
+ /* oxlint-disable-next-line no-explicit-any -- matched detour carries runtime generics */
686
+ ): Detour<any, any, TrailsError> | undefined =>
687
+ detours.find((d) => error instanceof d.on);
688
+
689
+ /** Execute a single detour recovery attempt, routing through ctx.trace when available. */
690
+ const executeDetourAttempt = async (
691
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
692
+ detour: Detour<any, any, TrailsError>,
693
+ attempt: number,
694
+ lastError: TrailsError,
695
+ input: unknown,
696
+ ctx: TrailContext
697
+ ): Promise<Result<unknown, Error>> => {
698
+ const run = async () =>
699
+ await detour.recover({ attempt, error: lastError, input }, ctx);
700
+
701
+ return ctx.trace
702
+ ? await ctx.trace(`detour:${detour.on.name}:${attempt}`, run)
703
+ : await run();
704
+ };
705
+
706
+ /** Classify a detour attempt result: continue the loop, or return early. */
707
+ const classifyDetourResult = (
708
+ result: Result<unknown, Error>,
709
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
710
+ detour: Detour<any, any, TrailsError>
711
+ ):
712
+ | { readonly done: true; readonly result: Result<unknown, Error> }
713
+ | { readonly done: false; readonly nextError: TrailsError } => {
714
+ if (result.isOk()) {
715
+ return { done: true, result };
716
+ }
717
+ const recoverError = result.error;
718
+ if (
719
+ !(recoverError instanceof TrailsError) ||
720
+ !(recoverError instanceof detour.on)
721
+ ) {
722
+ return { done: true, result };
723
+ }
724
+ return { done: false, nextError: recoverError };
725
+ };
726
+
727
+ /** Resolve effective maxAttempts, warning if the declared value exceeds the hard cap. */
728
+ const resolveMaxAttempts = (
729
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
730
+ detour: Detour<any, any, TrailsError>,
731
+ ctx: TrailContext
732
+ ): number => {
733
+ const declared = detour.maxAttempts ?? 1;
734
+ const clamped = Math.max(1, Math.min(declared, DETOUR_MAX_ATTEMPTS_CAP));
735
+ if (clamped === declared) {
736
+ return clamped;
737
+ }
738
+ ctx.logger?.warn('detour maxAttempts clamped', {
739
+ declared,
740
+ detour: detour.on.name,
741
+ effective: clamped,
742
+ });
743
+ return clamped;
744
+ };
745
+
746
+ /** Run the detour recovery loop for a single matched detour. */
747
+ const runDetourRecovery = async (
748
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
749
+ detour: Detour<any, any, TrailsError>,
750
+ error: TrailsError,
751
+ input: unknown,
752
+ ctx: TrailContext
753
+ ): Promise<Result<unknown, Error>> => {
754
+ const maxAttempts = resolveMaxAttempts(detour, ctx);
755
+ let lastError: TrailsError = error;
756
+
757
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
758
+ ctx.logger?.debug('detour recovery attempt', {
759
+ attempt,
760
+ errorClass: lastError.name,
761
+ matchedDetour: detour.on.name,
762
+ maxAttempts,
763
+ });
764
+ const result = await executeDetourAttempt(
765
+ detour,
766
+ attempt,
767
+ lastError,
768
+ input,
769
+ ctx
770
+ );
771
+ const classification = classifyDetourResult(result, detour);
772
+ if (classification.done) {
773
+ return classification.result;
774
+ }
775
+ lastError = classification.nextError;
776
+ }
777
+
778
+ return Result.err(
779
+ new RetryExhaustedError(lastError, {
780
+ attempts: maxAttempts,
781
+ detour: detour.on.name,
782
+ })
783
+ );
784
+ };
785
+
786
+ /**
787
+ * Wrap a blaze with the detour recovery loop.
788
+ *
789
+ * If the trail has no detours, returns the blaze unchanged (no wrapper overhead).
790
+ * The detour loop runs inside the layer stack, closest to the blaze.
791
+ */
792
+ const wrapWithDetours = (
793
+ blaze: Implementation<unknown, unknown>,
794
+ /* oxlint-disable-next-line no-explicit-any -- existential detour array from AnyTrail */
795
+ detours: readonly Detour<any, any, TrailsError>[]
796
+ ): Implementation<unknown, unknown> => {
797
+ if (detours.length === 0) {
798
+ return blaze;
799
+ }
800
+
801
+ return async (input, ctx) => {
802
+ const result = await blaze(input, ctx);
803
+ if (result.isOk()) {
804
+ return result;
805
+ }
806
+
807
+ const { error } = result;
808
+ if (!(error instanceof TrailsError)) {
809
+ return result;
810
+ }
811
+
812
+ const matched = findMatchingDetour(detours, error);
813
+ if (matched === undefined) {
814
+ return result;
815
+ }
816
+
817
+ return await runDetourRecovery(matched, error, input, ctx);
818
+ };
819
+ };
820
+
821
+ const prepareRunImpl = (
822
+ trail: AnyTrail,
823
+ ctx: TrailContext,
824
+ layers: readonly Layer[],
825
+ topo: Topo | undefined,
826
+ options: ExecuteTrailOptions | undefined
827
+ ): {
828
+ readonly ctxWithIntrinsics: TrailContext;
829
+ readonly impl: Implementation<unknown, unknown>;
830
+ } => {
831
+ const ctxWithIntrinsics = bindFireToCtx(
832
+ bindCrossToCtx(ctx, topo, options),
833
+ topo,
834
+ options
835
+ );
836
+ // Detour loop wraps the blaze (inside layer stack, closest to blaze)
837
+ let impl = wrapWithDetours(
838
+ bindFireAtLayerBoundary(
839
+ bindCrossAtLayerBoundary(
840
+ trail.blaze as Implementation<unknown, unknown>,
841
+ topo,
842
+ options
843
+ ),
844
+ topo,
845
+ options
846
+ ),
847
+ trail.detours
848
+ );
849
+
850
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
851
+ const layer = layers[i];
852
+ if (layer) {
853
+ impl = bindFireAtLayerBoundary(
854
+ bindCrossAtLayerBoundary(
855
+ layer.wrap(trail, impl as never) as Implementation<unknown, unknown>,
856
+ topo,
857
+ options
858
+ ),
859
+ topo,
860
+ options
861
+ );
862
+ }
863
+ }
864
+
865
+ return {
866
+ ctxWithIntrinsics,
867
+ impl,
868
+ };
869
+ };
870
+
871
+ const runImplWithoutTracing = async (
872
+ trail: AnyTrail,
873
+ input: unknown,
874
+ ctx: TrailContext,
875
+ layers: readonly Layer[],
876
+ topo: Topo | undefined,
877
+ options: ExecuteTrailOptions | undefined
878
+ ): Promise<Result<unknown, Error>> => {
879
+ const prepared = prepareRunImpl(
880
+ trail,
881
+ buildUntracedContext(ctx),
882
+ layers,
883
+ topo,
884
+ options
885
+ );
886
+ return await prepared.impl(input, prepared.ctxWithIntrinsics);
887
+ };
888
+
889
+ const runTrailWithTracing = async (
890
+ trail: AnyTrail,
891
+ input: unknown,
892
+ ctx: TrailContext,
893
+ layers: readonly Layer[],
894
+ topo: Topo | undefined,
895
+ options: ExecuteTrailOptions | undefined,
896
+ sink: ReturnType<typeof getTraceSink>
897
+ ): Promise<Result<unknown, Error>> => {
898
+ const { record, tracedCtx } = buildTracedContext(trail, ctx, sink);
899
+ let prepared: ReturnType<typeof prepareRunImpl>;
900
+
901
+ try {
902
+ prepared = prepareRunImpl(trail, tracedCtx, layers, topo, options);
903
+ } catch (error: unknown) {
904
+ const status: TraceRecord['status'] =
905
+ error instanceof CancelledError ? 'cancelled' : 'err';
906
+ await writeToSink(
907
+ sink,
908
+ completeRecord(record, status, categorizeSpanError(error))
909
+ );
910
+ throw error;
911
+ }
912
+
913
+ return await runImplWithRootRecord(
914
+ prepared.impl,
915
+ input,
916
+ prepared.ctxWithIntrinsics,
917
+ record,
918
+ sink
919
+ );
920
+ };
921
+
120
922
  const runTrail = async (
121
923
  trail: AnyTrail,
122
924
  input: unknown,
123
925
  ctx: TrailContext,
124
- gates: readonly Gate[]
926
+ layers: readonly Layer[],
927
+ topo: Topo | undefined,
928
+ options: ExecuteTrailOptions | undefined
125
929
  ): Promise<Result<unknown, Error>> => {
126
- const impl = composeGates([...gates], trail, trail.blaze);
127
- return await impl(input, ctx);
930
+ const sink = getTraceSink();
931
+ return isTracingDisabled(sink)
932
+ ? await runImplWithoutTracing(trail, input, ctx, layers, topo, options)
933
+ : await runTrailWithTracing(trail, input, ctx, layers, topo, options, sink);
128
934
  };
129
935
 
130
936
  // ---------------------------------------------------------------------------
@@ -132,7 +938,7 @@ const runTrail = async (
132
938
  // ---------------------------------------------------------------------------
133
939
 
134
940
  /**
135
- * Execute a trail through the standard validate-context-gates-run pipeline.
941
+ * Execute a trail through the standard validate-context-layers-run pipeline.
136
942
  *
137
943
  * The function never throws -- unexpected exceptions are caught and
138
944
  * returned as `Result.err(InternalError)`.
@@ -143,7 +949,10 @@ export const executeTrail = async (
143
949
  options?: ExecuteTrailOptions
144
950
  ): Promise<Result<unknown, Error>> => {
145
951
  try {
146
- const validated = validateInput(trail.input, rawInput);
952
+ const validated = validateInput(
953
+ options?.validationSchema ?? trail.input,
954
+ rawInput
955
+ );
147
956
  if (validated.isErr()) {
148
957
  return validated;
149
958
  }
@@ -157,7 +966,9 @@ export const executeTrail = async (
157
966
  trail,
158
967
  validated.value,
159
968
  resolvedCtx.value,
160
- options?.gates ?? []
969
+ options?.layers ?? [],
970
+ options?.topo,
971
+ options
161
972
  );
162
973
  } catch (error: unknown) {
163
974
  const message = error instanceof Error ? error.message : String(error);