@ontrails/core 1.0.0-beta.14 → 1.0.0-beta.16

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 (314) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +73 -24
  3. package/package.json +11 -4
  4. package/src/activation-provenance.ts +111 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-projection.ts +221 -0
  7. package/src/activation-source.ts +91 -0
  8. package/src/blob-ref.ts +51 -0
  9. package/src/branded.ts +1 -1
  10. package/src/context.ts +28 -4
  11. package/src/contour.ts +344 -0
  12. package/src/cross-batch.ts +69 -0
  13. package/src/cross-schema.ts +36 -0
  14. package/src/detours.ts +8 -0
  15. package/src/draft.ts +60 -52
  16. package/src/error-projection.ts +51 -0
  17. package/src/errors.ts +245 -48
  18. package/src/execute.ts +1250 -40
  19. package/src/fire.ts +1169 -0
  20. package/src/index.ts +383 -49
  21. package/src/internal/fork-ctx.ts +69 -0
  22. package/src/layer-projection.ts +192 -0
  23. package/src/layer.ts +81 -0
  24. package/src/observe.ts +361 -0
  25. package/src/path-security.ts +1 -1
  26. package/src/permits.ts +12 -0
  27. package/src/redaction/patterns.ts +6 -3
  28. package/src/resilience.ts +11 -11
  29. package/src/resource-config.ts +792 -0
  30. package/src/resource.ts +175 -0
  31. package/src/result.ts +18 -0
  32. package/src/run.ts +7 -6
  33. package/src/schedule-runtime.ts +689 -0
  34. package/src/schedule.ts +324 -0
  35. package/src/serialization.ts +90 -55
  36. package/src/signal-diagnostics.ts +633 -0
  37. package/src/signal-ref.ts +87 -0
  38. package/src/signal.ts +37 -0
  39. package/src/store/accessor-protocol.ts +56 -0
  40. package/src/store/index.ts +4 -0
  41. package/src/structured-examples.ts +245 -0
  42. package/src/surface-derivation.ts +91 -0
  43. package/src/surface-filter.ts +176 -0
  44. package/src/topo.ts +710 -59
  45. package/src/tracing.ts +345 -0
  46. package/src/trail.ts +347 -33
  47. package/src/trails/derive-trail.ts +842 -0
  48. package/src/trails/index.ts +9 -0
  49. package/src/trails/ingest.ts +138 -0
  50. package/src/{internal/trails-db.ts → trails-db.ts} +50 -31
  51. package/src/transport-error-map.ts +174 -0
  52. package/src/type-utils.ts +17 -2
  53. package/src/types.ts +208 -17
  54. package/src/validate-established-topo.ts +8 -2
  55. package/src/validate-topo.ts +244 -13
  56. package/src/validation.ts +58 -1
  57. package/src/webhook.ts +276 -0
  58. package/src/workspace.ts +1 -1
  59. package/src/zod-wrappers.ts +72 -0
  60. package/.turbo/turbo-build.log +0 -1
  61. package/.turbo/turbo-lint.log +0 -3
  62. package/.turbo/turbo-typecheck.log +0 -1
  63. package/dist/adapters.d.ts +0 -39
  64. package/dist/adapters.d.ts.map +0 -1
  65. package/dist/adapters.js +0 -2
  66. package/dist/adapters.js.map +0 -1
  67. package/dist/blob-ref.d.ts +0 -20
  68. package/dist/blob-ref.d.ts.map +0 -1
  69. package/dist/blob-ref.js +0 -22
  70. package/dist/blob-ref.js.map +0 -1
  71. package/dist/branded.d.ts +0 -36
  72. package/dist/branded.d.ts.map +0 -1
  73. package/dist/branded.js +0 -89
  74. package/dist/branded.js.map +0 -1
  75. package/dist/collections.d.ts +0 -31
  76. package/dist/collections.d.ts.map +0 -1
  77. package/dist/collections.js +0 -60
  78. package/dist/collections.js.map +0 -1
  79. package/dist/context.d.ts +0 -10
  80. package/dist/context.d.ts.map +0 -1
  81. package/dist/context.js +0 -21
  82. package/dist/context.js.map +0 -1
  83. package/dist/derive.d.ts +0 -44
  84. package/dist/derive.d.ts.map +0 -1
  85. package/dist/derive.js +0 -149
  86. package/dist/derive.js.map +0 -1
  87. package/dist/dispatch.d.ts +0 -27
  88. package/dist/dispatch.d.ts.map +0 -1
  89. package/dist/dispatch.js +0 -34
  90. package/dist/dispatch.js.map +0 -1
  91. package/dist/draft.d.ts +0 -28
  92. package/dist/draft.d.ts.map +0 -1
  93. package/dist/draft.js +0 -156
  94. package/dist/draft.js.map +0 -1
  95. package/dist/errors.d.ts +0 -83
  96. package/dist/errors.d.ts.map +0 -1
  97. package/dist/errors.js +0 -142
  98. package/dist/errors.js.map +0 -1
  99. package/dist/event.d.ts +0 -8
  100. package/dist/event.d.ts.map +0 -1
  101. package/dist/event.js +0 -7
  102. package/dist/event.js.map +0 -1
  103. package/dist/execute.d.ts +0 -35
  104. package/dist/execute.d.ts.map +0 -1
  105. package/dist/execute.js +0 -90
  106. package/dist/execute.js.map +0 -1
  107. package/dist/fetch.d.ts +0 -15
  108. package/dist/fetch.d.ts.map +0 -1
  109. package/dist/fetch.js +0 -102
  110. package/dist/fetch.js.map +0 -1
  111. package/dist/gate.d.ts +0 -17
  112. package/dist/gate.d.ts.map +0 -1
  113. package/dist/gate.js +0 -21
  114. package/dist/gate.js.map +0 -1
  115. package/dist/guards.d.ts +0 -17
  116. package/dist/guards.d.ts.map +0 -1
  117. package/dist/guards.js +0 -25
  118. package/dist/guards.js.map +0 -1
  119. package/dist/health.d.ts +0 -18
  120. package/dist/health.d.ts.map +0 -1
  121. package/dist/health.js +0 -5
  122. package/dist/health.js.map +0 -1
  123. package/dist/index.d.ts +0 -48
  124. package/dist/index.d.ts.map +0 -1
  125. package/dist/index.js +0 -51
  126. package/dist/index.js.map +0 -1
  127. package/dist/internal/topo-saves.d.ts +0 -47
  128. package/dist/internal/topo-saves.d.ts.map +0 -1
  129. package/dist/internal/topo-saves.js +0 -310
  130. package/dist/internal/topo-saves.js.map +0 -1
  131. package/dist/internal/topo-store-read.d.ts +0 -67
  132. package/dist/internal/topo-store-read.d.ts.map +0 -1
  133. package/dist/internal/topo-store-read.js +0 -222
  134. package/dist/internal/topo-store-read.js.map +0 -1
  135. package/dist/internal/topo-store.d.ts +0 -12
  136. package/dist/internal/topo-store.d.ts.map +0 -1
  137. package/dist/internal/topo-store.js +0 -571
  138. package/dist/internal/topo-store.js.map +0 -1
  139. package/dist/internal/trails-db.d.ts +0 -16
  140. package/dist/internal/trails-db.d.ts.map +0 -1
  141. package/dist/internal/trails-db.js +0 -118
  142. package/dist/internal/trails-db.js.map +0 -1
  143. package/dist/job.d.ts +0 -24
  144. package/dist/job.d.ts.map +0 -1
  145. package/dist/job.js +0 -17
  146. package/dist/job.js.map +0 -1
  147. package/dist/layer.d.ts +0 -6
  148. package/dist/layer.d.ts.map +0 -1
  149. package/dist/layer.js +0 -4
  150. package/dist/layer.js.map +0 -1
  151. package/dist/path-security.d.ts +0 -28
  152. package/dist/path-security.d.ts.map +0 -1
  153. package/dist/path-security.js +0 -63
  154. package/dist/path-security.js.map +0 -1
  155. package/dist/patterns/bulk.d.ts +0 -15
  156. package/dist/patterns/bulk.d.ts.map +0 -1
  157. package/dist/patterns/bulk.js +0 -14
  158. package/dist/patterns/bulk.js.map +0 -1
  159. package/dist/patterns/change.d.ts +0 -10
  160. package/dist/patterns/change.d.ts.map +0 -1
  161. package/dist/patterns/change.js +0 -10
  162. package/dist/patterns/change.js.map +0 -1
  163. package/dist/patterns/date-range.d.ts +0 -10
  164. package/dist/patterns/date-range.d.ts.map +0 -1
  165. package/dist/patterns/date-range.js +0 -10
  166. package/dist/patterns/date-range.js.map +0 -1
  167. package/dist/patterns/index.d.ts +0 -9
  168. package/dist/patterns/index.d.ts.map +0 -1
  169. package/dist/patterns/index.js +0 -9
  170. package/dist/patterns/index.js.map +0 -1
  171. package/dist/patterns/pagination.d.ts +0 -18
  172. package/dist/patterns/pagination.d.ts.map +0 -1
  173. package/dist/patterns/pagination.js +0 -18
  174. package/dist/patterns/pagination.js.map +0 -1
  175. package/dist/patterns/progress.d.ts +0 -11
  176. package/dist/patterns/progress.d.ts.map +0 -1
  177. package/dist/patterns/progress.js +0 -11
  178. package/dist/patterns/progress.js.map +0 -1
  179. package/dist/patterns/sorting.d.ts +0 -13
  180. package/dist/patterns/sorting.d.ts.map +0 -1
  181. package/dist/patterns/sorting.js +0 -10
  182. package/dist/patterns/sorting.js.map +0 -1
  183. package/dist/patterns/status.d.ts +0 -15
  184. package/dist/patterns/status.d.ts.map +0 -1
  185. package/dist/patterns/status.js +0 -9
  186. package/dist/patterns/status.js.map +0 -1
  187. package/dist/patterns/timestamps.d.ts +0 -10
  188. package/dist/patterns/timestamps.d.ts.map +0 -1
  189. package/dist/patterns/timestamps.js +0 -10
  190. package/dist/patterns/timestamps.js.map +0 -1
  191. package/dist/provision-config.d.ts +0 -22
  192. package/dist/provision-config.d.ts.map +0 -1
  193. package/dist/provision-config.js +0 -210
  194. package/dist/provision-config.js.map +0 -1
  195. package/dist/provision.d.ts +0 -71
  196. package/dist/provision.d.ts.map +0 -1
  197. package/dist/provision.js +0 -56
  198. package/dist/provision.js.map +0 -1
  199. package/dist/redaction/index.d.ts +0 -4
  200. package/dist/redaction/index.d.ts.map +0 -1
  201. package/dist/redaction/index.js +0 -3
  202. package/dist/redaction/index.js.map +0 -1
  203. package/dist/redaction/patterns.d.ts +0 -9
  204. package/dist/redaction/patterns.d.ts.map +0 -1
  205. package/dist/redaction/patterns.js +0 -39
  206. package/dist/redaction/patterns.js.map +0 -1
  207. package/dist/redaction/redactor.d.ts +0 -27
  208. package/dist/redaction/redactor.d.ts.map +0 -1
  209. package/dist/redaction/redactor.js +0 -89
  210. package/dist/redaction/redactor.js.map +0 -1
  211. package/dist/resilience.d.ts +0 -34
  212. package/dist/resilience.d.ts.map +0 -1
  213. package/dist/resilience.js +0 -164
  214. package/dist/resilience.js.map +0 -1
  215. package/dist/result.d.ts +0 -57
  216. package/dist/result.d.ts.map +0 -1
  217. package/dist/result.js +0 -156
  218. package/dist/result.js.map +0 -1
  219. package/dist/run.d.ts +0 -27
  220. package/dist/run.d.ts.map +0 -1
  221. package/dist/run.js +0 -34
  222. package/dist/run.js.map +0 -1
  223. package/dist/serialization.d.ts +0 -27
  224. package/dist/serialization.d.ts.map +0 -1
  225. package/dist/serialization.js +0 -153
  226. package/dist/serialization.js.map +0 -1
  227. package/dist/service-config.d.ts +0 -22
  228. package/dist/service-config.d.ts.map +0 -1
  229. package/dist/service-config.js +0 -210
  230. package/dist/service-config.js.map +0 -1
  231. package/dist/service.d.ts +0 -71
  232. package/dist/service.d.ts.map +0 -1
  233. package/dist/service.js +0 -56
  234. package/dist/service.js.map +0 -1
  235. package/dist/signal.d.ts +0 -33
  236. package/dist/signal.d.ts.map +0 -1
  237. package/dist/signal.js +0 -17
  238. package/dist/signal.js.map +0 -1
  239. package/dist/topo-store.d.ts +0 -48
  240. package/dist/topo-store.d.ts.map +0 -1
  241. package/dist/topo-store.js +0 -175
  242. package/dist/topo-store.js.map +0 -1
  243. package/dist/topo.d.ts +0 -25
  244. package/dist/topo.d.ts.map +0 -1
  245. package/dist/topo.js +0 -112
  246. package/dist/topo.js.map +0 -1
  247. package/dist/trail.d.ts +0 -96
  248. package/dist/trail.d.ts.map +0 -1
  249. package/dist/trail.js +0 -20
  250. package/dist/trail.js.map +0 -1
  251. package/dist/type-utils.d.ts +0 -24
  252. package/dist/type-utils.d.ts.map +0 -1
  253. package/dist/type-utils.js +0 -12
  254. package/dist/type-utils.js.map +0 -1
  255. package/dist/types.d.ts +0 -71
  256. package/dist/types.d.ts.map +0 -1
  257. package/dist/types.js +0 -3
  258. package/dist/types.js.map +0 -1
  259. package/dist/validate-established-topo.d.ts +0 -76
  260. package/dist/validate-established-topo.d.ts.map +0 -1
  261. package/dist/validate-established-topo.js +0 -43
  262. package/dist/validate-established-topo.js.map +0 -1
  263. package/dist/validate-topo.d.ts +0 -24
  264. package/dist/validate-topo.d.ts.map +0 -1
  265. package/dist/validate-topo.js +0 -176
  266. package/dist/validate-topo.js.map +0 -1
  267. package/dist/validation.d.ts +0 -27
  268. package/dist/validation.d.ts.map +0 -1
  269. package/dist/validation.js +0 -165
  270. package/dist/validation.js.map +0 -1
  271. package/dist/workspace.d.ts +0 -25
  272. package/dist/workspace.d.ts.map +0 -1
  273. package/dist/workspace.js +0 -57
  274. package/dist/workspace.js.map +0 -1
  275. package/src/__tests__/blob-ref.test.ts +0 -103
  276. package/src/__tests__/branded.test.ts +0 -148
  277. package/src/__tests__/collections.test.ts +0 -126
  278. package/src/__tests__/context.test.ts +0 -77
  279. package/src/__tests__/derive.test.ts +0 -260
  280. package/src/__tests__/errors.test.ts +0 -309
  281. package/src/__tests__/execute.test.ts +0 -525
  282. package/src/__tests__/fetch.test.ts +0 -217
  283. package/src/__tests__/gate.test.ts +0 -125
  284. package/src/__tests__/guards.test.ts +0 -102
  285. package/src/__tests__/path-security.test.ts +0 -114
  286. package/src/__tests__/patterns.test.ts +0 -273
  287. package/src/__tests__/redaction.test.ts +0 -244
  288. package/src/__tests__/resilience.test.ts +0 -246
  289. package/src/__tests__/result.test.ts +0 -155
  290. package/src/__tests__/run.test.ts +0 -181
  291. package/src/__tests__/serialization.test.ts +0 -401
  292. package/src/__tests__/service-config.test.ts +0 -228
  293. package/src/__tests__/service.test.ts +0 -197
  294. package/src/__tests__/signal.test.ts +0 -82
  295. package/src/__tests__/topo-store-read.test.ts +0 -251
  296. package/src/__tests__/topo-store.test.ts +0 -469
  297. package/src/__tests__/topo.test.ts +0 -277
  298. package/src/__tests__/trail-permit.test.ts +0 -60
  299. package/src/__tests__/trail.test.ts +0 -261
  300. package/src/__tests__/trails-db.test.ts +0 -191
  301. package/src/__tests__/type-utils.test.ts +0 -90
  302. package/src/__tests__/validate-topo.test.ts +0 -489
  303. package/src/__tests__/validation.test.ts +0 -336
  304. package/src/__tests__/workspace.test.ts +0 -183
  305. package/src/event.ts +0 -15
  306. package/src/gate.ts +0 -44
  307. package/src/internal/topo-saves.ts +0 -429
  308. package/src/internal/topo-store-read.ts +0 -473
  309. package/src/internal/topo-store.ts +0 -1087
  310. package/src/provision-config.ts +0 -373
  311. package/src/provision.ts +0 -148
  312. package/src/topo-store.ts +0 -301
  313. package/tsconfig.json +0 -9
  314. package/tsconfig.tsbuildinfo +0 -1
package/src/execute.ts CHANGED
@@ -1,23 +1,77 @@
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 './tracing.js';
15
+ import type { Topo } from './topo.js';
16
+
17
+ import {
18
+ buildActivationProvenanceTraceAttrs,
19
+ getActivationProvenance,
20
+ } from './activation-provenance.js';
21
+ import {
22
+ createFireFn,
23
+ isFrameworkFireFn,
24
+ waitForPendingFireDispatches,
25
+ withFireDispatchTracking,
26
+ } from './fire.js';
27
+ import type { BasePermit } from './permits.js';
28
+ import type {
29
+ CrossBatchOptions,
30
+ CrossFn,
31
+ Detour,
32
+ Implementation,
33
+ TraceFn,
34
+ TrailContext,
35
+ TrailContextInit,
36
+ } from './types.js';
13
37
 
14
- import { composeGates } from './gate.js';
15
- import { createTrailContext } from './context.js';
16
- import { InternalError } from './errors.js';
38
+ import { createTrailContext, passthroughTrace } from './context.js';
39
+ import { buildCrossValidationSchema } from './cross-schema.js';
40
+ import {
41
+ CancelledError,
42
+ InternalError,
43
+ NotFoundError,
44
+ PermitError,
45
+ RetryExhaustedError,
46
+ TrailsError,
47
+ ValidationError,
48
+ } from './errors.js';
49
+ import {
50
+ claimNextCrossBatchIndex,
51
+ createCrossBatchValidationResults,
52
+ normalizeCrossBatchConcurrency,
53
+ } from './cross-batch.js';
54
+ import { forkCtx } from './internal/fork-ctx.js';
55
+ import {
56
+ TRACE_CONTEXT_KEY,
57
+ completeRecord,
58
+ createSpanRecord,
59
+ createTraceRecord,
60
+ getTraceSink,
61
+ isTracingDisabled,
62
+ writeToSink,
63
+ } from './tracing.js';
64
+ import {
65
+ OBSERVE_LOGGER_CONTEXT_KEY,
66
+ OBSERVE_LOGGER_METADATA_KEY,
67
+ createObserveLogger,
68
+ } from './observe.js';
17
69
  import { Result } from './result.js';
18
- import { createProvisionLookup } from './provision.js';
19
- import { resolveProvisions } from './provision-config.js';
20
- import { validateInput } from './validation.js';
70
+ import { DETOUR_MAX_ATTEMPTS_CAP } from './detours.js';
71
+ import { createResourceLookup } from './resource.js';
72
+ import { createResources } from './resource-config.js';
73
+ import { LAYER_INPUTS_KEY, SURFACE_KEY } from './types.js';
74
+ import { validateInput, validateOutput } from './validation.js';
21
75
 
22
76
  type MutableTrailContext = {
23
77
  -readonly [K in keyof TrailContext]: TrailContext[K];
@@ -33,18 +87,85 @@ export interface ExecuteTrailOptions {
33
87
  readonly ctx?: Partial<TrailContextInit> | undefined;
34
88
  /** AbortSignal override (takes final precedence over ctx and factory). */
35
89
  readonly abortSignal?: AbortSignal | undefined;
36
- /** Gates to compose around the implementation. */
37
- readonly gates?: readonly Gate[] | undefined;
90
+ /**
91
+ * Typed layers supplied for this execution.
92
+ *
93
+ * Layers compose around the trail implementation. Layers without `input`
94
+ * schemas are surface-invisible wrappers for concerns such as tenant guards,
95
+ * rate limiting, circuit breaking, or custom audit logging.
96
+ */
97
+ readonly layers?: readonly Layer[] | undefined;
98
+ /**
99
+ * Typed layers attached at surface scope.
100
+ *
101
+ * Surfaces (CLI, MCP, HTTP) forward their `layers` option here so they
102
+ * compose around every trail dispatched through that surface. The final
103
+ * composition order is `topo → surface → trail → blaze` (outermost-first).
104
+ */
105
+ readonly surfaceLayers?: readonly Layer[] | undefined;
106
+ /**
107
+ * Typed layers attached at topo scope.
108
+ *
109
+ * The CLI/MCP/HTTP surfaces typically forward `topo.layers` here so the
110
+ * topo's declared layers wrap every trail invocation. The final
111
+ * composition order is `topo → surface → trail → blaze` (outermost-first).
112
+ */
113
+ readonly topoLayers?: readonly Layer[] | undefined;
38
114
  /** Factory that produces a base TrailContext (takes precedence over defaults). */
39
115
  readonly createContext?:
40
116
  | (() => TrailContextInit | Promise<TrailContextInit>)
41
117
  | 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. */
118
+ /** Explicit resource instance overrides keyed by resource ID. */
119
+ readonly resources?: ResourceOverrideMap | undefined;
120
+ /** Config values for resources that declare a `config` schema, keyed by resource ID. */
45
121
  readonly configValues?:
46
122
  | Readonly<Record<string, Record<string, unknown>>>
47
123
  | undefined;
124
+ /** Topo used for signal-driven activation; required for `ctx.fire()` to work. */
125
+ readonly topo?: Topo | undefined;
126
+ /**
127
+ * Whether this invocation is a dry run.
128
+ *
129
+ * Sets `ctx.dryRun` for the trail. Defaults to `false`. The framework
130
+ * never short-circuits execution on this field — it only carries the
131
+ * flag through. Trails that read `ctx.dryRun` decide what dry-run means.
132
+ */
133
+ readonly dryRun?: boolean | undefined;
134
+ /**
135
+ * Permit to overlay onto `ctx.permit` for this invocation.
136
+ *
137
+ * When provided, this overrides any permit supplied via `ctx.permit` on
138
+ * the partial context overrides or via the `createContext` factory. Leave
139
+ * unset to inherit the permit from the resolved context (typically
140
+ * `undefined`). Surfaces parse and validate the permit at their boundary
141
+ * (e.g. CLI `--permit '<json>'`) before passing it here.
142
+ */
143
+ readonly permit?: BasePermit | undefined;
144
+ /**
145
+ * Per-layer runtime input keyed by `Layer.name`.
146
+ *
147
+ * Surfaces (CLI, MCP, HTTP) parse their native idiom into a per-layer
148
+ * input object — usually projected from each layer's `input` schema —
149
+ * and pass it here. The executor merges these into
150
+ * `ctx.extensions[LAYER_INPUTS_KEY]` so layers can read their own slot
151
+ * via `ctx.extensions?.[LAYER_INPUTS_KEY]?.[layer.name]`.
152
+ *
153
+ * @see TRL-473 for the CLI projection contract.
154
+ */
155
+ readonly layerInputs?: Readonly<Record<string, unknown>> | undefined;
156
+ /**
157
+ * Override the validation schema used for input validation.
158
+ *
159
+ * When a trail is invoked via `ctx.cross()` and the target declares
160
+ * `crossInput`, the cross function merges `trail.input` with
161
+ * `trail.crossInput` and passes the merged schema here so validation
162
+ * accepts both public and composition-only fields.
163
+ *
164
+ * Used by the cross execution path; not part of the public API.
165
+ *
166
+ * @internal
167
+ */
168
+ readonly validationSchema?: z.ZodType | undefined;
48
169
  }
49
170
 
50
171
  // ---------------------------------------------------------------------------
@@ -63,25 +184,55 @@ const applyContextOverrides = (
63
184
  }
64
185
  : base;
65
186
 
66
- return options?.abortSignal
187
+ const withAbort = options?.abortSignal
67
188
  ? { ...withOverrides, abortSignal: options.abortSignal }
68
189
  : withOverrides;
190
+
191
+ const withDryRun =
192
+ options?.dryRun === undefined
193
+ ? withAbort
194
+ : { ...withAbort, dryRun: options.dryRun };
195
+
196
+ const withPermit =
197
+ options?.permit === undefined
198
+ ? withDryRun
199
+ : { ...withDryRun, permit: options.permit };
200
+
201
+ if (options?.layerInputs === undefined) {
202
+ return withPermit;
203
+ }
204
+ // Merge per-layer inputs onto any inherited LAYER_INPUTS_KEY slot so
205
+ // crossed/forked contexts can preserve outer-surface routing.
206
+ const inheritedExtensions = withPermit.extensions ?? {};
207
+ const inheritedLayerInputs = (inheritedExtensions[LAYER_INPUTS_KEY] ?? {}) as
208
+ | Readonly<Record<string, unknown>>
209
+ | Record<string, unknown>;
210
+ return {
211
+ ...withPermit,
212
+ extensions: {
213
+ ...inheritedExtensions,
214
+ [LAYER_INPUTS_KEY]: {
215
+ ...inheritedLayerInputs,
216
+ ...options.layerInputs,
217
+ },
218
+ },
219
+ };
69
220
  };
70
221
 
71
- const bindProvisionLookup = (
222
+ const bindResourceLookup = (
72
223
  resolved: TrailContextInit,
73
224
  options?: ExecuteTrailOptions
74
225
  ): TrailContext => {
75
226
  if (
76
227
  options?.ctx?.extensions === undefined &&
77
- resolved.provision !== undefined
228
+ resolved.resource !== undefined
78
229
  ) {
79
230
  return resolved as TrailContext;
80
231
  }
81
232
 
82
233
  const bound = { ...resolved } as MutableTrailContext;
83
- const lookup = createProvisionLookup(() => bound);
84
- bound.provision = lookup;
234
+ const lookup = createResourceLookup(() => bound);
235
+ bound.resource = lookup;
85
236
  return bound;
86
237
  };
87
238
 
@@ -92,39 +243,985 @@ const bindProvisionLookup = (
92
243
  * 1. Factory (`createContext`) or `createTrailContext()` defaults.
93
244
  * 2. Partial `ctx` overrides merged on top.
94
245
  * 3. `abortSignal` override takes final precedence.
246
+ * 4. `dryRun` option takes final precedence (defaults to `false` via
247
+ * `createTrailContext` when neither option nor `ctx.dryRun` is provided).
248
+ * 5. `permit` option takes final precedence over any inherited permit when
249
+ * provided; leaving it unset preserves whatever the resolved context
250
+ * already carries.
95
251
  */
96
252
  const resolveContext = async (
97
253
  options?: ExecuteTrailOptions
98
254
  ): Promise<TrailContext> => {
99
255
  const seed = options?.createContext
100
256
  ? await options.createContext()
101
- : createTrailContext();
102
- const base = seed.provision ? seed : createTrailContext(seed);
257
+ : undefined;
258
+ const base = createTrailContext(seed);
103
259
  const resolved = applyContextOverrides(base, options);
104
- return bindProvisionLookup(resolved, options);
260
+ return bindResourceLookup(resolved, options);
261
+ };
262
+
263
+ const readObserveLoggerMetadata = (
264
+ ctx: TrailContext
265
+ ): Record<string, unknown> => {
266
+ const value = ctx.extensions?.[OBSERVE_LOGGER_METADATA_KEY];
267
+ return value !== null && typeof value === 'object'
268
+ ? (value as Record<string, unknown>)
269
+ : {};
270
+ };
271
+
272
+ const applyTopoObserveContext = (
273
+ trail: AnyTrail,
274
+ ctx: TrailContext,
275
+ topo: Topo | undefined
276
+ ): TrailContext => {
277
+ if (topo?.observe?.log === undefined) {
278
+ return ctx;
279
+ }
280
+ const hasTopoObserveLogger =
281
+ ctx.extensions?.[OBSERVE_LOGGER_CONTEXT_KEY] === true;
282
+ if (ctx.logger !== undefined && !hasTopoObserveLogger) {
283
+ return ctx;
284
+ }
285
+ const { log } = topo.observe;
286
+ // Accumulated metadata (e.g. signal fan-out fields) takes precedence over
287
+ // the previous observe logger's `topo`/`trailId` values, but the new trail's
288
+ // identity wins so log records keep pointing at the trail currently running.
289
+ const accumulated = readObserveLoggerMetadata(ctx);
290
+ return {
291
+ ...ctx,
292
+ extensions: {
293
+ ...ctx.extensions,
294
+ [OBSERVE_LOGGER_CONTEXT_KEY]: true,
295
+ },
296
+ logger: createObserveLogger(log, trail.id, {
297
+ ...accumulated,
298
+ topo: topo.name,
299
+ trailId: trail.id,
300
+ }),
301
+ };
302
+ };
303
+
304
+ const findMissingScopes = (
305
+ required: readonly string[],
306
+ held: readonly string[]
307
+ ): readonly string[] => required.filter((scope) => !held.includes(scope));
308
+
309
+ const enforcePermitRequirement = (
310
+ trail: AnyTrail,
311
+ ctx: TrailContext
312
+ ): Result<TrailContext, Error> => {
313
+ const requirement = trail.permit;
314
+ if (requirement === undefined || requirement === 'public') {
315
+ return Result.ok(ctx);
316
+ }
317
+
318
+ if (ctx.permit === undefined) {
319
+ return Result.err(
320
+ new PermitError('No permit provided', {
321
+ context: { required: requirement.scopes, trailId: trail.id },
322
+ })
323
+ );
324
+ }
325
+
326
+ const missing = findMissingScopes(requirement.scopes, ctx.permit.scopes);
327
+ return missing.length === 0
328
+ ? Result.ok(ctx)
329
+ : Result.err(
330
+ new PermitError(`Missing scopes: ${missing.join(', ')}`, {
331
+ context: { missing, required: requirement.scopes, trailId: trail.id },
332
+ })
333
+ );
105
334
  };
106
335
 
107
336
  const prepareContext = async (
108
337
  trail: AnyTrail,
109
338
  options?: ExecuteTrailOptions
110
- ): Promise<Result<TrailContext, Error>> => {
111
- const baseCtx = await resolveContext(options);
112
- return await resolveProvisions(
339
+ ): Promise<
340
+ Result<
341
+ { readonly ctx: TrailContext; readonly releaseResources: () => void },
342
+ Error
343
+ >
344
+ > => {
345
+ const baseCtx = applyTopoObserveContext(
113
346
  trail,
114
- baseCtx,
115
- options?.provisions,
347
+ await resolveContext(options),
348
+ options?.topo
349
+ );
350
+ const permitted = enforcePermitRequirement(trail, baseCtx);
351
+ if (permitted.isErr()) {
352
+ return Result.err(permitted.error);
353
+ }
354
+
355
+ const resources = await createResources(
356
+ trail,
357
+ permitted.value,
358
+ options?.resources,
116
359
  options?.configValues
117
360
  );
361
+ return resources.isErr()
362
+ ? Result.err(resources.error)
363
+ : Result.ok({
364
+ ctx: resources.value.ctx,
365
+ releaseResources: resources.value.release,
366
+ });
367
+ };
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Intrinsic tracing
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /** Derive the status + error category fields from a trail result. */
374
+ const deriveResultErrorCategory = (error: Error): string => {
375
+ if (error instanceof TrailsError) {
376
+ return error.category;
377
+ }
378
+ return 'internal';
379
+ };
380
+
381
+ const deriveOutcome = (
382
+ result: Result<unknown, Error>
383
+ ): {
384
+ readonly status: TraceRecord['status'];
385
+ readonly errorCategory: string | undefined;
386
+ } =>
387
+ result.match<{
388
+ readonly status: TraceRecord['status'];
389
+ readonly errorCategory: string | undefined;
390
+ }>({
391
+ err: (error) => ({
392
+ errorCategory: deriveResultErrorCategory(error),
393
+ status: error instanceof CancelledError ? 'cancelled' : 'err',
394
+ }),
395
+ ok: () => ({ errorCategory: undefined, status: 'ok' }),
396
+ });
397
+
398
+ /**
399
+ * Best-effort error category for a thrown (not Result.err) value.
400
+ *
401
+ * Unknown/non-Error throws normalize to `'internal'` so the trace record
402
+ * always carries a category when the trail unexpectedly throws.
403
+ */
404
+ const categorizeSpanError = (error: unknown): string => {
405
+ if (error instanceof TrailsError) {
406
+ return error.category;
407
+ }
408
+ return 'internal';
409
+ };
410
+
411
+ /** Extract the permit identity fields for the trace record. */
412
+ const extractPermit = (
413
+ ctx: TrailContext
414
+ ): { readonly id: string; readonly tenantId?: string } | undefined => {
415
+ if (ctx.permit === undefined) {
416
+ return undefined;
417
+ }
418
+ const tenantId =
419
+ 'tenantId' in ctx.permit
420
+ ? (ctx.permit as { tenantId?: string }).tenantId
421
+ : undefined;
422
+ return tenantId === undefined
423
+ ? { id: ctx.permit.id }
424
+ : { id: ctx.permit.id, tenantId };
425
+ };
426
+
427
+ /**
428
+ * Build a `ctx.trace` function bound to a parent trace context.
429
+ *
430
+ * Each call creates a child span under the parent, times the callback,
431
+ * records success/failure with the appropriate error category, writes the
432
+ * completed span to the sink, and returns the callback result. Errors
433
+ * thrown by the callback are recorded and then rethrown.
434
+ *
435
+ * The returned function reads the *current* trace context from its captured
436
+ * parent. That means direct nesting (`ctx.trace('a', () => ctx.trace('b',
437
+ * ...))`) produces siblings under `a`'s parent, not children of `a`. For
438
+ * true child nesting, callers should cross into another trail (which gets
439
+ * its own root record parented by this one) — full cross-trail parenting
440
+ * is implemented in a later phase. For Phase 1, sibling spans under the
441
+ * trail's root are the supported shape.
442
+ */
443
+ const buildTraceFn =
444
+ (parent: TraceContext, sink: ReturnType<typeof getTraceSink>): TraceFn =>
445
+ async <T>(label: string, fn: () => T | Promise<T>): Promise<T> => {
446
+ const record = createSpanRecord(parent, label);
447
+ try {
448
+ const value = await fn();
449
+ await writeToSink(sink, completeRecord(record, 'ok'));
450
+ return value;
451
+ } catch (error: unknown) {
452
+ const errorCategory = categorizeSpanError(error);
453
+ const status: TraceRecord['status'] =
454
+ error instanceof CancelledError ? 'cancelled' : 'err';
455
+ await writeToSink(sink, completeRecord(record, status, errorCategory));
456
+ throw error;
457
+ }
458
+ };
459
+
460
+ /** Build the root trace record + trace-enriched context for a trail run. */
461
+ const buildTracedContext = (
462
+ trail: AnyTrail,
463
+ ctx: TrailContext,
464
+ sink: ReturnType<typeof getTraceSink>
465
+ ): { readonly record: TraceRecord; readonly tracedCtx: TrailContext } => {
466
+ // If a parent trace context is present (set by an outer executeTrail when
467
+ // the current trail was invoked via ctx.cross or ctx.fire), inherit its
468
+ // traceId/rootId so the trace tree spans trail boundaries. Otherwise this
469
+ // execution becomes a fresh root.
470
+ const parent = ctx.extensions?.[TRACE_CONTEXT_KEY] as
471
+ | TraceContext
472
+ | undefined;
473
+
474
+ const record = createTraceRecord({
475
+ intent: trail.intent,
476
+ parentId: parent?.spanId,
477
+ permit: extractPermit(ctx),
478
+ rootId: parent?.rootId,
479
+ sampled: parent?.sampled ?? true,
480
+ surface: ctx.extensions?.[SURFACE_KEY] as TraceRecord['surface'],
481
+ traceId: parent?.traceId,
482
+ trailId: trail.id,
483
+ });
484
+ const activation = getActivationProvenance(ctx);
485
+ const recordWithAttrs: TraceRecord =
486
+ activation === undefined
487
+ ? record
488
+ : {
489
+ ...record,
490
+ attrs: buildActivationProvenanceTraceAttrs(activation),
491
+ };
492
+
493
+ // Root trace context for this trail's span. When inheriting a parent, the
494
+ // traceId/rootId carry forward and only spanId advances to the new record.
495
+ const rootTrace: TraceContext = {
496
+ rootId: parent?.rootId ?? record.id,
497
+ sampled: recordWithAttrs.sampled ?? true,
498
+ spanId: recordWithAttrs.id,
499
+ traceId: recordWithAttrs.traceId,
500
+ };
501
+
502
+ const tracedCtx: TrailContext = {
503
+ ...ctx,
504
+ extensions: {
505
+ ...ctx.extensions,
506
+ [TRACE_CONTEXT_KEY]: rootTrace,
507
+ },
508
+ trace: buildTraceFn(rootTrace, sink),
509
+ };
510
+
511
+ return { record: recordWithAttrs, tracedCtx };
512
+ };
513
+
514
+ const buildUntracedContext = (ctx: TrailContext): TrailContext => {
515
+ const { [TRACE_CONTEXT_KEY]: _traceContext, ...extensions } =
516
+ ctx.extensions ?? {};
517
+ const hasExtensions = Object.keys(extensions).length > 0;
518
+
519
+ return {
520
+ ...ctx,
521
+ extensions: hasExtensions ? extensions : undefined,
522
+ trace: passthroughTrace,
523
+ };
524
+ };
525
+
526
+ /** Run the composed implementation and write the root record on any outcome. */
527
+ const runImplWithRootRecord = async (
528
+ impl: Implementation<unknown, unknown>,
529
+ input: unknown,
530
+ tracedCtx: TrailContext,
531
+ record: TraceRecord,
532
+ sink: ReturnType<typeof getTraceSink>
533
+ ): Promise<Result<unknown, Error>> => {
534
+ try {
535
+ const result = await impl(input, tracedCtx);
536
+ await waitForPendingFireDispatches(tracedCtx);
537
+ const outcome = deriveOutcome(result);
538
+ await writeToSink(
539
+ sink,
540
+ completeRecord(record, outcome.status, outcome.errorCategory)
541
+ );
542
+ return result;
543
+ } catch (error: unknown) {
544
+ await waitForPendingFireDispatches(tracedCtx);
545
+ // Normalize unexpected throws so the root record still reflects the error
546
+ // outcome. The outer executeTrail try/catch converts the thrown value into
547
+ // a Result.err(InternalError) for the caller.
548
+ const status: TraceRecord['status'] =
549
+ error instanceof CancelledError ? 'cancelled' : 'err';
550
+ const errorCategory = categorizeSpanError(error);
551
+ await writeToSink(sink, completeRecord(record, status, errorCategory));
552
+ throw error;
553
+ }
554
+ };
555
+
556
+ const resolveCrossTarget = (
557
+ trailOrId: AnyTrail | string,
558
+ topo: Topo | undefined
559
+ ): Result<AnyTrail, Error> => {
560
+ if (typeof trailOrId !== 'string') {
561
+ return Result.ok(trailOrId);
562
+ }
563
+
564
+ if (topo === undefined) {
565
+ return Result.err(
566
+ new NotFoundError(
567
+ `Trail "${trailOrId}" cannot be crossed without topo access`
568
+ )
569
+ );
570
+ }
571
+
572
+ const target = topo.get(trailOrId);
573
+ return target
574
+ ? Result.ok(target)
575
+ : Result.err(
576
+ new NotFoundError(
577
+ `Trail "${trailOrId}" not found in topo "${topo.name}"`
578
+ )
579
+ );
580
+ };
581
+
582
+ const collectConcurrentBranchResourceIds = (
583
+ target: AnyTrail,
584
+ topo: Topo | undefined
585
+ ): Set<string> =>
586
+ new Set(
587
+ topo?.resourceIds() ?? target.resources.map((resource) => resource.id)
588
+ );
589
+
590
+ const stripInheritedResourceExtensions = (
591
+ ctx: TrailContext,
592
+ target: AnyTrail,
593
+ topo: Topo | undefined
594
+ ): Record<string, unknown> => {
595
+ const resourceIds = collectConcurrentBranchResourceIds(target, topo);
596
+ const entries = Object.entries(ctx.extensions ?? {}).filter(
597
+ ([key]) => !resourceIds.has(key)
598
+ );
599
+ return Object.fromEntries(entries);
600
+ };
601
+
602
+ const deriveConcurrentBranchObserveMetadata = (
603
+ ctx: TrailContext,
604
+ target: AnyTrail,
605
+ branchIndex: number
606
+ ): Record<string, unknown> | undefined => {
607
+ // Only carry observe metadata forward when the parent ctx is using a
608
+ // topo-managed observe logger. Otherwise we'd attach branch fields to
609
+ // caller-supplied loggers that didn't opt into the structured contract.
610
+ if (ctx.extensions?.[OBSERVE_LOGGER_CONTEXT_KEY] !== true) {
611
+ return;
612
+ }
613
+ return {
614
+ ...readObserveLoggerMetadata(ctx),
615
+ branchIndex,
616
+ crossedTrailId: target.id,
617
+ };
618
+ };
619
+
620
+ const buildConcurrentBranchExtensions = (
621
+ ctx: TrailContext,
622
+ target: AnyTrail,
623
+ topo: Topo | undefined,
624
+ branchIndex: number
625
+ ): Record<string, unknown> => {
626
+ const stripped = stripInheritedResourceExtensions(ctx, target, topo);
627
+ const observeMetadata = deriveConcurrentBranchObserveMetadata(
628
+ ctx,
629
+ target,
630
+ branchIndex
631
+ );
632
+ if (observeMetadata === undefined) {
633
+ return stripped;
634
+ }
635
+ return {
636
+ ...stripped,
637
+ [OBSERVE_LOGGER_METADATA_KEY]: observeMetadata,
638
+ };
639
+ };
640
+
641
+ const deriveConcurrentBranchLogger = (
642
+ ctx: TrailContext,
643
+ target: AnyTrail,
644
+ branchIndex: number
645
+ ) =>
646
+ ctx.logger?.child?.({
647
+ branchIndex,
648
+ crossedTrailId: target.id,
649
+ }) ?? ctx.logger;
650
+
651
+ /**
652
+ * Build a child context for one concurrent crossing branch.
653
+ *
654
+ * Concurrent crossings should not inherit already-resolved resource instances
655
+ * from the parent execution scope. Stripping resource IDs from extensions
656
+ * forces each branch to resolve its own scope while still carrying forward
657
+ * request-scoped values like tracing, surface identity, permits, and the
658
+ * shared AbortSignal.
659
+ *
660
+ * `cross`, `fire`, and `resource` are cleared so the child execution can
661
+ * rebind them to the branch-local context instead of reusing closures that
662
+ * capture the parent scope.
663
+ */
664
+ const buildConcurrentBranchContext = (
665
+ ctx: TrailContext,
666
+ target: AnyTrail,
667
+ topo: Topo | undefined,
668
+ branchIndex: number
669
+ ): TrailContext =>
670
+ forkCtx(ctx, {
671
+ extensions: buildConcurrentBranchExtensions(ctx, target, topo, branchIndex),
672
+ logger: deriveConcurrentBranchLogger(ctx, target, branchIndex),
673
+ });
674
+
675
+ const executeResolvedCrossTarget = async (
676
+ target: AnyTrail,
677
+ input: unknown,
678
+ ctx: TrailContext,
679
+ topo: Topo | undefined,
680
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
681
+ ): Promise<Result<unknown, Error>> =>
682
+ await // eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
683
+ executeTrail(target, input, {
684
+ ...forwarded,
685
+ ctx,
686
+ topo,
687
+ validationSchema: buildCrossValidationSchema(target),
688
+ });
689
+
690
+ const executeCrossTarget = async (
691
+ trailOrId: AnyTrail | string,
692
+ input: unknown,
693
+ ctx: TrailContext,
694
+ topo: Topo | undefined,
695
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
696
+ ): Promise<Result<unknown, Error>> => {
697
+ const target = resolveCrossTarget(trailOrId, topo);
698
+ if (target.isErr()) {
699
+ return target;
700
+ }
701
+
702
+ return await executeResolvedCrossTarget(
703
+ target.value,
704
+ input,
705
+ ctx,
706
+ topo,
707
+ forwarded
708
+ );
709
+ };
710
+
711
+ type CrossBatchCall = readonly [AnyTrail | string, unknown];
712
+
713
+ const executeConcurrentCrossBatchCall = async (
714
+ call: CrossBatchCall,
715
+ branchIndex: number,
716
+ ctx: TrailContext,
717
+ topo: Topo | undefined,
718
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
719
+ ): Promise<Result<unknown, Error>> => {
720
+ const [trailOrId, batchInput] = call;
721
+ const target = resolveCrossTarget(trailOrId, topo);
722
+ if (target.isErr()) {
723
+ return target;
724
+ }
725
+
726
+ return await executeResolvedCrossTarget(
727
+ target.value,
728
+ batchInput,
729
+ buildConcurrentBranchContext(ctx, target.value, topo, branchIndex),
730
+ topo,
731
+ forwarded
732
+ );
733
+ };
734
+
735
+ const executeUnlimitedCrossBatch = async (
736
+ calls: readonly CrossBatchCall[],
737
+ ctx: TrailContext,
738
+ topo: Topo | undefined,
739
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
740
+ ): Promise<Result<unknown, Error>[]> =>
741
+ await Promise.all(
742
+ calls.map((call, branchIndex) =>
743
+ executeConcurrentCrossBatchCall(call, branchIndex, ctx, topo, forwarded)
744
+ )
745
+ );
746
+
747
+ const createCrossBatchResults = (
748
+ calls: readonly CrossBatchCall[]
749
+ ): Result<unknown, Error>[] =>
750
+ Array.from<Result<unknown, Error>>({ length: calls.length });
751
+
752
+ const executeLimitedCrossBatch = async (
753
+ calls: readonly CrossBatchCall[],
754
+ ctx: TrailContext,
755
+ topo: Topo | undefined,
756
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
757
+ limit: number
758
+ ): Promise<Result<unknown, Error>[]> => {
759
+ const results = createCrossBatchResults(calls);
760
+ const nextIndex = { value: 0 };
761
+
762
+ const runWorker = async () => {
763
+ while (true) {
764
+ const branchIndex = claimNextCrossBatchIndex(nextIndex, calls);
765
+ if (branchIndex === undefined) {
766
+ return;
767
+ }
768
+
769
+ const call = calls[branchIndex];
770
+ if (call === undefined) {
771
+ // Defensive: `claimNextCrossBatchIndex` only returns indices within
772
+ // bounds, so this slot should always be populated. If it ever isn't,
773
+ // surface a clear InternalError in place of the missing slot and keep
774
+ // the worker loop running so sibling branches still get processed.
775
+ results[branchIndex] = Result.err(
776
+ new InternalError(
777
+ `unreachable: concurrent cross batch call missing at index ${branchIndex}`
778
+ )
779
+ );
780
+ continue;
781
+ }
782
+
783
+ results[branchIndex] = await executeConcurrentCrossBatchCall(
784
+ call,
785
+ branchIndex,
786
+ ctx,
787
+ topo,
788
+ forwarded
789
+ );
790
+ }
791
+ };
792
+
793
+ await Promise.all(Array.from({ length: limit }, runWorker));
794
+ return results;
795
+ };
796
+
797
+ const executeCrossBatch = async (
798
+ calls: readonly CrossBatchCall[],
799
+ ctx: TrailContext,
800
+ topo: Topo | undefined,
801
+ forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
802
+ batchOptions?: CrossBatchOptions
803
+ ): Promise<Result<unknown, Error>[]> => {
804
+ if (calls.length === 0) {
805
+ return [];
806
+ }
807
+
808
+ const concurrency = normalizeCrossBatchConcurrency(batchOptions);
809
+ if (concurrency.isErr()) {
810
+ return createCrossBatchValidationResults(calls, concurrency.error);
811
+ }
812
+
813
+ const limit = concurrency.value ?? calls.length;
814
+ return limit >= calls.length
815
+ ? await executeUnlimitedCrossBatch(calls, ctx, topo, forwarded)
816
+ : await executeLimitedCrossBatch(calls, ctx, topo, forwarded, limit);
817
+ };
818
+
819
+ const bindCrossToCtx = (
820
+ ctx: TrailContext,
821
+ topo: Topo | undefined,
822
+ options: ExecuteTrailOptions | undefined
823
+ ): TrailContext => {
824
+ if (ctx.cross !== undefined) {
825
+ return ctx;
826
+ }
827
+
828
+ const {
829
+ createContext: _omit,
830
+ validationSchema: _omitSchema,
831
+ ...forwarded
832
+ } = options ?? {};
833
+ const cross = (async (
834
+ trailOrCalls:
835
+ | AnyTrail
836
+ | string
837
+ | readonly (readonly [AnyTrail | string, unknown])[],
838
+ inputOrOptions?: CrossBatchOptions | unknown
839
+ ) => {
840
+ if (Array.isArray(trailOrCalls)) {
841
+ return await executeCrossBatch(
842
+ trailOrCalls,
843
+ ctx,
844
+ topo,
845
+ forwarded,
846
+ inputOrOptions as CrossBatchOptions | undefined
847
+ );
848
+ }
849
+
850
+ return await executeCrossTarget(
851
+ trailOrCalls as AnyTrail | string,
852
+ inputOrOptions,
853
+ ctx,
854
+ topo,
855
+ forwarded
856
+ );
857
+ }) as CrossFn;
858
+
859
+ return {
860
+ ...ctx,
861
+ cross,
862
+ };
863
+ };
864
+
865
+ const bindFireToCtx = (
866
+ ctx: TrailContext,
867
+ topo: Topo | undefined,
868
+ options: ExecuteTrailOptions | undefined,
869
+ producerTrailId?: string | undefined
870
+ ): TrailContext => {
871
+ // Symmetric with bindCrossToCtx: a caller-supplied ctx.fire (e.g. test
872
+ // helper, scenario harness, or runtime intercepting signal fan-out) is
873
+ // preserved as-is. Without this guard, passing both `topo: app` and a
874
+ // custom `ctx.fire` would silently clobber the injected mock with the
875
+ // topo-backed dispatcher. Framework-created fire functions are replaced
876
+ // because consumer fan-out seeds them before the consumer trace span exists.
877
+ if (ctx.fire !== undefined && !isFrameworkFireFn(ctx.fire)) {
878
+ return ctx;
879
+ }
880
+ if (topo === undefined) {
881
+ return ctx;
882
+ }
883
+ // Forward the producer's execution options to consumers so resources,
884
+ // layers, configValues, and abortSignal propagate through signal fan-out.
885
+ // `createContext` is intentionally stripped — consumers inherit the
886
+ // already-resolved ctx via `consumerCtx`, and re-running the factory would
887
+ // clobber that.
888
+ // Strip createContext (consumers inherit resolved ctx) and validationSchema
889
+ // (consumers validate against their own schema, not the producer's cross schema).
890
+ const {
891
+ createContext: _omit,
892
+ validationSchema: _omitSchema,
893
+ ...forwarded
894
+ } = options ?? {};
895
+ const trackedCtx = withFireDispatchTracking(ctx);
896
+ const fire = createFireFn(
897
+ topo,
898
+ trackedCtx,
899
+ (consumer, input, consumerCtx) =>
900
+ // eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
901
+ executeTrail(consumer, input, {
902
+ ...forwarded,
903
+ ctx: consumerCtx,
904
+ topo,
905
+ }),
906
+ producerTrailId
907
+ );
908
+ return { ...trackedCtx, fire };
909
+ };
910
+
911
+ const bindCrossAtLayerBoundary =
912
+ <I, O>(
913
+ implementation: Implementation<I, O>,
914
+ topo: Topo | undefined,
915
+ options: ExecuteTrailOptions | undefined
916
+ ): Implementation<I, O> =>
917
+ (input, ctx) =>
918
+ implementation(input, bindCrossToCtx(ctx, topo, options));
919
+
920
+ const bindFireAtLayerBoundary = <I, O>(
921
+ implementation: Implementation<I, O>,
922
+ trail: AnyTrail,
923
+ topo: Topo | undefined,
924
+ options: ExecuteTrailOptions | undefined
925
+ ): Implementation<I, O> => {
926
+ if (topo === undefined) {
927
+ return implementation;
928
+ }
929
+
930
+ return (input, ctx) =>
931
+ implementation(input, bindFireToCtx(ctx, topo, options, trail.id));
932
+ };
933
+
934
+ // ---------------------------------------------------------------------------
935
+ // Detour loop
936
+ // ---------------------------------------------------------------------------
937
+
938
+ /**
939
+ * Find the first detour whose `on` class matches the error via `instanceof`.
940
+ *
941
+ * Declaration order wins — no most-specific-first hierarchy walking.
942
+ */
943
+ const findMatchingDetour = (
944
+ /* oxlint-disable-next-line no-explicit-any -- existential detour array from AnyTrail */
945
+ detours: readonly Detour<any, any, TrailsError>[],
946
+ error: TrailsError
947
+ /* oxlint-disable-next-line no-explicit-any -- matched detour carries runtime generics */
948
+ ): Detour<any, any, TrailsError> | undefined =>
949
+ detours.find((d) => error instanceof d.on);
950
+
951
+ /** Execute a single detour recovery attempt, routing through ctx.trace when available. */
952
+ const executeDetourAttempt = async (
953
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
954
+ detour: Detour<any, any, TrailsError>,
955
+ attempt: number,
956
+ lastError: TrailsError,
957
+ input: unknown,
958
+ ctx: TrailContext
959
+ ): Promise<Result<unknown, Error>> => {
960
+ const run = async () =>
961
+ await detour.recover({ attempt, error: lastError, input }, ctx);
962
+
963
+ return ctx.trace
964
+ ? await ctx.trace(`detour:${detour.on.name}:${attempt}`, run)
965
+ : await run();
966
+ };
967
+
968
+ /** Classify a detour attempt result: continue the loop, or return early. */
969
+ const classifyDetourResult = (
970
+ result: Result<unknown, Error>,
971
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
972
+ detour: Detour<any, any, TrailsError>
973
+ ):
974
+ | { readonly done: true; readonly result: Result<unknown, Error> }
975
+ | { readonly done: false; readonly nextError: TrailsError } => {
976
+ if (result.isOk()) {
977
+ return { done: true, result };
978
+ }
979
+ const recoverError = result.error;
980
+ if (
981
+ !(recoverError instanceof TrailsError) ||
982
+ !(recoverError instanceof detour.on)
983
+ ) {
984
+ return { done: true, result };
985
+ }
986
+ return { done: false, nextError: recoverError };
987
+ };
988
+
989
+ /** Resolve effective maxAttempts, warning if the declared value exceeds the hard cap. */
990
+ const resolveMaxAttempts = (
991
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
992
+ detour: Detour<any, any, TrailsError>,
993
+ ctx: TrailContext
994
+ ): number => {
995
+ const declared = detour.maxAttempts ?? 1;
996
+ const clamped = Math.max(1, Math.min(declared, DETOUR_MAX_ATTEMPTS_CAP));
997
+ if (clamped === declared) {
998
+ return clamped;
999
+ }
1000
+ ctx.logger?.warn('detour maxAttempts clamped', {
1001
+ declared,
1002
+ detour: detour.on.name,
1003
+ effective: clamped,
1004
+ });
1005
+ return clamped;
1006
+ };
1007
+
1008
+ /** Run the detour recovery loop for a single matched detour. */
1009
+ const runDetourRecovery = async (
1010
+ /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
1011
+ detour: Detour<any, any, TrailsError>,
1012
+ error: TrailsError,
1013
+ input: unknown,
1014
+ ctx: TrailContext
1015
+ ): Promise<Result<unknown, Error>> => {
1016
+ const maxAttempts = resolveMaxAttempts(detour, ctx);
1017
+ let lastError: TrailsError = error;
1018
+
1019
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1020
+ ctx.logger?.debug('detour recovery attempt', {
1021
+ attempt,
1022
+ errorClass: lastError.name,
1023
+ matchedDetour: detour.on.name,
1024
+ maxAttempts,
1025
+ });
1026
+ const result = await executeDetourAttempt(
1027
+ detour,
1028
+ attempt,
1029
+ lastError,
1030
+ input,
1031
+ ctx
1032
+ );
1033
+ const classification = classifyDetourResult(result, detour);
1034
+ if (classification.done) {
1035
+ return classification.result;
1036
+ }
1037
+ lastError = classification.nextError;
1038
+ }
1039
+
1040
+ return Result.err(
1041
+ new RetryExhaustedError(lastError, {
1042
+ attempts: maxAttempts,
1043
+ detour: detour.on.name,
1044
+ })
1045
+ );
1046
+ };
1047
+
1048
+ /**
1049
+ * Wrap a blaze with the detour recovery loop.
1050
+ *
1051
+ * If the trail has no detours, returns the blaze unchanged (no wrapper overhead).
1052
+ * The detour loop runs inside the layer stack, closest to the blaze.
1053
+ */
1054
+ const wrapWithDetours = (
1055
+ blaze: Implementation<unknown, unknown>,
1056
+ /* oxlint-disable-next-line no-explicit-any -- existential detour array from AnyTrail */
1057
+ detours: readonly Detour<any, any, TrailsError>[]
1058
+ ): Implementation<unknown, unknown> => {
1059
+ if (detours.length === 0) {
1060
+ return blaze;
1061
+ }
1062
+
1063
+ return async (input, ctx) => {
1064
+ const result = await blaze(input, ctx);
1065
+ if (result.isOk()) {
1066
+ return result;
1067
+ }
1068
+
1069
+ const { error } = result;
1070
+ if (!(error instanceof TrailsError)) {
1071
+ return result;
1072
+ }
1073
+
1074
+ const matched = findMatchingDetour(detours, error);
1075
+ if (matched === undefined) {
1076
+ return result;
1077
+ }
1078
+
1079
+ return await runDetourRecovery(matched, error, input, ctx);
1080
+ };
1081
+ };
1082
+
1083
+ const wrapWithOutputValidation = (
1084
+ trail: AnyTrail,
1085
+ implementation: Implementation<unknown, unknown>
1086
+ ): Implementation<unknown, unknown> => {
1087
+ const { output } = trail;
1088
+ if (output === undefined) {
1089
+ return implementation;
1090
+ }
1091
+
1092
+ return async (input, ctx) => {
1093
+ const result = await implementation(input, ctx);
1094
+ if (result.isErr()) {
1095
+ return result;
1096
+ }
1097
+
1098
+ const validated = validateOutput(output, result.value);
1099
+ return validated.isErr()
1100
+ ? Result.err(validated.error)
1101
+ : Result.ok(validated.value);
1102
+ };
1103
+ };
1104
+
1105
+ const prepareRunImpl = (
1106
+ trail: AnyTrail,
1107
+ ctx: TrailContext,
1108
+ layers: readonly Layer[],
1109
+ topo: Topo | undefined,
1110
+ options: ExecuteTrailOptions | undefined
1111
+ ): {
1112
+ readonly ctxWithIntrinsics: TrailContext;
1113
+ readonly impl: Implementation<unknown, unknown>;
1114
+ } => {
1115
+ const ctxWithIntrinsics = bindFireToCtx(
1116
+ bindCrossToCtx(ctx, topo, options),
1117
+ topo,
1118
+ options,
1119
+ trail.id
1120
+ );
1121
+ // Detour loop wraps the blaze (inside layer stack, closest to blaze)
1122
+ let impl = wrapWithDetours(
1123
+ bindFireAtLayerBoundary(
1124
+ bindCrossAtLayerBoundary(
1125
+ trail.blaze as Implementation<unknown, unknown>,
1126
+ topo,
1127
+ options
1128
+ ),
1129
+ trail,
1130
+ topo,
1131
+ options
1132
+ ),
1133
+ trail.detours
1134
+ );
1135
+
1136
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
1137
+ const layer = layers[i];
1138
+ if (layer) {
1139
+ impl = bindFireAtLayerBoundary(
1140
+ bindCrossAtLayerBoundary(
1141
+ layer.wrap(trail, impl as never) as Implementation<unknown, unknown>,
1142
+ topo,
1143
+ options
1144
+ ),
1145
+ trail,
1146
+ topo,
1147
+ options
1148
+ );
1149
+ }
1150
+ }
1151
+
1152
+ return {
1153
+ ctxWithIntrinsics,
1154
+ impl: wrapWithOutputValidation(trail, impl),
1155
+ };
1156
+ };
1157
+
1158
+ const runImplWithoutTracing = async (
1159
+ trail: AnyTrail,
1160
+ input: unknown,
1161
+ ctx: TrailContext,
1162
+ layers: readonly Layer[],
1163
+ topo: Topo | undefined,
1164
+ options: ExecuteTrailOptions | undefined
1165
+ ): Promise<Result<unknown, Error>> => {
1166
+ const prepared = prepareRunImpl(
1167
+ trail,
1168
+ buildUntracedContext(ctx),
1169
+ layers,
1170
+ topo,
1171
+ options
1172
+ );
1173
+ try {
1174
+ return await prepared.impl(input, prepared.ctxWithIntrinsics);
1175
+ } finally {
1176
+ await waitForPendingFireDispatches(prepared.ctxWithIntrinsics);
1177
+ }
1178
+ };
1179
+
1180
+ const runTrailWithTracing = async (
1181
+ trail: AnyTrail,
1182
+ input: unknown,
1183
+ ctx: TrailContext,
1184
+ layers: readonly Layer[],
1185
+ topo: Topo | undefined,
1186
+ options: ExecuteTrailOptions | undefined,
1187
+ sink: ReturnType<typeof getTraceSink>
1188
+ ): Promise<Result<unknown, Error>> => {
1189
+ const { record, tracedCtx } = buildTracedContext(trail, ctx, sink);
1190
+ let prepared: ReturnType<typeof prepareRunImpl>;
1191
+
1192
+ try {
1193
+ prepared = prepareRunImpl(trail, tracedCtx, layers, topo, options);
1194
+ } catch (error: unknown) {
1195
+ const status: TraceRecord['status'] =
1196
+ error instanceof CancelledError ? 'cancelled' : 'err';
1197
+ await writeToSink(
1198
+ sink,
1199
+ completeRecord(record, status, categorizeSpanError(error))
1200
+ );
1201
+ throw error;
1202
+ }
1203
+
1204
+ return await runImplWithRootRecord(
1205
+ prepared.impl,
1206
+ input,
1207
+ prepared.ctxWithIntrinsics,
1208
+ record,
1209
+ sink
1210
+ );
118
1211
  };
119
1212
 
120
1213
  const runTrail = async (
121
1214
  trail: AnyTrail,
122
1215
  input: unknown,
123
1216
  ctx: TrailContext,
124
- gates: readonly Gate[]
1217
+ layers: readonly Layer[],
1218
+ topo: Topo | undefined,
1219
+ options: ExecuteTrailOptions | undefined
125
1220
  ): Promise<Result<unknown, Error>> => {
126
- const impl = composeGates([...gates], trail, trail.blaze);
127
- return await impl(input, ctx);
1221
+ const sink = topo?.observe?.trace ?? getTraceSink();
1222
+ return isTracingDisabled(sink)
1223
+ ? await runImplWithoutTracing(trail, input, ctx, layers, topo, options)
1224
+ : await runTrailWithTracing(trail, input, ctx, layers, topo, options, sink);
128
1225
  };
129
1226
 
130
1227
  // ---------------------------------------------------------------------------
@@ -132,7 +1229,103 @@ const runTrail = async (
132
1229
  // ---------------------------------------------------------------------------
133
1230
 
134
1231
  /**
135
- * Execute a trail through the standard validate-context-gates-run pipeline.
1232
+ * Compose the typed layers attached at topo, surface, and trail scope.
1233
+ *
1234
+ * Composition order is topo → surface → trail → execution-supplied → blaze
1235
+ * (outermost-first): trail-scope layers run inside surface/topo layers, and
1236
+ * `executeTrail({ layers })` layers wrap closest to the blaze for per-call
1237
+ * behavior.
1238
+ */
1239
+ const composeAttachedLayers = (
1240
+ trail: AnyTrail,
1241
+ options: ExecuteTrailOptions | undefined
1242
+ ): readonly Layer[] => [
1243
+ ...(options?.topoLayers ?? []),
1244
+ ...(options?.surfaceLayers ?? []),
1245
+ ...trail.layers,
1246
+ ...(options?.layers ?? []),
1247
+ ];
1248
+
1249
+ const isLayerInputMap = (
1250
+ value: unknown
1251
+ ): value is Readonly<Record<string, unknown>> =>
1252
+ value !== null && typeof value === 'object' && !Array.isArray(value);
1253
+
1254
+ const readContextLayerInputs = (
1255
+ ctx: TrailContext
1256
+ ): Result<Readonly<Record<string, unknown>> | undefined, ValidationError> => {
1257
+ const value = ctx.extensions?.[LAYER_INPUTS_KEY];
1258
+ if (value === undefined) {
1259
+ return Result.ok();
1260
+ }
1261
+ return isLayerInputMap(value)
1262
+ ? Result.ok(value)
1263
+ : Result.err(
1264
+ new ValidationError(
1265
+ 'Layer inputs must be an object keyed by layer name',
1266
+ {
1267
+ context: { extensionKey: LAYER_INPUTS_KEY },
1268
+ }
1269
+ )
1270
+ );
1271
+ };
1272
+
1273
+ const validateLayerInputs = (
1274
+ layers: readonly Layer[],
1275
+ layerInputs: Readonly<Record<string, unknown>>
1276
+ ): Result<Readonly<Record<string, unknown>>, ValidationError> => {
1277
+ const validated: Record<string, unknown> = { ...layerInputs };
1278
+ for (const layer of layers) {
1279
+ if (layer.input === undefined) {
1280
+ continue;
1281
+ }
1282
+ const slot = layerInputs[layer.name];
1283
+ if (slot === undefined) {
1284
+ continue;
1285
+ }
1286
+ const parsed = layer.input.safeParse(slot);
1287
+ if (!parsed.success) {
1288
+ return Result.err(
1289
+ new ValidationError(
1290
+ `Invalid input for layer '${layer.name}': ${parsed.error.message}`,
1291
+ {
1292
+ cause: parsed.error,
1293
+ context: { issues: parsed.error.issues, layerName: layer.name },
1294
+ }
1295
+ )
1296
+ );
1297
+ }
1298
+ validated[layer.name] = parsed.data;
1299
+ }
1300
+ return Result.ok(validated);
1301
+ };
1302
+
1303
+ const validateContextLayerInputs = (
1304
+ ctx: TrailContext,
1305
+ layers: readonly Layer[]
1306
+ ): Result<TrailContext, ValidationError> => {
1307
+ const layerInputs = readContextLayerInputs(ctx);
1308
+ if (layerInputs.isErr()) {
1309
+ return Result.err(layerInputs.error);
1310
+ }
1311
+ if (layerInputs.value === undefined) {
1312
+ return Result.ok(ctx);
1313
+ }
1314
+ const validated = validateLayerInputs(layers, layerInputs.value);
1315
+ if (validated.isErr()) {
1316
+ return Result.err(validated.error);
1317
+ }
1318
+ return Result.ok({
1319
+ ...ctx,
1320
+ extensions: {
1321
+ ...ctx.extensions,
1322
+ [LAYER_INPUTS_KEY]: validated.value,
1323
+ },
1324
+ });
1325
+ };
1326
+
1327
+ /**
1328
+ * Execute a trail through the standard validate-context-layers-run pipeline.
136
1329
  *
137
1330
  * The function never throws -- unexpected exceptions are caught and
138
1331
  * returned as `Result.err(InternalError)`.
@@ -143,22 +1336,39 @@ export const executeTrail = async (
143
1336
  options?: ExecuteTrailOptions
144
1337
  ): Promise<Result<unknown, Error>> => {
145
1338
  try {
146
- const validated = validateInput(trail.input, rawInput);
1339
+ const validated = validateInput(
1340
+ options?.validationSchema ?? trail.input,
1341
+ rawInput
1342
+ );
147
1343
  if (validated.isErr()) {
148
1344
  return validated;
149
1345
  }
150
1346
 
151
1347
  const resolvedCtx = await prepareContext(trail, options);
152
1348
  if (resolvedCtx.isErr()) {
153
- return resolvedCtx;
1349
+ return Result.err(resolvedCtx.error);
154
1350
  }
155
1351
 
156
- return await runTrail(
157
- trail,
158
- validated.value,
159
- resolvedCtx.value,
160
- options?.gates ?? []
161
- );
1352
+ const layers = composeAttachedLayers(trail, options);
1353
+ try {
1354
+ const layerCtx = validateContextLayerInputs(
1355
+ resolvedCtx.value.ctx,
1356
+ layers
1357
+ );
1358
+ if (layerCtx.isErr()) {
1359
+ return Result.err(layerCtx.error);
1360
+ }
1361
+ return await runTrail(
1362
+ trail,
1363
+ validated.value,
1364
+ layerCtx.value,
1365
+ layers,
1366
+ options?.topo,
1367
+ options
1368
+ );
1369
+ } finally {
1370
+ resolvedCtx.value.releaseResources();
1371
+ }
162
1372
  } catch (error: unknown) {
163
1373
  const message = error instanceof Error ? error.message : String(error);
164
1374
  return Result.err(new InternalError(message));