@ontrails/core 0.2.0

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