@ontrails/core 1.0.0-beta.17 → 1.0.0-beta.19

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.
package/src/execute.ts CHANGED
@@ -8,11 +8,12 @@
8
8
 
9
9
  import type { z } from 'zod';
10
10
 
11
- import type { AnyTrail } from './trail.js';
11
+ import type { AnyTrail, TrailVersionForkEntry } from './trail.js';
12
12
  import type { Layer } from './layer.js';
13
13
  import type { ResourceOverrideMap } from './resource.js';
14
14
  import type { TraceContext, TraceRecord } from './tracing.js';
15
15
  import type { Topo } from './topo.js';
16
+ import type { TrailVersionReference } from './version-resolution.js';
16
17
 
17
18
  import {
18
19
  buildActivationProvenanceTraceAttrs,
@@ -26,8 +27,9 @@ import {
26
27
  } from './fire.js';
27
28
  import type { BasePermit } from './permits.js';
28
29
  import type {
29
- CrossBatchOptions,
30
- CrossFn,
30
+ ComposeBatchOptions,
31
+ ComposeOptions,
32
+ ComposeFn,
31
33
  Detour,
32
34
  Implementation,
33
35
  TraceFn,
@@ -36,7 +38,7 @@ import type {
36
38
  } from './types.js';
37
39
 
38
40
  import { createTrailContext, passthroughTrace } from './context.js';
39
- import { buildCrossValidationSchema } from './cross-schema.js';
41
+ import { buildComposeValidationSchema } from './compose-schema.js';
40
42
  import {
41
43
  CancelledError,
42
44
  InternalError,
@@ -47,10 +49,10 @@ import {
47
49
  ValidationError,
48
50
  } from './errors.js';
49
51
  import {
50
- claimNextCrossBatchIndex,
51
- createCrossBatchValidationResults,
52
- normalizeCrossBatchConcurrency,
53
- } from './cross-batch.js';
52
+ claimNextComposeBatchIndex,
53
+ createComposeBatchValidationResults,
54
+ normalizeComposeBatchConcurrency,
55
+ } from './compose-batch.js';
54
56
  import { forkCtx } from './internal/fork-ctx.js';
55
57
  import {
56
58
  TRACE_CONTEXT_KEY,
@@ -72,11 +74,22 @@ import { createResourceLookup } from './resource.js';
72
74
  import { createResources } from './resource-config.js';
73
75
  import { LAYER_INPUTS_KEY, SURFACE_KEY } from './types.js';
74
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';
75
83
 
76
84
  type MutableTrailContext = {
77
85
  -readonly [K in keyof TrailContext]: TrailContext[K];
78
86
  };
79
87
 
88
+ type ComposeForwardOptions = Omit<
89
+ ExecuteTrailInternalOptions,
90
+ 'createContext' | 'composeValidation' | 'validationSchema' | 'version'
91
+ >;
92
+
80
93
  // ---------------------------------------------------------------------------
81
94
  // Options
82
95
  // ---------------------------------------------------------------------------
@@ -90,7 +103,7 @@ export interface ExecuteTrailOptions {
90
103
  /**
91
104
  * Typed layers supplied for this execution.
92
105
  *
93
- * Layers compose around the trail implementation. Layers without `input`
106
+ * Layers compose around the blaze. Layers without `input`
94
107
  * schemas are surface-invisible wrappers for concerns such as tenant guards,
95
108
  * rate limiting, circuit breaking, or custom audit logging.
96
109
  */
@@ -153,15 +166,41 @@ export interface ExecuteTrailOptions {
153
166
  * @see TRL-473 for the CLI projection contract.
154
167
  */
155
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 projected
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;
156
195
  /**
157
196
  * Override the validation schema used for input validation.
158
197
  *
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
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
162
201
  * accepts both public and composition-only fields.
163
202
  *
164
- * Used by the cross execution path; not part of the public API.
203
+ * Used by the compose execution path; not part of the public API.
165
204
  *
166
205
  * @internal
167
206
  */
@@ -174,7 +213,7 @@ export interface ExecuteTrailOptions {
174
213
 
175
214
  const applyContextOverrides = (
176
215
  base: TrailContextInit,
177
- options?: ExecuteTrailOptions
216
+ options?: ExecuteTrailInternalOptions
178
217
  ): TrailContextInit => {
179
218
  const withOverrides = options?.ctx
180
219
  ? {
@@ -202,7 +241,7 @@ const applyContextOverrides = (
202
241
  return withPermit;
203
242
  }
204
243
  // Merge per-layer inputs onto any inherited LAYER_INPUTS_KEY slot so
205
- // crossed/forked contexts can preserve outer-surface routing.
244
+ // composed/forked contexts can preserve outer-surface metadata.
206
245
  const inheritedExtensions = withPermit.extensions ?? {};
207
246
  const inheritedLayerInputs = (inheritedExtensions[LAYER_INPUTS_KEY] ?? {}) as
208
247
  | Readonly<Record<string, unknown>>
@@ -221,7 +260,7 @@ const applyContextOverrides = (
221
260
 
222
261
  const bindResourceLookup = (
223
262
  resolved: TrailContextInit,
224
- options?: ExecuteTrailOptions
263
+ options?: ExecuteTrailInternalOptions
225
264
  ): TrailContext => {
226
265
  if (
227
266
  options?.ctx?.extensions === undefined &&
@@ -250,7 +289,7 @@ const bindResourceLookup = (
250
289
  * already carries.
251
290
  */
252
291
  const resolveContext = async (
253
- options?: ExecuteTrailOptions
292
+ options?: ExecuteTrailInternalOptions
254
293
  ): Promise<TrailContext> => {
255
294
  const seed = options?.createContext
256
295
  ? await options.createContext()
@@ -335,7 +374,7 @@ const enforcePermitRequirement = (
335
374
 
336
375
  const prepareContext = async (
337
376
  trail: AnyTrail,
338
- options?: ExecuteTrailOptions
377
+ options?: ExecuteTrailInternalOptions
339
378
  ): Promise<
340
379
  Result<
341
380
  { readonly ctx: TrailContext; readonly releaseResources: () => void },
@@ -435,8 +474,8 @@ const extractPermit = (
435
474
  * The returned function reads the *current* trace context from its captured
436
475
  * parent. That means direct nesting (`ctx.trace('a', () => ctx.trace('b',
437
476
  * ...))`) 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
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
440
479
  * is implemented in a later phase. For Phase 1, sibling spans under the
441
480
  * trail's root are the supported shape.
442
481
  */
@@ -464,7 +503,7 @@ const buildTracedContext = (
464
503
  sink: ReturnType<typeof getTraceSink>
465
504
  ): { readonly record: TraceRecord; readonly tracedCtx: TrailContext } => {
466
505
  // 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
506
+ // the current trail was invoked via ctx.compose or ctx.fire), inherit its
468
507
  // traceId/rootId so the trace tree spans trail boundaries. Otherwise this
469
508
  // execution becomes a fresh root.
470
509
  const parent = ctx.extensions?.[TRACE_CONTEXT_KEY] as
@@ -553,25 +592,35 @@ const runImplWithRootRecord = async (
553
592
  }
554
593
  };
555
594
 
556
- const resolveCrossTarget = (
595
+ interface ResolvedComposeTarget {
596
+ readonly trail: AnyTrail;
597
+ readonly version?: TrailVersionReference | undefined;
598
+ }
599
+
600
+ const resolveComposeTarget = (
557
601
  trailOrId: AnyTrail | string,
558
602
  topo: Topo | undefined
559
- ): Result<AnyTrail, Error> => {
603
+ ): Result<ResolvedComposeTarget, Error> => {
560
604
  if (typeof trailOrId !== 'string') {
561
- return Result.ok(trailOrId);
605
+ return Result.ok({ trail: trailOrId });
606
+ }
607
+
608
+ const parsed = parseTrailIdVersionReference(trailOrId);
609
+ if (parsed.isErr()) {
610
+ return parsed;
562
611
  }
563
612
 
564
613
  if (topo === undefined) {
565
614
  return Result.err(
566
615
  new NotFoundError(
567
- `Trail "${trailOrId}" cannot be crossed without topo access`
616
+ `Trail "${trailOrId}" cannot be composed without topo access`
568
617
  )
569
618
  );
570
619
  }
571
620
 
572
- const target = topo.get(trailOrId);
621
+ const target = topo.get(parsed.value.id);
573
622
  return target
574
- ? Result.ok(target)
623
+ ? Result.ok({ trail: target, version: parsed.value.version })
575
624
  : Result.err(
576
625
  new NotFoundError(
577
626
  `Trail "${trailOrId}" not found in topo "${topo.name}"`
@@ -613,7 +662,7 @@ const deriveConcurrentBranchObserveMetadata = (
613
662
  return {
614
663
  ...readObserveLoggerMetadata(ctx),
615
664
  branchIndex,
616
- crossedTrailId: target.id,
665
+ composedTrailId: target.id,
617
666
  };
618
667
  };
619
668
 
@@ -645,19 +694,19 @@ const deriveConcurrentBranchLogger = (
645
694
  ) =>
646
695
  ctx.logger?.child?.({
647
696
  branchIndex,
648
- crossedTrailId: target.id,
697
+ composedTrailId: target.id,
649
698
  }) ?? ctx.logger;
650
699
 
651
700
  /**
652
- * Build a child context for one concurrent crossing branch.
701
+ * Build a child context for one concurrent composing branch.
653
702
  *
654
- * Concurrent crossings should not inherit already-resolved resource instances
703
+ * Concurrent compositions should not inherit already-resolved resource instances
655
704
  * from the parent execution scope. Stripping resource IDs from extensions
656
705
  * forces each branch to resolve its own scope while still carrying forward
657
706
  * request-scoped values like tracing, surface identity, permits, and the
658
707
  * shared AbortSignal.
659
708
  *
660
- * `cross`, `fire`, and `resource` are cleared so the child execution can
709
+ * `compose`, `fire`, and `resource` are cleared so the child execution can
661
710
  * rebind them to the branch-local context instead of reusing closures that
662
711
  * capture the parent scope.
663
712
  */
@@ -672,115 +721,131 @@ const buildConcurrentBranchContext = (
672
721
  logger: deriveConcurrentBranchLogger(ctx, target, branchIndex),
673
722
  });
674
723
 
675
- const executeResolvedCrossTarget = async (
724
+ const executeResolvedComposeTarget = async (
676
725
  target: AnyTrail,
677
726
  input: unknown,
678
727
  ctx: TrailContext,
679
728
  topo: Topo | undefined,
680
- forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
729
+ forwarded: ComposeForwardOptions,
730
+ version?: TrailVersionReference | undefined
681
731
  ): Promise<Result<unknown, Error>> =>
682
732
  await // eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
683
- executeTrail(target, input, {
733
+ executeTrailInternal(target, input, {
684
734
  ...forwarded,
735
+ composeValidation: true,
685
736
  ctx,
686
737
  topo,
687
- validationSchema: buildCrossValidationSchema(target),
738
+ ...(version === undefined ? {} : { version }),
739
+ validationSchema: buildComposeValidationSchema(target),
688
740
  });
689
741
 
690
- const executeCrossTarget = async (
742
+ const executeComposeTarget = async (
691
743
  trailOrId: AnyTrail | string,
692
744
  input: unknown,
693
745
  ctx: TrailContext,
694
746
  topo: Topo | undefined,
695
- forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
747
+ forwarded: ComposeForwardOptions,
748
+ composeOptions?: ComposeOptions | undefined
696
749
  ): Promise<Result<unknown, Error>> => {
697
- const target = resolveCrossTarget(trailOrId, topo);
750
+ const target = resolveComposeTarget(trailOrId, topo);
698
751
  if (target.isErr()) {
699
752
  return target;
700
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
+ }
701
764
 
702
- return await executeResolvedCrossTarget(
703
- target.value,
765
+ return await executeResolvedComposeTarget(
766
+ target.value.trail,
704
767
  input,
705
768
  ctx,
706
769
  topo,
707
- forwarded
770
+ forwarded,
771
+ composeOptions?.version ?? target.value.version
708
772
  );
709
773
  };
710
774
 
711
- type CrossBatchCall = readonly [AnyTrail | string, unknown];
775
+ type ComposeBatchCall = readonly [AnyTrail | string, unknown];
712
776
 
713
- const executeConcurrentCrossBatchCall = async (
714
- call: CrossBatchCall,
777
+ const executeConcurrentComposeBatchCall = async (
778
+ call: ComposeBatchCall,
715
779
  branchIndex: number,
716
780
  ctx: TrailContext,
717
781
  topo: Topo | undefined,
718
- forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
782
+ forwarded: ComposeForwardOptions
719
783
  ): Promise<Result<unknown, Error>> => {
720
784
  const [trailOrId, batchInput] = call;
721
- const target = resolveCrossTarget(trailOrId, topo);
785
+ const target = resolveComposeTarget(trailOrId, topo);
722
786
  if (target.isErr()) {
723
787
  return target;
724
788
  }
725
789
 
726
- return await executeResolvedCrossTarget(
727
- target.value,
790
+ return await executeResolvedComposeTarget(
791
+ target.value.trail,
728
792
  batchInput,
729
- buildConcurrentBranchContext(ctx, target.value, topo, branchIndex),
793
+ buildConcurrentBranchContext(ctx, target.value.trail, topo, branchIndex),
730
794
  topo,
731
- forwarded
795
+ forwarded,
796
+ target.value.version
732
797
  );
733
798
  };
734
799
 
735
- const executeUnlimitedCrossBatch = async (
736
- calls: readonly CrossBatchCall[],
800
+ const executeUnlimitedComposeBatch = async (
801
+ calls: readonly ComposeBatchCall[],
737
802
  ctx: TrailContext,
738
803
  topo: Topo | undefined,
739
- forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
804
+ forwarded: ComposeForwardOptions
740
805
  ): Promise<Result<unknown, Error>[]> =>
741
806
  await Promise.all(
742
807
  calls.map((call, branchIndex) =>
743
- executeConcurrentCrossBatchCall(call, branchIndex, ctx, topo, forwarded)
808
+ executeConcurrentComposeBatchCall(call, branchIndex, ctx, topo, forwarded)
744
809
  )
745
810
  );
746
811
 
747
- const createCrossBatchResults = (
748
- calls: readonly CrossBatchCall[]
812
+ const createComposeBatchResults = (
813
+ calls: readonly ComposeBatchCall[]
749
814
  ): Result<unknown, Error>[] =>
750
815
  Array.from<Result<unknown, Error>>({ length: calls.length });
751
816
 
752
- const executeLimitedCrossBatch = async (
753
- calls: readonly CrossBatchCall[],
817
+ const executeLimitedComposeBatch = async (
818
+ calls: readonly ComposeBatchCall[],
754
819
  ctx: TrailContext,
755
820
  topo: Topo | undefined,
756
- forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
821
+ forwarded: ComposeForwardOptions,
757
822
  limit: number
758
823
  ): Promise<Result<unknown, Error>[]> => {
759
- const results = createCrossBatchResults(calls);
824
+ const results = createComposeBatchResults(calls);
760
825
  const nextIndex = { value: 0 };
761
826
 
762
827
  const runWorker = async () => {
763
828
  while (true) {
764
- const branchIndex = claimNextCrossBatchIndex(nextIndex, calls);
829
+ const branchIndex = claimNextComposeBatchIndex(nextIndex, calls);
765
830
  if (branchIndex === undefined) {
766
831
  return;
767
832
  }
768
833
 
769
834
  const call = calls[branchIndex];
770
835
  if (call === undefined) {
771
- // Defensive: `claimNextCrossBatchIndex` only returns indices within
836
+ // Defensive: `claimNextComposeBatchIndex` only returns indices within
772
837
  // bounds, so this slot should always be populated. If it ever isn't,
773
838
  // surface a clear InternalError in place of the missing slot and keep
774
839
  // the worker loop running so sibling branches still get processed.
775
840
  results[branchIndex] = Result.err(
776
841
  new InternalError(
777
- `unreachable: concurrent cross batch call missing at index ${branchIndex}`
842
+ `unreachable: concurrent compose batch call missing at index ${branchIndex}`
778
843
  )
779
844
  );
780
845
  continue;
781
846
  }
782
847
 
783
- results[branchIndex] = await executeConcurrentCrossBatchCall(
848
+ results[branchIndex] = await executeConcurrentComposeBatchCall(
784
849
  call,
785
850
  branchIndex,
786
851
  ctx,
@@ -794,81 +859,85 @@ const executeLimitedCrossBatch = async (
794
859
  return results;
795
860
  };
796
861
 
797
- const executeCrossBatch = async (
798
- calls: readonly CrossBatchCall[],
862
+ const executeComposeBatch = async (
863
+ calls: readonly ComposeBatchCall[],
799
864
  ctx: TrailContext,
800
865
  topo: Topo | undefined,
801
- forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
802
- batchOptions?: CrossBatchOptions
866
+ forwarded: ComposeForwardOptions,
867
+ batchOptions?: ComposeBatchOptions
803
868
  ): Promise<Result<unknown, Error>[]> => {
804
869
  if (calls.length === 0) {
805
870
  return [];
806
871
  }
807
872
 
808
- const concurrency = normalizeCrossBatchConcurrency(batchOptions);
873
+ const concurrency = normalizeComposeBatchConcurrency(batchOptions);
809
874
  if (concurrency.isErr()) {
810
- return createCrossBatchValidationResults(calls, concurrency.error);
875
+ return createComposeBatchValidationResults(calls, concurrency.error);
811
876
  }
812
877
 
813
878
  const limit = concurrency.value ?? calls.length;
814
879
  return limit >= calls.length
815
- ? await executeUnlimitedCrossBatch(calls, ctx, topo, forwarded)
816
- : await executeLimitedCrossBatch(calls, ctx, topo, forwarded, limit);
880
+ ? await executeUnlimitedComposeBatch(calls, ctx, topo, forwarded)
881
+ : await executeLimitedComposeBatch(calls, ctx, topo, forwarded, limit);
817
882
  };
818
883
 
819
- const bindCrossToCtx = (
884
+ const bindComposeToCtx = (
820
885
  ctx: TrailContext,
821
886
  topo: Topo | undefined,
822
- options: ExecuteTrailOptions | undefined
887
+ options: ExecuteTrailInternalOptions | undefined
823
888
  ): TrailContext => {
824
- if (ctx.cross !== undefined) {
889
+ if (ctx.compose !== undefined) {
825
890
  return ctx;
826
891
  }
827
892
 
828
893
  const {
829
894
  createContext: _omit,
895
+ composeValidation: _omitComposeValidation,
830
896
  validationSchema: _omitSchema,
897
+ version: _omitVersion,
831
898
  ...forwarded
832
899
  } = options ?? {};
833
- const cross = (async (
900
+ const compose = (async (
834
901
  trailOrCalls:
835
902
  | AnyTrail
836
903
  | string
837
904
  | readonly (readonly [AnyTrail | string, unknown])[],
838
- inputOrOptions?: CrossBatchOptions | unknown
905
+ inputOrOptions?: unknown,
906
+ singleOptions?: ComposeOptions
839
907
  ) => {
840
908
  if (Array.isArray(trailOrCalls)) {
841
- return await executeCrossBatch(
909
+ return await executeComposeBatch(
842
910
  trailOrCalls,
843
911
  ctx,
844
912
  topo,
845
913
  forwarded,
846
- inputOrOptions as CrossBatchOptions | undefined
914
+ inputOrOptions as ComposeBatchOptions | undefined
847
915
  );
848
916
  }
849
917
 
850
- return await executeCrossTarget(
918
+ return await executeComposeTarget(
851
919
  trailOrCalls as AnyTrail | string,
852
920
  inputOrOptions,
853
921
  ctx,
854
922
  topo,
855
- forwarded
923
+ forwarded,
924
+ singleOptions
856
925
  );
857
- }) as CrossFn;
926
+ }) as ComposeFn;
858
927
 
859
928
  return {
860
929
  ...ctx,
861
- cross,
930
+ compose,
862
931
  };
863
932
  };
864
933
 
865
934
  const bindFireToCtx = (
866
935
  ctx: TrailContext,
867
936
  topo: Topo | undefined,
868
- options: ExecuteTrailOptions | undefined,
937
+ options: ExecuteTrailInternalOptions | undefined,
869
938
  producerTrailId?: string | undefined
870
939
  ): TrailContext => {
871
- // Symmetric with bindCrossToCtx: a caller-supplied ctx.fire (e.g. test
940
+ // Symmetric with bindComposeToCtx: a caller-supplied ctx.fire (e.g. test
872
941
  // helper, scenario harness, or runtime intercepting signal fan-out) is
873
942
  // preserved as-is. Without this guard, passing both `topo: app` and a
874
943
  // custom `ctx.fire` would silently clobber the injected mock with the
@@ -886,10 +955,12 @@ const bindFireToCtx = (
886
955
  // already-resolved ctx via `consumerCtx`, and re-running the factory would
887
956
  // clobber that.
888
957
  // Strip createContext (consumers inherit resolved ctx) and validationSchema
889
- // (consumers validate against their own schema, not the producer's cross schema).
958
+ // (consumers validate against their own schema, not the producer's compose schema).
890
959
  const {
891
960
  createContext: _omit,
961
+ composeValidation: _omitComposeValidation,
892
962
  validationSchema: _omitSchema,
963
+ version: _omitVersion,
893
964
  ...forwarded
894
965
  } = options ?? {};
895
966
  const trackedCtx = withFireDispatchTracking(ctx);
@@ -898,7 +969,7 @@ const bindFireToCtx = (
898
969
  trackedCtx,
899
970
  (consumer, input, consumerCtx) =>
900
971
  // eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
901
- executeTrail(consumer, input, {
972
+ executeTrailInternal(consumer, input, {
902
973
  ...forwarded,
903
974
  ctx: consumerCtx,
904
975
  topo,
@@ -908,20 +979,20 @@ const bindFireToCtx = (
908
979
  return { ...trackedCtx, fire };
909
980
  };
910
981
 
911
- const bindCrossAtLayerBoundary =
982
+ const bindComposeAtLayerBoundary =
912
983
  <I, O>(
913
984
  implementation: Implementation<I, O>,
914
985
  topo: Topo | undefined,
915
- options: ExecuteTrailOptions | undefined
986
+ options: ExecuteTrailInternalOptions | undefined
916
987
  ): Implementation<I, O> =>
917
988
  (input, ctx) =>
918
- implementation(input, bindCrossToCtx(ctx, topo, options));
989
+ implementation(input, bindComposeToCtx(ctx, topo, options));
919
990
 
920
991
  const bindFireAtLayerBoundary = <I, O>(
921
992
  implementation: Implementation<I, O>,
922
993
  trail: AnyTrail,
923
994
  topo: Topo | undefined,
924
- options: ExecuteTrailOptions | undefined
995
+ options: ExecuteTrailInternalOptions | undefined
925
996
  ): Implementation<I, O> => {
926
997
  if (topo === undefined) {
927
998
  return implementation;
@@ -948,7 +1019,7 @@ const findMatchingDetour = (
948
1019
  ): Detour<any, any, TrailsError> | undefined =>
949
1020
  detours.find((d) => error instanceof d.on);
950
1021
 
951
- /** Execute a single detour recovery attempt, routing through ctx.trace when available. */
1022
+ /** Execute a single detour recovery attempt, tracing through ctx.trace when available. */
952
1023
  const executeDetourAttempt = async (
953
1024
  /* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
954
1025
  detour: Detour<any, any, TrailsError>,
@@ -1107,13 +1178,13 @@ const prepareRunImpl = (
1107
1178
  ctx: TrailContext,
1108
1179
  layers: readonly Layer[],
1109
1180
  topo: Topo | undefined,
1110
- options: ExecuteTrailOptions | undefined
1181
+ options: ExecuteTrailInternalOptions | undefined
1111
1182
  ): {
1112
1183
  readonly ctxWithIntrinsics: TrailContext;
1113
1184
  readonly impl: Implementation<unknown, unknown>;
1114
1185
  } => {
1115
1186
  const ctxWithIntrinsics = bindFireToCtx(
1116
- bindCrossToCtx(ctx, topo, options),
1187
+ bindComposeToCtx(ctx, topo, options),
1117
1188
  topo,
1118
1189
  options,
1119
1190
  trail.id
@@ -1121,7 +1192,7 @@ const prepareRunImpl = (
1121
1192
  // Detour loop wraps the blaze (inside layer stack, closest to blaze)
1122
1193
  let impl = wrapWithDetours(
1123
1194
  bindFireAtLayerBoundary(
1124
- bindCrossAtLayerBoundary(
1195
+ bindComposeAtLayerBoundary(
1125
1196
  trail.blaze as Implementation<unknown, unknown>,
1126
1197
  topo,
1127
1198
  options
@@ -1137,7 +1208,7 @@ const prepareRunImpl = (
1137
1208
  const layer = layers[i];
1138
1209
  if (layer) {
1139
1210
  impl = bindFireAtLayerBoundary(
1140
- bindCrossAtLayerBoundary(
1211
+ bindComposeAtLayerBoundary(
1141
1212
  layer.wrap(trail, impl as never) as Implementation<unknown, unknown>,
1142
1213
  topo,
1143
1214
  options
@@ -1161,7 +1232,7 @@ const runImplWithoutTracing = async (
1161
1232
  ctx: TrailContext,
1162
1233
  layers: readonly Layer[],
1163
1234
  topo: Topo | undefined,
1164
- options: ExecuteTrailOptions | undefined
1235
+ options: ExecuteTrailInternalOptions | undefined
1165
1236
  ): Promise<Result<unknown, Error>> => {
1166
1237
  const prepared = prepareRunImpl(
1167
1238
  trail,
@@ -1183,7 +1254,7 @@ const runTrailWithTracing = async (
1183
1254
  ctx: TrailContext,
1184
1255
  layers: readonly Layer[],
1185
1256
  topo: Topo | undefined,
1186
- options: ExecuteTrailOptions | undefined,
1257
+ options: ExecuteTrailInternalOptions | undefined,
1187
1258
  sink: ReturnType<typeof getTraceSink>
1188
1259
  ): Promise<Result<unknown, Error>> => {
1189
1260
  const { record, tracedCtx } = buildTracedContext(trail, ctx, sink);
@@ -1216,7 +1287,7 @@ const runTrail = async (
1216
1287
  ctx: TrailContext,
1217
1288
  layers: readonly Layer[],
1218
1289
  topo: Topo | undefined,
1219
- options: ExecuteTrailOptions | undefined
1290
+ options: ExecuteTrailInternalOptions | undefined
1220
1291
  ): Promise<Result<unknown, Error>> => {
1221
1292
  const sink = topo?.observe?.trace ?? getTraceSink();
1222
1293
  return isTracingDisabled(sink)
@@ -1238,7 +1309,7 @@ const runTrail = async (
1238
1309
  */
1239
1310
  const composeAttachedLayers = (
1240
1311
  trail: AnyTrail,
1241
- options: ExecuteTrailOptions | undefined
1312
+ options: ExecuteTrailInternalOptions | undefined
1242
1313
  ): readonly Layer[] => [
1243
1314
  ...(options?.topoLayers ?? []),
1244
1315
  ...(options?.surfaceLayers ?? []),
@@ -1246,6 +1317,125 @@ const composeAttachedLayers = (
1246
1317
  ...(options?.layers ?? []),
1247
1318
  ];
1248
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
+ blaze: _blaze,
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
+ blaze: entry.blaze,
1370
+ composes: Object.freeze([...(entry.composes ?? [])]),
1371
+ detours: Object.freeze([...(entry.detours ?? [])]),
1372
+ ...(entry.composeInput === undefined
1373
+ ? {}
1374
+ : { composeInput: entry.composeInput }),
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 Result.err(resolved.error);
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
+
1249
1439
  const isLayerInputMap = (
1250
1440
  value: unknown
1251
1441
  ): value is Readonly<Record<string, unknown>> =>
@@ -1330,12 +1520,16 @@ const validateContextLayerInputs = (
1330
1520
  * The function never throws -- unexpected exceptions are caught and
1331
1521
  * returned as `Result.err(InternalError)`.
1332
1522
  */
1333
- export const executeTrail = async (
1523
+ const executeTrailInternal = async (
1334
1524
  trail: AnyTrail,
1335
1525
  rawInput: unknown,
1336
- options?: ExecuteTrailOptions
1526
+ options?: ExecuteTrailInternalOptions
1337
1527
  ): Promise<Result<unknown, Error>> => {
1338
1528
  try {
1529
+ if (options?.version !== undefined) {
1530
+ return await executeRequestedTrailVersion(trail, rawInput, options);
1531
+ }
1532
+
1339
1533
  const validated = validateInput(
1340
1534
  options?.validationSchema ?? trail.input,
1341
1535
  rawInput
@@ -1374,3 +1568,10 @@ export const executeTrail = async (
1374
1568
  return Result.err(new InternalError(message));
1375
1569
  }
1376
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);